diff --git a/.gitignore b/.gitignore index de4b16b..fb01baf 100644 --- a/.gitignore +++ b/.gitignore @@ -20,4 +20,7 @@ shared/ # MQTT ignored files (무시할 파일들) fems-mqtt/data/ -fems-mqtt/log/ \ No newline at end of file +fems-mqtt/log/ + +fems-realtime-api/node_modules/ +fems-realtime-api/logs/* \ No newline at end of file diff --git a/fems-api/src/controllers/app/device/device.controller.js b/fems-api/src/controllers/app/device/device.controller.js new file mode 100644 index 0000000..10c6294 --- /dev/null +++ b/fems-api/src/controllers/app/device/device.controller.js @@ -0,0 +1,194 @@ +// src/controllers/app/device/device.controller.js +const express = require("express"); +const router = express.Router(); +const authMiddleware = require("../../../middleware/auth.middleware"); +const roleCheck = require("../../../middleware/roleCheck.middleware"); +const deviceService = require("../../../services/device.service"); +const logger = require("../../../config/logger"); +const { body, query } = require("express-validator"); +const validate = require("../../../middleware/validator.middleware"); + +// 모든 라우트에 인증 미들웨어 적용 +router.use(authMiddleware); +router.use(roleCheck(["super_admin", "company_admin", "branch_admin", "user"])); + +// 데이터 포인트 유효성 검사 규칙 +const dataPointValidationRules = [ + body("DataPoints.*.tag_name").notEmpty().withMessage("Tag name is required"), + body("DataPoints.*.data_type") + .isIn([ + "BOOL", + "INT16", + "UINT16", + "INT32", + "UINT32", + "FLOAT", + "DOUBLE", + "STRING", + ]) + .withMessage("Valid data type is required"), + body("DataPoints.*.address").notEmpty().withMessage("Address is required"), + body("DataPoints.*.register_type") + .optional() + .isIn(["COIL", "DISCRETE_INPUT", "HOLDING_REGISTER", "INPUT_REGISTER"]), + body("DataPoints.*.scan_rate") + .isInt({ min: 100 }) + .withMessage("Scan rate must be at least 100ms"), + body("DataPoints.*.scale_factor").optional().isFloat(), + body("DataPoints.*.offset").optional().isFloat(), +]; + +// 디바이스 등록 (데이터 포인트 포함) +router.post( + "/", + [ + // 디바이스 기본 정보 validation + body("device_type").notEmpty().withMessage("Device type is required"), + body("device_name").notEmpty().withMessage("Device name is required"), + body("location").optional().isString(), + body("manufacturer").optional().isString(), + body("model").optional().isString(), + + // 연결 설정 validation + body("DeviceConnection") + .isObject() + .withMessage("Connection settings are required"), + body("DeviceConnection.protocol") + .isIn(["MODBUS_TCP", "MODBUS_RTU", "OPC_UA", "MQTT", "BACNET"]) + .withMessage("Valid protocol is required"), + body("DeviceConnection.ip_address") + .if(body("DeviceConnection.protocol").equals("MODBUS_TCP")) + .isIP() + .withMessage("Valid IP address is required for Modbus TCP"), + body("DeviceConnection.port") + .if(body("DeviceConnection.protocol").equals("MODBUS_TCP")) + .isInt({ min: 1, max: 65535 }) + .withMessage("Valid port number is required"), + + // 데이터 포인트 validation + ...dataPointValidationRules, + + validate, + ], + async (req, res, next) => { + try { + const deviceData = { + ...req.body, + status: "ACTIVE", + }; + + const device = await deviceService.createDevice(deviceData); + res.status(201).json(device); + } catch (error) { + logger.error("Failed to create device:", error); + next(error); + } + } +); + +// 디바이스 수정 (데이터 포인트 포함) +router.put( + "/:id", + [ + // 디바이스 기본 정보 validation + body("device_name").optional().isString(), + body("location").optional().isString(), + body("status") + .optional() + .isIn(["ACTIVE", "INACTIVE", "MAINTENANCE", "ERROR"]), + + // 연결 설정 validation (수정 시) + body("DeviceConnection").optional().isObject(), + body("DeviceConnection.protocol") + .optional() + .isIn(["MODBUS_TCP", "MODBUS_RTU", "OPC_UA", "MQTT", "BACNET"]), + + // 데이터 포인트 validation + ...dataPointValidationRules, + + validate, + ], + async (req, res, next) => { + try { + const deviceId = req.params.id; + const updateData = req.body; + + const updated = await deviceService.updateDevice(deviceId, updateData); + + if (!updated) { + return res.status(404).json({ + message: "Device not found", + }); + } + + res.json(updated); + } catch (error) { + logger.error("Failed to update device:", error); + next(error); + } + } +); + +// 디바이스 목록 조회 +router.get( + "/", + [ + query("device_type").optional().isString(), + query("status") + .optional() + .isIn(["ACTIVE", "INACTIVE", "MAINTENANCE", "ERROR"]), + validate, + ], + async (req, res, next) => { + try { + const filters = req.query; + const devices = await deviceService.getDevices(filters); + res.json(devices); + } catch (error) { + logger.error("Failed to get devices:", error); + next(error); + } + } +); + +// 디바이스 상세 조회 +router.get("/:id", async (req, res, next) => { + try { + const deviceId = req.params.id; + const device = await deviceService.getDeviceDetail(deviceId); + + if (!device) { + return res.status(404).json({ + message: "Device not found", + }); + } + + res.json(device); + } catch (error) { + logger.error("Failed to get device detail:", error); + next(error); + } +}); + +// 디바이스 삭제 +router.delete("/:id", async (req, res, next) => { + try { + const deviceId = req.params.id; + const result = await deviceService.deleteDevice(deviceId); + + if (!result) { + return res.status(404).json({ + message: "Device not found", + }); + } + + res.status(200).json({ + message: "Device deleted successfully", + }); + } catch (error) { + logger.error("Failed to delete device:", error); + next(error); + } +}); + +module.exports = router; diff --git a/fems-api/src/models/DataPoint.js b/fems-api/src/models/DataPoint.js new file mode 100644 index 0000000..dc34273 --- /dev/null +++ b/fems-api/src/models/DataPoint.js @@ -0,0 +1,94 @@ +// src/models/DataPoint.js +const { Model, DataTypes } = require("sequelize"); + +class DataPoint extends Model { + static init(sequelize) { + super.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + tag_name: { + type: DataTypes.STRING(100), + allowNull: false, + comment: "데이터 포인트 태그명", + }, + description: { + type: DataTypes.STRING(200), + comment: "설명", + }, + data_type: { + type: DataTypes.ENUM( + "BOOL", + "INT16", + "UINT16", + "INT32", + "UINT32", + "FLOAT", + "DOUBLE", + "STRING" + ), + allowNull: false, + comment: "데이터 타입", + }, + address: { + type: DataTypes.STRING(50), + allowNull: false, + comment: "메모리 주소 또는 레지스터 번호", + }, + register_type: { + type: DataTypes.ENUM( + "COIL", + "DISCRETE_INPUT", + "HOLDING_REGISTER", + "INPUT_REGISTER" + ), + comment: "Modbus 레지스터 타입", + }, + scan_rate: { + type: DataTypes.INTEGER, + defaultValue: 1000, + comment: "스캔 주기 (ms)", + }, + scale_factor: { + type: DataTypes.FLOAT, + defaultValue: 1, + comment: "스케일 팩터", + }, + offset: { + type: DataTypes.FLOAT, + defaultValue: 0, + comment: "오프셋", + }, + unit: { + type: DataTypes.STRING(20), + comment: "단위", + }, + is_active: { + type: DataTypes.BOOLEAN, + defaultValue: true, + comment: "활성화 여부", + }, + created_at: DataTypes.DATE, + updated_at: DataTypes.DATE, + }, + { + sequelize, + modelName: "DataPoint", + tableName: "data_points", + timestamps: true, + updatedAt: "updated_at", + createdAt: "created_at", + } + ); + return this; + } + + static associate(models) { + this.belongsTo(models.Device, { foreignKey: "deviceId" }); + } +} + +module.exports = DataPoint; diff --git a/fems-api/src/models/Device.js b/fems-api/src/models/Device.js new file mode 100644 index 0000000..8158db6 --- /dev/null +++ b/fems-api/src/models/Device.js @@ -0,0 +1,86 @@ +// src/models/Device.js +const { Model, DataTypes } = require("sequelize"); + +class Device extends Model { + static init(sequelize) { + return super.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + device_type: { + type: DataTypes.STRING(50), + allowNull: false, + comment: "디바이스 유형 (PLC, PowerMeter, Sensor 등)", + }, + device_name: { + type: DataTypes.STRING(100), + allowNull: false, + comment: "디바이스 표시 이름", + }, + manufacturer: { + type: DataTypes.STRING(100), + comment: "제조사", + }, + model: { + type: DataTypes.STRING(100), + comment: "모델명", + }, + serial_number: { + type: DataTypes.STRING(100), + comment: "시리얼 번호", + }, + location: { + type: DataTypes.STRING(100), + comment: "설치 위치", + }, + description: { + type: DataTypes.TEXT, + comment: "설명", + }, + status: { + type: DataTypes.ENUM("ACTIVE", "INACTIVE", "MAINTENANCE", "ERROR"), + defaultValue: "ACTIVE", + comment: "디바이스 상태", + }, + created_at: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW, + }, + updated_at: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW, + }, + // 새로 추가할 필드들 + companyId: { + type: DataTypes.UUID, + comment: "회사 ID", + }, + branchId: { + type: DataTypes.UUID, + comment: "지점 ID", + }, + }, + { + sequelize, + modelName: "Device", + tableName: "devices", + timestamps: true, + updatedAt: "updated_at", + createdAt: "created_at", + } + ); + } + + static associate(models) { + this.belongsTo(models.Company, { foreignKey: "companyId" }); + this.belongsTo(models.Branch, { foreignKey: "branchId" }); + this.hasOne(models.DeviceConnection, { foreignKey: "deviceId" }); + this.hasMany(models.DataPoint, { foreignKey: "deviceId" }); + this.hasMany(models.DeviceStatus, { foreignKey: "deviceId" }); + } +} + +module.exports = Device; diff --git a/fems-api/src/models/DeviceConnection.js b/fems-api/src/models/DeviceConnection.js new file mode 100644 index 0000000..4a88fb3 --- /dev/null +++ b/fems-api/src/models/DeviceConnection.js @@ -0,0 +1,70 @@ +// src/models/DeviceConnection.js +const { Model, DataTypes } = require("sequelize"); + +class DeviceConnection extends Model { + static init(sequelize) { + super.init( + { + id: { + type: DataTypes.UUID, + defaultValue: DataTypes.UUIDV4, + primaryKey: true, + }, + protocol: { + type: DataTypes.ENUM( + "MODBUS_TCP", + "MODBUS_RTU", + "OPC_UA", + "MQTT", + "BACNET" + ), + allowNull: false, + comment: "통신 프로토콜", + }, + ip_address: { + type: DataTypes.STRING(45), + comment: "IP 주소", + }, + port: { + type: DataTypes.INTEGER, + comment: "포트 번호", + }, + unit_id: { + type: DataTypes.INTEGER, + comment: "Modbus Unit ID", + }, + connection_timeout: { + type: DataTypes.INTEGER, + defaultValue: 1000, + comment: "연결 타임아웃 (ms)", + }, + retry_interval: { + type: DataTypes.INTEGER, + defaultValue: 5000, + comment: "재시도 간격 (ms)", + }, + protocol_settings: { + type: DataTypes.JSONB, + comment: "프로토콜별 추가 설정", + }, + created_at: DataTypes.DATE, + updated_at: DataTypes.DATE, + }, + { + sequelize, + modelName: "DeviceConnection", + tableName: "device_connections", + timestamps: true, + updatedAt: "updated_at", + createdAt: "created_at", + } + ); + return this; + } + + static associate(models) { + this.belongsTo(models.Device, { foreignKey: "deviceId" }); + } +} + +module.exports = DeviceConnection; diff --git a/fems-api/src/models/DeviceStatus.js b/fems-api/src/models/DeviceStatus.js new file mode 100644 index 0000000..c769d30 --- /dev/null +++ b/fems-api/src/models/DeviceStatus.js @@ -0,0 +1,35 @@ +// src/models/DeviceStatus.js +const { Model, DataTypes } = require("sequelize"); + +class DeviceStatus extends Model { + static init(sequelize) { + super.init( + { + time: { + type: DataTypes.DATE, + allowNull: false, + }, + status: { + type: DataTypes.STRING(20), + allowNull: false, + }, + status_detail: { + type: DataTypes.JSONB, + }, + }, + { + sequelize, + modelName: "DeviceStatus", + tableName: "device_status", + timestamps: false, + } + ); + return this; + } + + static associate(models) { + this.belongsTo(models.Device, { foreignKey: "deviceId" }); + } +} + +module.exports = DeviceStatus; diff --git a/fems-api/src/routes/app.js b/fems-api/src/routes/app.js index 8407ccb..323cc7e 100644 --- a/fems-api/src/routes/app.js +++ b/fems-api/src/routes/app.js @@ -15,6 +15,7 @@ const equipmentPartsController = require("../controllers/app/equipmentParts/equi const departmentController = require("../controllers/app/department/department.controller"); const healthController = require("../controllers/app/health/health.controller"); const companiesController = require("../controllers/admin/companies/companies.controller"); +const deviceController = require("../controllers/app/device/device.controller"); router.use("/health", healthController); router.use("/auth", authController); @@ -29,5 +30,6 @@ router.use("/parts", partsController); router.use("/equipment-parts", equipmentPartsController); router.use("/department", departmentController); router.use("/companies", companiesController); +router.use("/devices", deviceController); module.exports = router; diff --git a/fems-api/src/services/device.service.js b/fems-api/src/services/device.service.js new file mode 100644 index 0000000..0b00bb3 --- /dev/null +++ b/fems-api/src/services/device.service.js @@ -0,0 +1,396 @@ +// src/services/device.service.js +const { + Device, + DeviceConnection, + DataPoint, + DeviceStatus, +} = require("../models"); +const alertService = require("./alert.service"); +const logger = require("../config/logger"); + +class DeviceService { + // 디바이스 생성 (데이터 포인트 포함) + async createDevice(data) { + const transaction = await Device.sequelize.transaction(); + + try { + // 1. 디바이스 생성 + const device = await Device.create( + { + device_type: data.device_type, + device_name: data.device_name, + manufacturer: data.manufacturer, + model: data.model, + serial_number: data.serial_number || null, // null 처리 추가 + location: data.location, + description: data.description, + status: data.status, + }, + { transaction } + ); + + // 2. 연결 정보 생성 + if (data.DeviceConnection) { + // deviceId는 방금 생성한 device.id를 사용 + const connectionData = { + ...data.DeviceConnection, + deviceId: device.id, // 명시적으로 deviceId 설정 + protocol_settings: data.DeviceConnection.protocol_settings || {}, // 기본값 처리 + }; + + await DeviceConnection.create(connectionData, { transaction }); + } + + // 3. 데이터 포인트 생성 + if (data.DataPoints && Array.isArray(data.DataPoints)) { + await Promise.all( + data.DataPoints.map((point) => + DataPoint.create( + { + ...point, + deviceId: device.id, // 명시적으로 deviceId 설정 + is_active: true, + }, + { transaction } + ) + ) + ); + } + + await alertService.createAlert({ + type: "DEVICE_CREATED", + message: `Device created: ${device.device_name}`, + companyId: device.companyId, + transaction, + }); + + await transaction.commit(); + + // 생성된 디바이스 상세 정보 조회 + return this.getDeviceDetail(device.id); + } catch (error) { + await transaction.rollback(); + logger.error("Failed to create device:", error); + throw error; + } + } + + async updateDevice(id, data) { + let transaction; + + try { + transaction = await Device.sequelize.transaction(); + + // 1. 디바이스 존재 확인 + const device = await Device.findByPk(id, { + include: [ + { + model: DataPoint, + }, + ], + transaction, + }); + + if (!device) { + if (transaction) await transaction.rollback(); + return null; + } + + // 2. 디바이스 정보 업데이트 + await device.update( + { + device_type: data.device_type, + device_name: data.device_name, + manufacturer: data.manufacturer, + model: data.model, + serial_number: data.serial_number, + location: data.location, + description: data.description, + status: data.status, + }, + { transaction } + ); + + // 3. 연결 정보 업데이트 + if (data.DeviceConnection) { + await DeviceConnection.update(data.DeviceConnection, { + where: { deviceId: id }, + transaction, + }); + } + + // 4. 데이터 포인트 처리 + if (data.DataPoints && Array.isArray(data.DataPoints)) { + await this.processDataPoints( + id, + device.DataPoints, + data.DataPoints, + transaction + ); + } + + await alertService.createAlert({ + type: "DEVICE_UPDATED", + message: `Device updated: ${device.device_name}`, + companyId: device.companyId, + transaction, + }); + + // 트랜잭션 커밋 + await transaction.commit(); + transaction = null; // 커밋 후 트랜잭션 참조 제거 + + // // MQTT 메시지 발행 + // try { + // await this.publishDeviceUpdate(id, "updated"); + // } catch (mqttError) { + // logger.error("Failed to publish MQTT message:", mqttError); + // // MQTT 발행 실패는 전체 업데이트를 실패로 처리하지 않음 + // } + + logger.info("Device updated:", { id }); + + // 업데이트된 디바이스 상세 정보 조회 + return this.getDeviceDetail(id); + } catch (error) { + logger.error("Failed to update device:", { + id, + error: error.message, + stack: error.stack, + }); + + // 트랜잭션이 존재하고 아직 완료되지 않았다면 롤백 + if (transaction && !transaction.finished) { + try { + await transaction.rollback(); + } catch (rollbackError) { + logger.error("Rollback failed:", rollbackError); + } + } + + throw error; + } + } + + // processDataPoints 메서드 개선 + async processDataPoints(deviceId, existingPoints, newPoints, transaction) { + try { + // 1. 기존 포인트와 새로운 포인트 매핑 + const existingPointMap = new Map( + existingPoints.map((point) => [point.id, point]) + ); + + const newPointMap = new Map( + newPoints.filter((point) => point.id).map((point) => [point.id, point]) + ); + + // 2. 삭제할 포인트 처리 + const pointsToDelete = existingPoints.filter( + (point) => !newPointMap.has(point.id) + ); + + if (pointsToDelete.length > 0) { + await DataPoint.update( + { is_active: false }, + { + where: { + id: pointsToDelete.map((p) => p.id), + deviceId: deviceId, + }, + transaction, + } + ); + } + + // 3. 추가할 포인트 처리 + const pointsToAdd = newPoints.filter( + (point) => !point.id || !existingPointMap.has(point.id) + ); + + if (pointsToAdd.length > 0) { + await Promise.all( + pointsToAdd.map((point) => { + const { id, ...pointData } = point; + return DataPoint.create( + { + ...pointData, + deviceId: deviceId, + }, + { transaction } + ); + }) + ); + } + + // 4. 수정할 포인트 처리 + const pointsToUpdate = newPoints.filter( + (point) => point.id && existingPointMap.has(point.id) + ); + + await Promise.all( + pointsToUpdate.map((point) => { + const { id, ...updateData } = point; + return DataPoint.update(updateData, { + where: { + id: id, + deviceId: deviceId, + }, + transaction, + }); + }) + ); + + logger.info("Data points processed:", { + deviceId, + deleted: pointsToDelete.length, + added: pointsToAdd.length, + updated: pointsToUpdate.length, + }); + } catch (error) { + logger.error("Failed to process data points:", { + deviceId, + error: error.message, + stack: error.stack, + }); + throw error; + } + } + + // 디바이스 목록 조회 + async getDevices(filters = {}) { + try { + const where = {}; + if (filters.device_type) where.device_type = filters.device_type; + if (filters.status) where.status = filters.status; + + const devices = await Device.findAll({ + where, + include: [ + { + model: DeviceConnection, + attributes: { exclude: ["protocol_settings"] }, + }, + { + model: DataPoint, + where: { is_active: true }, + required: false, + }, + ], + order: [["created_at", "DESC"]], + }); + + return devices; + } catch (error) { + logger.error("Failed to get devices:", error); + throw error; + } + } + + // 디바이스 상세 조회 + async getDeviceDetail(id) { + try { + const device = await Device.findByPk(id, { + include: [ + { + model: DeviceConnection, + attributes: { exclude: ["protocol_settings"] }, + }, + { + model: DataPoint, + where: { is_active: true }, + required: false, + }, + { + model: DeviceStatus, + limit: 1, + order: [["time", "DESC"]], + required: false, + }, + ], + }); + + return device; + } catch (error) { + logger.error("Failed to get device detail:", error); + throw error; + } + } + + async deleteDevice(id) { + const transaction = await Device.sequelize.transaction(); + + try { + // 1. 디바이스 존재 확인 + const device = await Device.findByPk(id, { transaction }); + + if (!device) { + await transaction.rollback(); + return false; + } + + // 2. 순차적으로 관련 데이터 모두 삭제 + try { + // 데이터 포인트 삭제 + await DataPoint.destroy({ + where: { deviceId: id }, + transaction, + }); + + // 디바이스 연결 정보 삭제 + await DeviceConnection.destroy({ + where: { deviceId: id }, + transaction, + }); + + // 디바이스 상태 이력 삭제 + await DeviceStatus.destroy({ + where: { deviceId: id }, + transaction, + }); + + // 디바이스 삭제 + await device.destroy({ transaction }); + + await alertService.createAlert({ + type: "DEVICE_DELETED", + message: `Device deleted: ${device.device_name}`, + companyId: device.companyId, + transaction, + }); + + // 트랜잭션 커밋 + await transaction.commit(); + + logger.info("Device deleted successfully:", { id }); + + return true; + } catch (error) { + logger.error(`Error during delete operation: ${error.message}`); + await transaction.rollback(); + throw error; + } + } catch (error) { + // 트랜잭션이 아직 active 상태인 경우에만 롤백 + if (transaction && !transaction.finished) { + await transaction.rollback(); + } + logger.error("Failed to delete device:", { + id, + error: error.message, + stack: error.stack, + }); + throw error; + } + } + + // 로깅 헬퍼 메서드 추가 + async logDeleteOperation(id, step, success = true, error = null) { + logger.info(`Delete operation step [${step}]`, { + deviceId: id, + success, + error: error ? error.message : null, + }); + } +} + +module.exports = new DeviceService(); diff --git a/fems-api/src/utils/initialSetup/dataSetup.js b/fems-api/src/utils/initialSetup/dataSetup.js index 17e5fb1..5d4652b 100644 --- a/fems-api/src/utils/initialSetup/dataSetup.js +++ b/fems-api/src/utils/initialSetup/dataSetup.js @@ -15,6 +15,7 @@ const { maintenanceLogDefinitions, equipmentDataTemplate, } = require("./setupData"); +const { initializeDevices } = require("./deviceInitializer"); const logger = require("../../config/logger"); const { createMaintenanceData } = require("./maintenanceSetup"); @@ -32,6 +33,9 @@ async function createInitialData(companyId, branchId) { // 정비 관련 데이터 생성 추가 await createMaintenanceData(companyId, branchId); + // 3. 디바이스 관련 데이터 생성 + await initializeDevices(companyId, branchId); + logger.info("Initial development data created successfully"); } catch (error) { logger.error("Error creating initial data:", error); diff --git a/fems-api/src/utils/initialSetup/deviceInitializer.js b/fems-api/src/utils/initialSetup/deviceInitializer.js new file mode 100644 index 0000000..74dc01b --- /dev/null +++ b/fems-api/src/utils/initialSetup/deviceInitializer.js @@ -0,0 +1,263 @@ +// src/utils/initialSetup/deviceInitializer.js +const logger = require("../config/logger"); +const { Device, DeviceConnection, DataPoint } = require("../models"); + +const initializeDevices = async () => { + try { + // 전력계 초기 데이터 + const powerMeter = { + device: { + device_type: "PowerMeter", + device_name: "power_meter_001", + manufacturer: "PowerTech", + model: "PM-2133", + location: "전기실 1", + description: "전력계 1 주요 전력 사용량 측정", + status: "ACTIVE", + companyId, + branchId, + }, + connection: { + protocol: "MODBUS_TCP", + ip_address: "192.168.0.3", + port: 1502, + unit_id: 1, + connection_timeout: 1000, + retry_interval: 2000, + }, + dataPoints: [ + { + tag_name: "instant_power", + description: "순시 전력", + data_type: "FLOAT", + address: "1000", + register_type: "HOLDING_REGISTER", + scan_rate: 1000, + unit: "kW", + }, + { + tag_name: "cumulative_power", + description: "적산 전력량", + data_type: "FLOAT", + address: "1002", + register_type: "HOLDING_REGISTER", + scan_rate: 1000, + unit: "kWh", + }, + { + tag_name: "voltage", + description: "전압", + data_type: "FLOAT", + address: "1004", + register_type: "HOLDING_REGISTER", + scan_rate: 1000, + unit: "V", + }, + { + tag_name: "current", + description: "전류", + data_type: "FLOAT", + address: "1006", + register_type: "HOLDING_REGISTER", + scan_rate: 1000, + unit: "A", + }, + { + tag_name: "power_factor", + description: "역률", + data_type: "FLOAT", + address: "1008", + register_type: "HOLDING_REGISTER", + scan_rate: 1000, + unit: "PF", + }, + ], + }; + + // PLC 초기 데이터 + const plc = { + device: { + device_type: "PLC", + device_name: "PLC0001", + manufacturer: "LSIS", + model: "XGB-DN32H", + location: "생산동 1층", + description: "생산라인 PLC 메인 생산라인 제어", + status: "ACTIVE", + companyId, + branchId, + }, + connection: { + protocol: "MODBUS_TCP", + ip_address: "192.168.0.8", + port: 2004, + unit_id: 1, + connection_timeout: 1000, + retry_interval: 2000, + }, + dataPoints: [ + // 공정 변수 + { + tag_name: "temperature", + description: "공정 온도", + data_type: "FLOAT", + address: "100", + register_type: "HOLDING_REGISTER", + scan_rate: 1000, + scale_factor: 0.1, + unit: "°C", + }, + { + tag_name: "pressure", + description: "공정 압력", + data_type: "FLOAT", + address: "102", + register_type: "HOLDING_REGISTER", + scan_rate: 1000, + scale_factor: 0.01, + unit: "bar", + }, + { + tag_name: "flow_rate", + description: "유량", + data_type: "FLOAT", + address: "104", + register_type: "HOLDING_REGISTER", + scan_rate: 1000, + scale_factor: 0.1, + unit: "L/min", + }, + { + tag_name: "motor_speed", + description: "모터 속도", + data_type: "UINT16", + address: "106", + register_type: "HOLDING_REGISTER", + scan_rate: 1000, + unit: "RPM", + }, + // 생산 카운터 + { + tag_name: "total_production", + description: "총 생산량", + data_type: "UINT32", + address: "200", + register_type: "HOLDING_REGISTER", + scan_rate: 1000, + }, + { + tag_name: "daily_production", + description: "일일 생산량", + data_type: "UINT32", + address: "202", + register_type: "HOLDING_REGISTER", + scan_rate: 1000, + }, + { + tag_name: "defect_count", + description: "불량 수량", + data_type: "UINT32", + address: "204", + register_type: "HOLDING_REGISTER", + scan_rate: 1000, + }, + { + tag_name: "operation_time", + description: "운전 시간", + data_type: "UINT32", + address: "206", + register_type: "HOLDING_REGISTER", + scan_rate: 1000, + unit: "min", + }, + // 상태 비트 + { + tag_name: "running", + description: "운전 상태", + data_type: "BOOL", + address: "0", + register_type: "COIL", + scan_rate: 1000, + }, + { + tag_name: "auto_mode", + description: "자동 모드", + data_type: "BOOL", + address: "1", + register_type: "COIL", + scan_rate: 1000, + }, + { + tag_name: "error_state", + description: "에러 상태", + data_type: "BOOL", + address: "2", + register_type: "COIL", + scan_rate: 1000, + }, + { + tag_name: "emergency_stop", + description: "비상 정지", + data_type: "BOOL", + address: "3", + register_type: "COIL", + scan_rate: 1000, + }, + { + tag_name: "maintenance_mode", + description: "유지보수 모드", + data_type: "BOOL", + address: "4", + register_type: "COIL", + scan_rate: 1000, + }, + ], + }; + + // 디바이스 생성 함수 + const createDeviceWithDetails = async (deviceData) => { + try { + // 디바이스 생성 + const device = await Device.create(deviceData.device); + logger.info( + `Created device: ${device.id} for company: ${companyId}, branch: ${branchId}` + ); + + // 연결 정보 생성 + await DeviceConnection.create({ + deviceId: device.id, + ...deviceData.connection, + }); + logger.info(`Created connection for device: ${device.id}`); + + // 데이터 포인트 생성 + const dataPointPromises = deviceData.dataPoints.map((point) => + DataPoint.create({ + deviceId: device.id, + ...point, + }) + ); + await Promise.all(dataPointPromises); + + logger.info(`Created data points for device: ${device.id}`); + return device; + } catch (error) { + logger.error(`Failed to create device details: ${error.message}`); + throw error; + } + }; + + // 디바이스 생성 실행 + await createDeviceWithDetails(powerMeter); + await createDeviceWithDetails(plc); + + logger.info("Device initialization completed successfully"); + } catch (error) { + logger.error("Failed to initialize devices:", error); + throw error; + } +}; + +module.exports = { + initializeDevices, +}; diff --git a/fems-app/src/app/(equipment)/devices/[id]/page.tsx b/fems-app/src/app/(equipment)/devices/[id]/page.tsx new file mode 100644 index 0000000..c72bac4 --- /dev/null +++ b/fems-app/src/app/(equipment)/devices/[id]/page.tsx @@ -0,0 +1,102 @@ +// src/app/devices/list/[id]/page.tsx +"use client"; + +import React from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useRouter } from "next/navigation"; +import { DeviceForm } from "../components/DeviceForm"; +import { api } from "@/lib/api"; +import { DataPoint, Device } from "@/types/device"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { AlertCircle } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +export default function EditDevicePage({ params }: { params: { id: string } }) { + const router = useRouter(); + + // 디바이스 정보 조회 (데이터 포인트 포함) + const { + data: device, + isError, + isLoading, + } = useQuery({ + queryKey: ["device", params.id], + queryFn: async () => { + const { data } = await api.get(`/api/v1/app/devices/${params.id}`); + console.log(data); + return { + ...data, + // 백엔드에서 받은 데이터 포인트를 프론트엔드 형식으로 변환 + data_points: data.DataPoints?.map((point: DataPoint) => ({ + id: point.id, + tag_name: point.tag_name || "", + description: point.description || "", + data_type: point.data_type, + address: point.address, + register_type: point.register_type || "HOLDING_REGISTER", + scan_rate: point.scan_rate || 1000, + scale_factor: point.scale_factor || 1, + offset: point.offset || 0, + unit: point.unit || "", + is_active: point.is_active !== false, + })), + // connection 데이터도 정규화 + connection: { + protocol: data.DeviceConnection?.protocol || "MODBUS_TCP", + ip_address: data.DeviceConnection?.ip_address || "", + port: data.DeviceConnection?.port || 502, + unit_id: data.DeviceConnection?.unit_id || 1, + connection_timeout: data.DeviceConnection?.connection_timeout || 1000, + retry_interval: data.DeviceConnection?.retry_interval || 5000, + protocol_settings: data.DeviceConnection?.protocol_settings || {}, + }, + }; + }, + // staleTime: 0, // 항상 최신 데이터를 가져오도록 설정 + // cacheTime: 0, // 캐시를 사용하지 않음 + }); + + if (isLoading) { + return ( +
+
디바이스 정보를 불러오는 중...
+
+ ); + } + + if (isError) { + return ( +
+ + + + 디바이스 정보를 불러오는데 실패했습니다. + + + +
+ ); + } + + return ( +
+
+

디바이스 수정

+

디바이스 정보를 수정합니다.

+
+ {device && ( + router.push("/devices/list")} + onSuccess={() => router.push("/devices/list")} + /> + )} +
+ ); +} diff --git a/fems-app/src/app/(equipment)/devices/components/Detail/DeviceDetailDialog.tsx b/fems-app/src/app/(equipment)/devices/components/Detail/DeviceDetailDialog.tsx new file mode 100644 index 0000000..23c114a --- /dev/null +++ b/fems-app/src/app/(equipment)/devices/components/Detail/DeviceDetailDialog.tsx @@ -0,0 +1,239 @@ +// src/app/devices/components/Detail/DeviceDetailDialog.tsx +"use client"; + +import React from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { Badge } from "@/components/ui/badge"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Wifi, WifiOff, AlertCircle, Terminal, Settings2 } from "lucide-react"; +import { DataPoint, Device } from "@/types/device"; +import { useQuery } from "@tanstack/react-query"; +import { api } from "@/lib/api"; + +interface DeviceDetailDialogProps { + device: Device | null; + isOpen: boolean; + onClose: () => void; +} + +export function DeviceDetailDialog({ + device, + isOpen, + onClose, +}: DeviceDetailDialogProps) { + // Fetch device details + const { data: deviceDetail } = useQuery({ + queryKey: ["device", device?.id], + queryFn: async () => { + if (!device) return null; + const { data } = await api.get(`/api/v1/app/devices/${device.id}`); + return { + ...data, + // connection 데이터 정규화 + connection: { + protocol: data.DeviceConnection?.protocol || "MODBUS_TCP", + ip_address: data.DeviceConnection?.ip_address || "", + port: data.DeviceConnection?.port || 502, + unit_id: data.DeviceConnection?.unit_id || 1, + connection_timeout: data.DeviceConnection?.connection_timeout || 1000, + retry_interval: data.DeviceConnection?.retry_interval || 5000, + protocol_settings: data.DeviceConnection?.protocol_settings || {}, + }, + // 데이터 포인트 정규화 + data_points: data.DataPoints?.map((point: DataPoint) => ({ + id: point.id, + tag_name: point.tag_name || "", + description: point.description || "", + data_type: point.data_type, + address: point.address, + register_type: point.register_type || "HOLDING_REGISTER", + scan_rate: point.scan_rate || 1000, + scale_factor: point.scale_factor || 1, + offset: point.offset || 0, + unit: point.unit || "", + is_active: point.is_active !== false, + })), + }; + }, + enabled: !!device && isOpen, + }); + + if (!device || !deviceDetail) return null; + + const statusMap = { + ACTIVE: { label: "활성", icon: Wifi, variant: "default" }, + INACTIVE: { label: "비활성", icon: WifiOff, variant: "secondary" }, + MAINTENANCE: { label: "유지보수", icon: Settings2, variant: "outline" }, + ERROR: { label: "오류", icon: AlertCircle, variant: "destructive" }, + } as const; + + const protocolMap = { + MODBUS_TCP: "Modbus TCP", + MODBUS_RTU: "Modbus RTU", + OPC_UA: "OPC UA", + MQTT: "MQTT", + BACNET: "BACnet", + } as const; + + const status = statusMap[deviceDetail.status as keyof typeof statusMap]; + const StatusIcon = status.icon; + + return ( + + + + 디바이스 상세 정보 + + 선택한 디바이스의 상세 정보를 확인합니다. + + + +
+ {/* 기본 정보 */} + + + 기본 정보 + + +
+
디바이스 ID
+
{deviceDetail.id}
+ +
디바이스명
+
{deviceDetail.device_name}
+ +
유형
+
{deviceDetail.device_type}
+ +
상태
+
+ + + {status.label} + +
+ +
설치 위치
+
{deviceDetail.location}
+ +
설명
+
{deviceDetail.description || "-"}
+
+
+
+ + {/* 하드웨어 정보 */} + + + 하드웨어 정보 + + +
+
제조사
+
{deviceDetail.manufacturer}
+ +
모델
+
{deviceDetail.model}
+ +
시리얼 번호
+
{deviceDetail.serial_number || "-"}
+
+
+
+ + {/* 통신 설정 */} + + + 통신 설정 + + +
+
프로토콜
+
+ {deviceDetail.connection && ( + + + { + protocolMap[ + deviceDetail.connection + .protocol as keyof typeof protocolMap + ] + } + + )} +
+ + {deviceDetail.connection?.ip_address && ( + <> +
IP 주소
+
{deviceDetail.connection.ip_address}
+ +
포트
+
{deviceDetail.connection.port}
+ +
Unit ID
+
{deviceDetail.connection.unit_id}
+ + )} + + {deviceDetail.connection && ( + <> +
+ 연결 타임아웃 +
+
{deviceDetail.connection.connection_timeout}ms
+ +
+ 재시도 간격 +
+
{deviceDetail.connection.retry_interval}ms
+ + )} +
+
+
+ + {/* 데이터 포인트 */} + + + 데이터 포인트 + + + {deviceDetail.data_points && + deviceDetail.data_points.length > 0 ? ( +
+ {deviceDetail.data_points.map((point: DataPoint) => ( +
+
+
{point.tag_name}
+ {point.data_type} +
+
+ 주소: {point.address} + {point.register_type && ` (${point.register_type})`} +
+
+ 스캔 주기: {point.scan_rate}ms + {point.unit && ` | 단위: ${point.unit}`} +
+
+ ))} +
+ ) : ( +
+ 등록된 데이터 포인트가 없습니다. +
+ )} +
+
+
+
+
+ ); +} diff --git a/fems-app/src/app/(equipment)/devices/components/DeviceForm/DataPointFormFields.tsx b/fems-app/src/app/(equipment)/devices/components/DeviceForm/DataPointFormFields.tsx new file mode 100644 index 0000000..13ee66a --- /dev/null +++ b/fems-app/src/app/(equipment)/devices/components/DeviceForm/DataPointFormFields.tsx @@ -0,0 +1,249 @@ +// src/app/devices/list/components/DeviceForm/DataPointFormFields.tsx +import React from "react"; +import { UseFormReturn } from "react-hook-form"; +import { + FormField, + FormItem, + FormLabel, + FormControl, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { DeviceFormData } from "./schema"; + +interface DataPointFormFieldsProps { + form: UseFormReturn; + index: number; + isSubmitting: boolean; +} + +export function DataPointFormFields({ + form, + index, + isSubmitting, +}: DataPointFormFieldsProps) { + return ( +
+
+
+ {/* 태그명 */} + ( + + 태그명 + + + + + + )} + /> + + {/* 데이터 타입 */} + ( + + 데이터 타입 + + + + )} + /> + + {/* 레지스터 타입 */} + ( + + 레지스터 타입 + + + + )} + /> + + {/* 주소 */} + ( + + 주소 + + + + + + )} + /> + + {/* 스캔 주기 */} + ( + + 스캔 주기 (ms) + + field.onChange(Number(e.target.value))} + disabled={isSubmitting} + /> + + + + )} + /> + + {/* 스케일 팩터 */} + ( + + 스케일 팩터 + + field.onChange(Number(e.target.value))} + disabled={isSubmitting} + /> + + + + )} + /> + + {/* 오프셋 */} + ( + + 오프셋 + + field.onChange(Number(e.target.value))} + disabled={isSubmitting} + /> + + + + )} + /> + + {/* 단위 */} + ( + + 단위 + + + + + + )} + /> +
+ + {/* 설명 */} + ( + + 설명 + + + + + + )} + /> +
+
+ ); +} diff --git a/fems-app/src/app/(equipment)/devices/components/DeviceForm/DeviceFormFields.tsx b/fems-app/src/app/(equipment)/devices/components/DeviceForm/DeviceFormFields.tsx new file mode 100644 index 0000000..d116f29 --- /dev/null +++ b/fems-app/src/app/(equipment)/devices/components/DeviceForm/DeviceFormFields.tsx @@ -0,0 +1,365 @@ +// src/app/devices/list/components/DeviceForm/DeviceFormFields.tsx +import React from "react"; +import { UseFormReturn } from "react-hook-form"; +import { + FormField, + FormItem, + FormLabel, + FormControl, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { DeviceFormData } from "./schema"; + +interface DeviceFormFieldsProps { + form: UseFormReturn; + isSubmitting: boolean; +} + +export function DeviceFormFields({ + form, + isSubmitting, +}: DeviceFormFieldsProps) { + // 프로토콜에 따른 필드 표시 여부 + + return ( +
+ {/* 기본 정보 섹션 */} +
+

기본 정보

+
+ {/* 디바이스명 */} + ( + + 디바이스명 + + + + + + )} + /> + + {/* 디바이스 유형 */} + ( + + 디바이스 유형 + + + + )} + /> + + {/* 제조사 */} + ( + + 제조사 + + + + + + )} + /> + + {/* 모델명 */} + ( + + 모델명 + + + + + + )} + /> + + {/* 시리얼 번호 */} + ( + + 시리얼 번호 + + + + + + )} + /> + + {/* 설치 위치 */} + ( + + 설치 위치 + + + + + + )} + /> +
+ + {/* 설명 */} + ( + + 설명 + +