feat: 설비 상세 패널 및 임시 이동 기능 구현

- 설비 마커 클릭 시 슬라이드 패널로 상세 정보 표시
- 설비 사진 업로드/삭제 기능
- 설비 임시 이동 기능 (3단계 지도 기반 선택)
  - Step 1: 공장 선택
  - Step 2: 레이아웃 지도에서 작업장 선택
  - Step 3: 상세 지도에서 위치 선택
- 설비 외부 반출/반입 기능
- 설비 수리 신청 기능 (기존 신고 시스템 연동)
- DB 마이그레이션 추가 (사진, 임시이동, 외부반출 테이블)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-02-04 13:45:56 +09:00
parent 90d3e32992
commit 4d83f10b07
13 changed files with 6043 additions and 74 deletions

View File

@@ -1,5 +1,6 @@
// controllers/equipmentController.js
const EquipmentModel = require('../models/equipmentModel');
const imageUploadService = require('../services/imageUploadService');
const EquipmentController = {
// CREATE - 설비 생성
@@ -379,6 +380,529 @@ const EquipmentController = {
message: '서버 오류가 발생했습니다.'
});
}
},
// ==========================================
// 설비 사진 관리
// ==========================================
// ADD PHOTO - 설비 사진 추가
addPhoto: async (req, res) => {
try {
const equipmentId = req.params.id;
const { photo_base64, description, display_order } = req.body;
if (!photo_base64) {
return res.status(400).json({
success: false,
message: '사진 데이터가 필요합니다.'
});
}
// Base64 이미지를 파일로 저장
const photoPath = await imageUploadService.saveBase64Image(
photo_base64,
'equipment',
'equipments'
);
if (!photoPath) {
return res.status(500).json({
success: false,
message: '사진 저장에 실패했습니다.'
});
}
// DB에 사진 정보 저장
const photoData = {
photo_path: photoPath,
description: description || null,
display_order: display_order || 0,
uploaded_by: req.user?.user_id || null
};
EquipmentModel.addPhoto(equipmentId, photoData, (error, result) => {
if (error) {
console.error('사진 정보 저장 오류:', error);
return res.status(500).json({
success: false,
message: '사진 정보 저장 중 오류가 발생했습니다.'
});
}
res.status(201).json({
success: true,
message: '사진이 성공적으로 추가되었습니다.',
data: result
});
});
} catch (error) {
console.error('사진 추가 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// GET PHOTOS - 설비 사진 조회
getPhotos: (req, res) => {
try {
const equipmentId = req.params.id;
EquipmentModel.getPhotos(equipmentId, (error, results) => {
if (error) {
console.error('사진 조회 오류:', error);
return res.status(500).json({
success: false,
message: '사진 조회 중 오류가 발생했습니다.'
});
}
res.json({
success: true,
data: results
});
});
} catch (error) {
console.error('사진 조회 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// DELETE PHOTO - 설비 사진 삭제
deletePhoto: async (req, res) => {
try {
const photoId = req.params.photoId;
EquipmentModel.deletePhoto(photoId, async (error, result) => {
if (error) {
if (error.message === 'Photo not found') {
return res.status(404).json({
success: false,
message: '사진을 찾을 수 없습니다.'
});
}
console.error('사진 삭제 오류:', error);
return res.status(500).json({
success: false,
message: '사진 삭제 중 오류가 발생했습니다.'
});
}
// 파일 시스템에서 사진 삭제
if (result.photo_path) {
await imageUploadService.deleteFile(result.photo_path);
}
res.json({
success: true,
message: '사진이 성공적으로 삭제되었습니다.',
data: { photo_id: photoId }
});
});
} catch (error) {
console.error('사진 삭제 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// ==========================================
// 설비 임시 이동
// ==========================================
// MOVE TEMPORARILY - 설비 임시 이동
moveTemporarily: (req, res) => {
try {
const equipmentId = req.params.id;
const moveData = {
target_workplace_id: req.body.target_workplace_id,
target_x_percent: req.body.target_x_percent,
target_y_percent: req.body.target_y_percent,
target_width_percent: req.body.target_width_percent,
target_height_percent: req.body.target_height_percent,
from_workplace_id: req.body.from_workplace_id,
from_x_percent: req.body.from_x_percent,
from_y_percent: req.body.from_y_percent,
reason: req.body.reason,
moved_by: req.user?.user_id || null
};
if (!moveData.target_workplace_id || moveData.target_x_percent === undefined || moveData.target_y_percent === undefined) {
return res.status(400).json({
success: false,
message: '이동할 작업장과 위치가 필요합니다.'
});
}
EquipmentModel.moveTemporarily(equipmentId, moveData, (error, result) => {
if (error) {
console.error('설비 이동 오류:', error);
return res.status(500).json({
success: false,
message: '설비 이동 중 오류가 발생했습니다.'
});
}
res.json({
success: true,
message: '설비가 임시 이동되었습니다.',
data: result
});
});
} catch (error) {
console.error('설비 이동 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// RETURN TO ORIGINAL - 설비 원위치 복귀
returnToOriginal: (req, res) => {
try {
const equipmentId = req.params.id;
const userId = req.user?.user_id || null;
EquipmentModel.returnToOriginal(equipmentId, userId, (error, result) => {
if (error) {
if (error.message === 'Equipment not found') {
return res.status(404).json({
success: false,
message: '설비를 찾을 수 없습니다.'
});
}
console.error('설비 복귀 오류:', error);
return res.status(500).json({
success: false,
message: '설비 복귀 중 오류가 발생했습니다.'
});
}
res.json({
success: true,
message: '설비가 원위치로 복귀되었습니다.',
data: result
});
});
} catch (error) {
console.error('설비 복귀 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// GET TEMPORARILY MOVED - 임시 이동된 설비 목록
getTemporarilyMoved: (req, res) => {
try {
EquipmentModel.getTemporarilyMoved((error, results) => {
if (error) {
console.error('임시 이동 설비 조회 오류:', error);
return res.status(500).json({
success: false,
message: '임시 이동 설비 조회 중 오류가 발생했습니다.'
});
}
res.json({
success: true,
data: results
});
});
} catch (error) {
console.error('임시 이동 설비 조회 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// GET MOVE LOGS - 설비 이동 이력 조회
getMoveLogs: (req, res) => {
try {
const equipmentId = req.params.id;
EquipmentModel.getMoveLogs(equipmentId, (error, results) => {
if (error) {
console.error('이동 이력 조회 오류:', error);
return res.status(500).json({
success: false,
message: '이동 이력 조회 중 오류가 발생했습니다.'
});
}
res.json({
success: true,
data: results
});
});
} catch (error) {
console.error('이동 이력 조회 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// ==========================================
// 설비 외부 반출/반입
// ==========================================
// EXPORT EQUIPMENT - 설비 외부 반출
exportEquipment: (req, res) => {
try {
const equipmentId = req.params.id;
const exportData = {
equipment_id: equipmentId,
export_date: req.body.export_date,
expected_return_date: req.body.expected_return_date,
destination: req.body.destination,
reason: req.body.reason,
notes: req.body.notes,
is_repair: req.body.is_repair || false,
exported_by: req.user?.user_id || null
};
EquipmentModel.exportEquipment(exportData, (error, result) => {
if (error) {
console.error('설비 반출 오류:', error);
return res.status(500).json({
success: false,
message: '설비 반출 중 오류가 발생했습니다.'
});
}
res.status(201).json({
success: true,
message: '설비가 외부로 반출되었습니다.',
data: result
});
});
} catch (error) {
console.error('설비 반출 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// RETURN EQUIPMENT - 설비 반입 (외부에서 복귀)
returnEquipment: (req, res) => {
try {
const logId = req.params.logId;
const returnData = {
return_date: req.body.return_date,
new_status: req.body.new_status || 'active',
notes: req.body.notes,
returned_by: req.user?.user_id || null
};
EquipmentModel.returnEquipment(logId, returnData, (error, result) => {
if (error) {
if (error.message === 'Export log not found') {
return res.status(404).json({
success: false,
message: '반출 기록을 찾을 수 없습니다.'
});
}
console.error('설비 반입 오류:', error);
return res.status(500).json({
success: false,
message: '설비 반입 중 오류가 발생했습니다.'
});
}
res.json({
success: true,
message: '설비가 반입되었습니다.',
data: result
});
});
} catch (error) {
console.error('설비 반입 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// GET EXTERNAL LOGS - 설비 외부 반출 이력 조회
getExternalLogs: (req, res) => {
try {
const equipmentId = req.params.id;
EquipmentModel.getExternalLogs(equipmentId, (error, results) => {
if (error) {
console.error('반출 이력 조회 오류:', error);
return res.status(500).json({
success: false,
message: '반출 이력 조회 중 오류가 발생했습니다.'
});
}
res.json({
success: true,
data: results
});
});
} catch (error) {
console.error('반출 이력 조회 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// GET EXPORTED EQUIPMENTS - 현재 외부 반출 중인 설비 목록
getExportedEquipments: (req, res) => {
try {
EquipmentModel.getExportedEquipments((error, results) => {
if (error) {
console.error('반출 중 설비 조회 오류:', error);
return res.status(500).json({
success: false,
message: '반출 중 설비 조회 중 오류가 발생했습니다.'
});
}
res.json({
success: true,
data: results
});
});
} catch (error) {
console.error('반출 중 설비 조회 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// ==========================================
// 설비 수리 신청
// ==========================================
// CREATE REPAIR REQUEST - 수리 신청
createRepairRequest: async (req, res) => {
try {
const equipmentId = req.params.id;
const { photo_base64_list, description, item_id, workplace_id } = req.body;
// 사진 저장 (있는 경우)
let photoPaths = [];
if (photo_base64_list && photo_base64_list.length > 0) {
for (const base64 of photo_base64_list) {
const path = await imageUploadService.saveBase64Image(base64, 'repair', 'issues');
if (path) photoPaths.push(path);
}
}
const requestData = {
equipment_id: equipmentId,
item_id: item_id || null,
workplace_id: workplace_id || null,
description: description || null,
photo_paths: photoPaths.length > 0 ? photoPaths : null,
reported_by: req.user?.user_id || null
};
EquipmentModel.createRepairRequest(requestData, (error, result) => {
if (error) {
if (error.message === '설비 수리 카테고리가 없습니다') {
return res.status(400).json({
success: false,
message: error.message
});
}
console.error('수리 신청 오류:', error);
return res.status(500).json({
success: false,
message: '수리 신청 중 오류가 발생했습니다.'
});
}
res.status(201).json({
success: true,
message: '수리 신청이 접수되었습니다.',
data: result
});
});
} catch (error) {
console.error('수리 신청 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// GET REPAIR HISTORY - 설비 수리 이력 조회
getRepairHistory: (req, res) => {
try {
const equipmentId = req.params.id;
EquipmentModel.getRepairHistory(equipmentId, (error, results) => {
if (error) {
console.error('수리 이력 조회 오류:', error);
return res.status(500).json({
success: false,
message: '수리 이력 조회 중 오류가 발생했습니다.'
});
}
res.json({
success: true,
data: results
});
});
} catch (error) {
console.error('수리 이력 조회 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
},
// GET REPAIR CATEGORIES - 설비 수리 항목 목록 조회
getRepairCategories: (req, res) => {
try {
EquipmentModel.getRepairCategories((error, results) => {
if (error) {
console.error('수리 항목 조회 오류:', error);
return res.status(500).json({
success: false,
message: '수리 항목 조회 중 오류가 발생했습니다.'
});
}
res.json({
success: true,
data: results
});
});
} catch (error) {
console.error('수리 항목 조회 오류:', error);
res.status(500).json({
success: false,
message: '서버 오류가 발생했습니다.'
});
}
}
};

View File

@@ -0,0 +1,17 @@
-- 설비 사진 테이블 생성
-- 실행: docker exec -i tkfb_db mysql -u hyungi -p'your_password' hyungi < db/migrations/20260205001000_create_equipment_photos.sql
CREATE TABLE IF NOT EXISTS equipment_photos (
photo_id INT AUTO_INCREMENT PRIMARY KEY,
equipment_id INT UNSIGNED NOT NULL,
photo_path VARCHAR(255) NOT NULL COMMENT '이미지 경로',
description VARCHAR(200) COMMENT '사진 설명',
display_order INT DEFAULT 0 COMMENT '표시 순서',
uploaded_by INT COMMENT '업로드한 사용자 ID',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_eq_photos_equipment FOREIGN KEY (equipment_id)
REFERENCES equipments(equipment_id) ON DELETE CASCADE,
CONSTRAINT fk_eq_photos_user FOREIGN KEY (uploaded_by)
REFERENCES users(user_id) ON DELETE SET NULL,
INDEX idx_eq_photos_equipment_id (equipment_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@@ -0,0 +1,174 @@
-- 설비 임시이동 필드 추가 및 신고 시스템 연동
-- 실행: docker exec -i tkfb_db mysql -u hyungi -p'your_password' hyungi < db/migrations/20260205002000_add_equipment_move_fields.sql
SET @dbname = DATABASE();
-- ============================================
-- STEP 1: equipments 테이블에 임시이동 필드 추가
-- ============================================
-- current_workplace_id 컬럼 추가
SELECT COUNT(*) INTO @col_exists
FROM information_schema.columns
WHERE table_schema = @dbname AND table_name = 'equipments' AND column_name = 'current_workplace_id';
SET @sql = IF(@col_exists = 0,
'ALTER TABLE equipments ADD COLUMN current_workplace_id INT UNSIGNED NULL COMMENT ''현재 임시 위치 - 작업장 ID'' AFTER map_height_percent',
'SELECT ''current_workplace_id already exists''');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- current_map_x_percent 컬럼 추가
SELECT COUNT(*) INTO @col_exists
FROM information_schema.columns
WHERE table_schema = @dbname AND table_name = 'equipments' AND column_name = 'current_map_x_percent';
SET @sql = IF(@col_exists = 0,
'ALTER TABLE equipments ADD COLUMN current_map_x_percent DECIMAL(5,2) NULL COMMENT ''현재 위치 X%'' AFTER current_workplace_id',
'SELECT ''current_map_x_percent already exists''');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- current_map_y_percent 컬럼 추가
SELECT COUNT(*) INTO @col_exists
FROM information_schema.columns
WHERE table_schema = @dbname AND table_name = 'equipments' AND column_name = 'current_map_y_percent';
SET @sql = IF(@col_exists = 0,
'ALTER TABLE equipments ADD COLUMN current_map_y_percent DECIMAL(5,2) NULL COMMENT ''현재 위치 Y%'' AFTER current_map_x_percent',
'SELECT ''current_map_y_percent already exists''');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- current_map_width_percent 컬럼 추가
SELECT COUNT(*) INTO @col_exists
FROM information_schema.columns
WHERE table_schema = @dbname AND table_name = 'equipments' AND column_name = 'current_map_width_percent';
SET @sql = IF(@col_exists = 0,
'ALTER TABLE equipments ADD COLUMN current_map_width_percent DECIMAL(5,2) NULL COMMENT ''현재 위치 너비%'' AFTER current_map_y_percent',
'SELECT ''current_map_width_percent already exists''');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- current_map_height_percent 컬럼 추가
SELECT COUNT(*) INTO @col_exists
FROM information_schema.columns
WHERE table_schema = @dbname AND table_name = 'equipments' AND column_name = 'current_map_height_percent';
SET @sql = IF(@col_exists = 0,
'ALTER TABLE equipments ADD COLUMN current_map_height_percent DECIMAL(5,2) NULL COMMENT ''현재 위치 높이%'' AFTER current_map_width_percent',
'SELECT ''current_map_height_percent already exists''');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- is_temporarily_moved 컬럼 추가
SELECT COUNT(*) INTO @col_exists
FROM information_schema.columns
WHERE table_schema = @dbname AND table_name = 'equipments' AND column_name = 'is_temporarily_moved';
SET @sql = IF(@col_exists = 0,
'ALTER TABLE equipments ADD COLUMN is_temporarily_moved BOOLEAN DEFAULT FALSE COMMENT ''임시 이동 상태'' AFTER current_map_height_percent',
'SELECT ''is_temporarily_moved already exists''');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- moved_at 컬럼 추가
SELECT COUNT(*) INTO @col_exists
FROM information_schema.columns
WHERE table_schema = @dbname AND table_name = 'equipments' AND column_name = 'moved_at';
SET @sql = IF(@col_exists = 0,
'ALTER TABLE equipments ADD COLUMN moved_at DATETIME NULL COMMENT ''이동 일시'' AFTER is_temporarily_moved',
'SELECT ''moved_at already exists''');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- moved_by 컬럼 추가
SELECT COUNT(*) INTO @col_exists
FROM information_schema.columns
WHERE table_schema = @dbname AND table_name = 'equipments' AND column_name = 'moved_by';
SET @sql = IF(@col_exists = 0,
'ALTER TABLE equipments ADD COLUMN moved_by INT NULL COMMENT ''이동 처리자'' AFTER moved_at',
'SELECT ''moved_by already exists''');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- Foreign Key: current_workplace_id -> workplaces
SELECT COUNT(*) INTO @fk_exists
FROM information_schema.table_constraints
WHERE table_schema = @dbname AND table_name = 'equipments' AND constraint_name = 'fk_eq_current_workplace';
SET @sql = IF(@fk_exists = 0,
'ALTER TABLE equipments ADD CONSTRAINT fk_eq_current_workplace FOREIGN KEY (current_workplace_id) REFERENCES workplaces(workplace_id) ON DELETE SET NULL',
'SELECT ''fk_eq_current_workplace already exists''');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SELECT 'equipments 임시이동 필드 추가 완료' AS status;
-- ============================================
-- STEP 2: work_issue_reports에 equipment_id 필드 추가
-- ============================================
SELECT COUNT(*) INTO @col_exists
FROM information_schema.columns
WHERE table_schema = @dbname AND table_name = 'work_issue_reports' AND column_name = 'equipment_id';
SET @sql = IF(@col_exists = 0,
'ALTER TABLE work_issue_reports ADD COLUMN equipment_id INT UNSIGNED NULL COMMENT ''관련 설비 ID'' AFTER visit_request_id',
'SELECT ''equipment_id already exists in work_issue_reports''');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- Foreign Key
SELECT COUNT(*) INTO @fk_exists
FROM information_schema.table_constraints
WHERE table_schema = @dbname AND table_name = 'work_issue_reports' AND constraint_name = 'fk_wir_equipment';
SET @sql = IF(@fk_exists = 0 AND @col_exists = 0,
'ALTER TABLE work_issue_reports ADD CONSTRAINT fk_wir_equipment FOREIGN KEY (equipment_id) REFERENCES equipments(equipment_id) ON DELETE SET NULL',
'SELECT ''fk_wir_equipment already exists or column not added''');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- Index
SELECT COUNT(*) INTO @idx_exists
FROM information_schema.statistics
WHERE table_schema = @dbname AND table_name = 'work_issue_reports' AND index_name = 'idx_wir_equipment_id';
SET @sql = IF(@idx_exists = 0 AND @col_exists = 0,
'ALTER TABLE work_issue_reports ADD INDEX idx_wir_equipment_id (equipment_id)',
'SELECT ''idx_wir_equipment_id already exists''');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SELECT 'work_issue_reports equipment_id 추가 완료' AS status;
-- ============================================
-- STEP 3: 설비 수리 카테고리 추가
-- ============================================
INSERT INTO issue_report_categories (category_type, category_name, description, display_order, is_active)
SELECT 'nonconformity', '설비 수리', '설비 고장 및 수리 요청', 10, 1
WHERE NOT EXISTS (
SELECT 1 FROM issue_report_categories WHERE category_name = '설비 수리'
);
-- 설비 수리 카테고리에 기본 항목 추가
SET @category_id = (SELECT category_id FROM issue_report_categories WHERE category_name = '설비 수리' LIMIT 1);
INSERT INTO issue_report_items (category_id, item_name, description, severity, display_order, is_active)
SELECT @category_id, '기계 고장', '기계 작동 불가 또는 이상', 'high', 1, 1
WHERE @category_id IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM issue_report_items WHERE category_id = @category_id AND item_name = '기계 고장'
);
INSERT INTO issue_report_items (category_id, item_name, description, severity, display_order, is_active)
SELECT @category_id, '부품 교체 필요', '소모품 또는 부품 교체 필요', 'medium', 2, 1
WHERE @category_id IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM issue_report_items WHERE category_id = @category_id AND item_name = '부품 교체 필요'
);
INSERT INTO issue_report_items (category_id, item_name, description, severity, display_order, is_active)
SELECT @category_id, '정기 점검 필요', '예방 정비 또는 정기 점검', 'low', 3, 1
WHERE @category_id IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM issue_report_items WHERE category_id = @category_id AND item_name = '정기 점검 필요'
);
INSERT INTO issue_report_items (category_id, item_name, description, severity, display_order, is_active)
SELECT @category_id, '외부 수리 필요', '전문 업체 수리가 필요한 경우', 'high', 4, 1
WHERE @category_id IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM issue_report_items WHERE category_id = @category_id AND item_name = '외부 수리 필요'
);
SELECT '설비 수리 카테고리 및 항목 추가 완료' AS status;

View File

@@ -0,0 +1,86 @@
-- 설비 외부반출 테이블 생성 및 상태 ENUM 확장
-- 실행: docker exec -i tkfb_db mysql -u hyungi -p'your_password' hyungi < db/migrations/20260205003000_create_equipment_external_logs.sql
SET @dbname = DATABASE();
-- ============================================
-- STEP 1: equipment_external_logs 테이블 생성
-- ============================================
CREATE TABLE IF NOT EXISTS equipment_external_logs (
log_id INT AUTO_INCREMENT PRIMARY KEY,
equipment_id INT UNSIGNED NOT NULL COMMENT '설비 ID',
log_type ENUM('export', 'return') NOT NULL COMMENT '반출/반입',
export_date DATE COMMENT '반출일',
expected_return_date DATE COMMENT '반입 예정일',
actual_return_date DATE COMMENT '실제 반입일',
destination VARCHAR(200) COMMENT '반출처 (수리업체명 등)',
reason TEXT COMMENT '반출 사유',
notes TEXT COMMENT '비고',
exported_by INT COMMENT '반출 담당자',
returned_by INT COMMENT '반입 담당자',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_eel_equipment FOREIGN KEY (equipment_id)
REFERENCES equipments(equipment_id) ON DELETE CASCADE,
CONSTRAINT fk_eel_exported_by FOREIGN KEY (exported_by)
REFERENCES users(user_id) ON DELETE SET NULL,
CONSTRAINT fk_eel_returned_by FOREIGN KEY (returned_by)
REFERENCES users(user_id) ON DELETE SET NULL,
INDEX idx_eel_equipment_id (equipment_id),
INDEX idx_eel_log_type (log_type),
INDEX idx_eel_export_date (export_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
SELECT 'equipment_external_logs 테이블 생성 완료' AS status;
-- ============================================
-- STEP 2: equipments 테이블 status ENUM 확장
-- ============================================
-- 현재 status 컬럼의 ENUM 값 확인 후 확장
-- 기존: active, maintenance, repair_needed, inactive
-- 추가: external (외부 반출), repair_external (수리 외주)
ALTER TABLE equipments
MODIFY COLUMN status ENUM(
'active', -- 정상 가동
'maintenance', -- 점검 중
'repair_needed', -- 수리 필요
'inactive', -- 비활성
'external', -- 외부 반출
'repair_external' -- 수리 외주 (외부 수리)
) DEFAULT 'active' COMMENT '설비 상태';
SELECT 'equipments status ENUM 확장 완료' AS status;
-- ============================================
-- STEP 3: 설비 이동 이력 테이블 생성 (선택)
-- ============================================
CREATE TABLE IF NOT EXISTS equipment_move_logs (
log_id INT AUTO_INCREMENT PRIMARY KEY,
equipment_id INT UNSIGNED NOT NULL COMMENT '설비 ID',
move_type ENUM('temporary', 'return') NOT NULL COMMENT '임시이동/복귀',
from_workplace_id INT UNSIGNED COMMENT '이전 작업장',
to_workplace_id INT UNSIGNED COMMENT '이동 작업장',
from_x_percent DECIMAL(5,2) COMMENT '이전 X좌표',
from_y_percent DECIMAL(5,2) COMMENT '이전 Y좌표',
to_x_percent DECIMAL(5,2) COMMENT '이동 X좌표',
to_y_percent DECIMAL(5,2) COMMENT '이동 Y좌표',
reason TEXT COMMENT '이동 사유',
moved_by INT COMMENT '이동 처리자',
moved_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_eml_equipment FOREIGN KEY (equipment_id)
REFERENCES equipments(equipment_id) ON DELETE CASCADE,
CONSTRAINT fk_eml_from_workplace FOREIGN KEY (from_workplace_id)
REFERENCES workplaces(workplace_id) ON DELETE SET NULL,
CONSTRAINT fk_eml_to_workplace FOREIGN KEY (to_workplace_id)
REFERENCES workplaces(workplace_id) ON DELETE SET NULL,
CONSTRAINT fk_eml_moved_by FOREIGN KEY (moved_by)
REFERENCES users(user_id) ON DELETE SET NULL,
INDEX idx_eml_equipment_id (equipment_id),
INDEX idx_eml_move_type (move_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
SELECT 'equipment_move_logs 테이블 생성 완료' AS status;

View File

@@ -353,6 +353,534 @@ const EquipmentModel = {
} catch (error) {
callback(error);
}
},
// ==========================================
// 설비 사진 관리
// ==========================================
// ADD PHOTO - 설비 사진 추가
addPhoto: async (equipmentId, photoData, callback) => {
try {
const db = await getDb();
const query = `
INSERT INTO equipment_photos (
equipment_id, photo_path, description, display_order, uploaded_by
) VALUES (?, ?, ?, ?, ?)
`;
const values = [
equipmentId,
photoData.photo_path,
photoData.description || null,
photoData.display_order || 0,
photoData.uploaded_by || null
];
const [result] = await db.query(query, values);
callback(null, {
photo_id: result.insertId,
equipment_id: equipmentId,
...photoData
});
} catch (error) {
callback(error);
}
},
// GET PHOTOS - 설비 사진 조회
getPhotos: async (equipmentId, callback) => {
try {
const db = await getDb();
const query = `
SELECT ep.*, u.name AS uploaded_by_name
FROM equipment_photos ep
LEFT JOIN users u ON ep.uploaded_by = u.user_id
WHERE ep.equipment_id = ?
ORDER BY ep.display_order ASC, ep.created_at ASC
`;
const [rows] = await db.query(query, [equipmentId]);
callback(null, rows);
} catch (error) {
callback(error);
}
},
// DELETE PHOTO - 설비 사진 삭제
deletePhoto: async (photoId, callback) => {
try {
const db = await getDb();
// 먼저 사진 정보 조회 (파일 삭제용)
const [photo] = await db.query(
'SELECT photo_path FROM equipment_photos WHERE photo_id = ?',
[photoId]
);
const query = 'DELETE FROM equipment_photos WHERE photo_id = ?';
const [result] = await db.query(query, [photoId]);
if (result.affectedRows === 0) {
return callback(new Error('Photo not found'));
}
callback(null, { photo_id: photoId, photo_path: photo[0]?.photo_path });
} catch (error) {
callback(error);
}
},
// ==========================================
// 설비 임시 이동
// ==========================================
// MOVE TEMPORARILY - 설비 임시 이동
moveTemporarily: async (equipmentId, moveData, callback) => {
try {
const db = await getDb();
// 1. 설비 현재 위치 업데이트
const updateQuery = `
UPDATE equipments SET
current_workplace_id = ?,
current_map_x_percent = ?,
current_map_y_percent = ?,
current_map_width_percent = ?,
current_map_height_percent = ?,
is_temporarily_moved = TRUE,
moved_at = NOW(),
moved_by = ?,
updated_at = NOW()
WHERE equipment_id = ?
`;
const updateValues = [
moveData.target_workplace_id,
moveData.target_x_percent,
moveData.target_y_percent,
moveData.target_width_percent || null,
moveData.target_height_percent || null,
moveData.moved_by || null,
equipmentId
];
const [result] = await db.query(updateQuery, updateValues);
if (result.affectedRows === 0) {
return callback(new Error('Equipment not found'));
}
// 2. 이동 이력 기록
const logQuery = `
INSERT INTO equipment_move_logs (
equipment_id, move_type,
from_workplace_id, to_workplace_id,
from_x_percent, from_y_percent,
to_x_percent, to_y_percent,
reason, moved_by
) VALUES (?, 'temporary', ?, ?, ?, ?, ?, ?, ?, ?)
`;
await db.query(logQuery, [
equipmentId,
moveData.from_workplace_id || null,
moveData.target_workplace_id,
moveData.from_x_percent || null,
moveData.from_y_percent || null,
moveData.target_x_percent,
moveData.target_y_percent,
moveData.reason || null,
moveData.moved_by || null
]);
callback(null, { equipment_id: equipmentId, moved: true });
} catch (error) {
callback(error);
}
},
// RETURN TO ORIGINAL - 설비 원위치 복귀
returnToOriginal: async (equipmentId, userId, callback) => {
try {
const db = await getDb();
// 1. 현재 임시 위치 정보 조회
const [equipment] = await db.query(
'SELECT current_workplace_id, current_map_x_percent, current_map_y_percent FROM equipments WHERE equipment_id = ?',
[equipmentId]
);
if (!equipment[0]) {
return callback(new Error('Equipment not found'));
}
// 2. 임시 위치 필드 초기화
const updateQuery = `
UPDATE equipments SET
current_workplace_id = NULL,
current_map_x_percent = NULL,
current_map_y_percent = NULL,
current_map_width_percent = NULL,
current_map_height_percent = NULL,
is_temporarily_moved = FALSE,
moved_at = NULL,
moved_by = NULL,
updated_at = NOW()
WHERE equipment_id = ?
`;
await db.query(updateQuery, [equipmentId]);
// 3. 복귀 이력 기록
const logQuery = `
INSERT INTO equipment_move_logs (
equipment_id, move_type,
from_workplace_id, from_x_percent, from_y_percent,
reason, moved_by
) VALUES (?, 'return', ?, ?, ?, '원위치 복귀', ?)
`;
await db.query(logQuery, [
equipmentId,
equipment[0].current_workplace_id,
equipment[0].current_map_x_percent,
equipment[0].current_map_y_percent,
userId || null
]);
callback(null, { equipment_id: equipmentId, returned: true });
} catch (error) {
callback(error);
}
},
// GET TEMPORARILY MOVED - 임시 이동된 설비 목록
getTemporarilyMoved: async (callback) => {
try {
const db = await getDb();
const query = `
SELECT
e.*,
w_orig.workplace_name AS original_workplace_name,
w_curr.workplace_name AS current_workplace_name,
u.name AS moved_by_name
FROM equipments e
LEFT JOIN workplaces w_orig ON e.workplace_id = w_orig.workplace_id
LEFT JOIN workplaces w_curr ON e.current_workplace_id = w_curr.workplace_id
LEFT JOIN users u ON e.moved_by = u.user_id
WHERE e.is_temporarily_moved = TRUE
ORDER BY e.moved_at DESC
`;
const [rows] = await db.query(query);
callback(null, rows);
} catch (error) {
callback(error);
}
},
// GET MOVE LOGS - 설비 이동 이력 조회
getMoveLogs: async (equipmentId, callback) => {
try {
const db = await getDb();
const query = `
SELECT
eml.*,
w_from.workplace_name AS from_workplace_name,
w_to.workplace_name AS to_workplace_name,
u.name AS moved_by_name
FROM equipment_move_logs eml
LEFT JOIN workplaces w_from ON eml.from_workplace_id = w_from.workplace_id
LEFT JOIN workplaces w_to ON eml.to_workplace_id = w_to.workplace_id
LEFT JOIN users u ON eml.moved_by = u.user_id
WHERE eml.equipment_id = ?
ORDER BY eml.moved_at DESC
`;
const [rows] = await db.query(query, [equipmentId]);
callback(null, rows);
} catch (error) {
callback(error);
}
},
// ==========================================
// 설비 외부 반출/반입
// ==========================================
// EXPORT EQUIPMENT - 설비 외부 반출
exportEquipment: async (exportData, callback) => {
try {
const db = await getDb();
// 1. 반출 로그 생성
const logQuery = `
INSERT INTO equipment_external_logs (
equipment_id, log_type, export_date, expected_return_date,
destination, reason, notes, exported_by
) VALUES (?, 'export', ?, ?, ?, ?, ?, ?)
`;
const logValues = [
exportData.equipment_id,
exportData.export_date || new Date().toISOString().slice(0, 10),
exportData.expected_return_date || null,
exportData.destination || null,
exportData.reason || null,
exportData.notes || null,
exportData.exported_by || null
];
const [logResult] = await db.query(logQuery, logValues);
// 2. 설비 상태 업데이트
const status = exportData.is_repair ? 'repair_external' : 'external';
await db.query(
'UPDATE equipments SET status = ?, updated_at = NOW() WHERE equipment_id = ?',
[status, exportData.equipment_id]
);
callback(null, {
log_id: logResult.insertId,
equipment_id: exportData.equipment_id,
exported: true
});
} catch (error) {
callback(error);
}
},
// RETURN EQUIPMENT - 설비 반입 (외부에서 복귀)
returnEquipment: async (logId, returnData, callback) => {
try {
const db = await getDb();
// 1. 반출 로그 조회
const [logs] = await db.query(
'SELECT equipment_id FROM equipment_external_logs WHERE log_id = ?',
[logId]
);
if (!logs[0]) {
return callback(new Error('Export log not found'));
}
const equipmentId = logs[0].equipment_id;
// 2. 반출 로그 업데이트
await db.query(
`UPDATE equipment_external_logs SET
actual_return_date = ?,
returned_by = ?,
notes = CONCAT(IFNULL(notes, ''), '\n반입: ', IFNULL(?, '')),
updated_at = NOW()
WHERE log_id = ?`,
[
returnData.return_date || new Date().toISOString().slice(0, 10),
returnData.returned_by || null,
returnData.notes || '',
logId
]
);
// 3. 설비 상태 복원
await db.query(
'UPDATE equipments SET status = ?, updated_at = NOW() WHERE equipment_id = ?',
[returnData.new_status || 'active', equipmentId]
);
callback(null, {
log_id: logId,
equipment_id: equipmentId,
returned: true
});
} catch (error) {
callback(error);
}
},
// GET EXTERNAL LOGS - 설비 외부 반출 이력 조회
getExternalLogs: async (equipmentId, callback) => {
try {
const db = await getDb();
const query = `
SELECT
eel.*,
u_exp.name AS exported_by_name,
u_ret.name AS returned_by_name
FROM equipment_external_logs eel
LEFT JOIN users u_exp ON eel.exported_by = u_exp.user_id
LEFT JOIN users u_ret ON eel.returned_by = u_ret.user_id
WHERE eel.equipment_id = ?
ORDER BY eel.created_at DESC
`;
const [rows] = await db.query(query, [equipmentId]);
callback(null, rows);
} catch (error) {
callback(error);
}
},
// GET EXPORTED EQUIPMENTS - 현재 외부 반출 중인 설비 목록
getExportedEquipments: async (callback) => {
try {
const db = await getDb();
const query = `
SELECT
e.*,
w.workplace_name,
eel.export_date,
eel.expected_return_date,
eel.destination,
eel.reason,
u.name AS exported_by_name
FROM equipments e
INNER JOIN (
SELECT equipment_id, MAX(log_id) AS latest_log_id
FROM equipment_external_logs
WHERE actual_return_date IS NULL
GROUP BY equipment_id
) latest ON e.equipment_id = latest.equipment_id
INNER JOIN equipment_external_logs eel ON eel.log_id = latest.latest_log_id
LEFT JOIN workplaces w ON e.workplace_id = w.workplace_id
LEFT JOIN users u ON eel.exported_by = u.user_id
WHERE e.status IN ('external', 'repair_external')
ORDER BY eel.export_date DESC
`;
const [rows] = await db.query(query);
callback(null, rows);
} catch (error) {
callback(error);
}
},
// ==========================================
// 설비 수리 신청 (work_issue_reports 연동)
// ==========================================
// CREATE REPAIR REQUEST - 수리 신청 (신고 시스템 활용)
createRepairRequest: async (requestData, callback) => {
try {
const db = await getDb();
// 설비 수리 카테고리 ID 조회
const [categories] = await db.query(
"SELECT category_id FROM issue_report_categories WHERE category_name = '설비 수리' LIMIT 1"
);
if (!categories[0]) {
return callback(new Error('설비 수리 카테고리가 없습니다'));
}
const categoryId = categories[0].category_id;
// 항목 ID 조회 (지정된 항목이 없으면 첫번째 항목 사용)
let itemId = requestData.item_id;
if (!itemId) {
const [items] = await db.query(
'SELECT item_id FROM issue_report_items WHERE category_id = ? LIMIT 1',
[categoryId]
);
itemId = items[0]?.item_id;
}
// 사진 경로 분리 (최대 5장)
const photos = requestData.photo_paths || [];
// work_issue_reports에 삽입
const query = `
INSERT INTO work_issue_reports (
reporter_id, issue_category_id, issue_item_id,
workplace_id, equipment_id,
additional_description,
photo_path1, photo_path2, photo_path3, photo_path4, photo_path5,
status
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'reported')
`;
const values = [
requestData.reported_by || null,
categoryId,
itemId,
requestData.workplace_id || null,
requestData.equipment_id,
requestData.description || null,
photos[0] || null,
photos[1] || null,
photos[2] || null,
photos[3] || null,
photos[4] || null
];
const [result] = await db.query(query, values);
// 설비 상태를 repair_needed로 업데이트
await db.query(
'UPDATE equipments SET status = ?, updated_at = NOW() WHERE equipment_id = ?',
['repair_needed', requestData.equipment_id]
);
callback(null, {
report_id: result.insertId,
equipment_id: requestData.equipment_id,
created: true
});
} catch (error) {
callback(error);
}
},
// GET REPAIR HISTORY - 설비 수리 이력 조회
getRepairHistory: async (equipmentId, callback) => {
try {
const db = await getDb();
const query = `
SELECT
wir.*,
irc.category_name,
iri.item_name,
u_rep.name AS reported_by_name,
u_res.name AS resolved_by_name,
w.workplace_name
FROM work_issue_reports wir
LEFT JOIN issue_report_categories irc ON wir.issue_category_id = irc.category_id
LEFT JOIN issue_report_items iri ON wir.issue_item_id = iri.item_id
LEFT JOIN users u_rep ON wir.reporter_id = u_rep.user_id
LEFT JOIN users u_res ON wir.resolved_by = u_res.user_id
LEFT JOIN workplaces w ON wir.workplace_id = w.workplace_id
WHERE wir.equipment_id = ?
ORDER BY wir.created_at DESC
`;
const [rows] = await db.query(query, [equipmentId]);
callback(null, rows);
} catch (error) {
callback(error);
}
},
// GET REPAIR CATEGORIES - 설비 수리 항목 목록 조회
getRepairCategories: async (callback) => {
try {
const db = await getDb();
// 설비 수리 카테고리의 항목들 조회
const query = `
SELECT iri.item_id, iri.item_name, iri.description, iri.severity
FROM issue_report_items iri
INNER JOIN issue_report_categories irc ON iri.category_id = irc.category_id
WHERE irc.category_name = '설비 수리' AND iri.is_active = 1
ORDER BY iri.display_order ASC
`;
const [rows] = await db.query(query);
callback(null, rows);
} catch (error) {
callback(error);
}
}
};

View File

@@ -25,6 +25,31 @@ router.get('/next-code', equipmentController.getNextEquipmentCode);
// READ 작업장별 설비
router.get('/workplace/:workplaceId', equipmentController.getEquipmentsByWorkplace);
// ==================== 임시 이동 (목록) ====================
// 임시 이동된 설비 목록
router.get('/moved/list', equipmentController.getTemporarilyMoved);
// ==================== 외부 반출 (목록) ====================
// 현재 외부 반출 중인 설비 목록
router.get('/exported/list', equipmentController.getExportedEquipments);
// 반출 로그 반입 처리
router.post('/external-logs/:logId/return', equipmentController.returnEquipment);
// ==================== 수리 ====================
// 수리 항목 목록 조회
router.get('/repair-categories', equipmentController.getRepairCategories);
// ==================== 사진 관리 ====================
// 사진 삭제 (설비 ID 없이 photo_id만으로)
router.delete('/photos/:photoId', equipmentController.deletePhoto);
// ==================== 개별 설비 ====================
// READ ONE 설비
router.get('/:id', equipmentController.getEquipmentById);
@@ -37,4 +62,39 @@ router.patch('/:id/map-position', equipmentController.updateMapPosition);
// DELETE 설비
router.delete('/:id', equipmentController.deleteEquipment);
// ==================== 설비 사진 ====================
// 설비 사진 추가
router.post('/:id/photos', equipmentController.addPhoto);
// 설비 사진 목록
router.get('/:id/photos', equipmentController.getPhotos);
// ==================== 설비 임시 이동 ====================
// 설비 임시 이동
router.post('/:id/move', equipmentController.moveTemporarily);
// 설비 원위치 복귀
router.post('/:id/return', equipmentController.returnToOriginal);
// 설비 이동 이력
router.get('/:id/move-logs', equipmentController.getMoveLogs);
// ==================== 설비 외부 반출 ====================
// 설비 외부 반출
router.post('/:id/export', equipmentController.exportEquipment);
// 설비 외부 반출 이력
router.get('/:id/external-logs', equipmentController.getExternalLogs);
// ==================== 설비 수리 ====================
// 수리 신청
router.post('/:id/repair-request', equipmentController.createRepairRequest);
// 수리 이력 조회
router.get('/:id/repair-history', equipmentController.getRepairHistory);
module.exports = router;

View File

@@ -20,19 +20,26 @@ try {
}
// 업로드 디렉토리 설정
const UPLOAD_DIR = path.join(__dirname, '../public/uploads/issues');
const UPLOAD_DIRS = {
issues: path.join(__dirname, '../public/uploads/issues'),
equipments: path.join(__dirname, '../public/uploads/equipments')
};
const UPLOAD_DIR = UPLOAD_DIRS.issues; // 기존 호환성 유지
const MAX_SIZE = { width: 1920, height: 1920 };
const QUALITY = 85;
/**
* 업로드 디렉토리 확인 및 생성
* @param {string} category - 카테고리 ('issues' 또는 'equipments')
*/
async function ensureUploadDir() {
async function ensureUploadDir(category = 'issues') {
const uploadDir = UPLOAD_DIRS[category] || UPLOAD_DIRS.issues;
try {
await fs.access(UPLOAD_DIR);
await fs.access(uploadDir);
} catch {
await fs.mkdir(UPLOAD_DIR, { recursive: true });
await fs.mkdir(uploadDir, { recursive: true });
}
return uploadDir;
}
/**
@@ -68,10 +75,11 @@ function getImageExtension(base64String) {
/**
* Base64 이미지를 파일로 저장
* @param {string} base64String - Base64 인코딩된 이미지 (data:image/...;base64,... 형식)
* @param {string} prefix - 파일명 접두사 (예: 'issue', 'resolution')
* @param {string} prefix - 파일명 접두사 (예: 'issue', 'resolution', 'equipment')
* @param {string} category - 저장 카테고리 ('issues' 또는 'equipments')
* @returns {Promise<string|null>} 저장된 파일의 웹 경로 또는 null
*/
async function saveBase64Image(base64String, prefix = 'issue') {
async function saveBase64Image(base64String, prefix = 'issue', category = 'issues') {
try {
if (!base64String || typeof base64String !== 'string') {
return null;
@@ -92,14 +100,14 @@ async function saveBase64Image(base64String, prefix = 'issue') {
}
// 디렉토리 확인
await ensureUploadDir();
const uploadDir = await ensureUploadDir(category);
// 파일명 생성
const timestamp = getTimestamp();
const uniqueId = generateId();
const extension = 'jpg'; // 모든 이미지를 JPEG로 저장
const filename = `${prefix}_${timestamp}_${uniqueId}.${extension}`;
const filepath = path.join(UPLOAD_DIR, filename);
const filepath = path.join(uploadDir, filename);
// sharp가 설치되어 있으면 리사이징 및 최적화
if (sharp) {
@@ -122,7 +130,7 @@ async function saveBase64Image(base64String, prefix = 'issue') {
}
// 웹 접근 경로 반환
return `/uploads/issues/${filename}`;
return `/uploads/${category}/${filename}`;
} catch (error) {
console.error('이미지 저장 실패:', error);
return null;