From 1eec79d860de1456791e22b4d0ada7f3e37cd441 Mon Sep 17 00:00:00 2001 From: GUARDiA Date: Sat, 4 Jul 2026 09:59:47 +0900 Subject: [PATCH] =?UTF-8?q?feat(mro):=20GUARDiA=20MRO=20=EC=B4=88=EA=B8=B0?= =?UTF-8?q?=20=EC=BB=A4=EB=B0=8B=20(=EC=84=A4=EB=B9=84=EB=B3=B4=EC=A0=84?= =?UTF-8?q?=20EAM/CMMS=20+=20MRO=20=EC=9E=90=EC=9E=AC/=EA=B5=AC=EB=A7=A4/?= =?UTF-8?q?=EC=9E=AC=EA=B3=A0,=208018,=20com.zioinfo.mro)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 7 + backend/pom.xml | 87 + .../java/com/zioinfo/mro/MroApplication.java | 27 + .../zioinfo/mro/admin/AdminController.java | 96 + .../zioinfo/mro/admin/AdminUserService.java | 118 + .../com/zioinfo/mro/admin/AuditService.java | 53 + .../com/zioinfo/mro/admin/SettingService.java | 35 + .../com/zioinfo/mro/admin/dto/AuditLog.java | 16 + .../com/zioinfo/mro/admin/dto/MroSetting.java | 13 + .../com/zioinfo/mro/admin/dto/UserDto.java | 30 + .../mro/admin/mapper/AdminUserMapper.java | 31 + .../mro/admin/mapper/AuditLogMapper.java | 17 + .../mro/admin/mapper/SettingMapper.java | 17 + .../java/com/zioinfo/mro/ai/AiController.java | 67 + .../java/com/zioinfo/mro/ai/AiService.java | 214 ++ .../java/com/zioinfo/mro/ai/OllamaClient.java | 100 + .../mro/ai/controller/AiConfigController.java | 50 + .../com/zioinfo/mro/ai/dto/AiConfigDto.java | 18 + .../mro/ai/dto/AiConfigUpdateRequest.java | 14 + .../mro/ai/learning/AiFeedbackController.java | 91 + .../mro/ai/learning/AiLearningStore.java | 163 + .../mro/ai/service/AiConfigService.java | 215 ++ .../zioinfo/mro/ai/service/AiTextRouter.java | 63 + .../com/zioinfo/mro/auth/AuthController.java | 29 + .../com/zioinfo/mro/auth/AuthService.java | 48 + .../java/com/zioinfo/mro/auth/JwtFilter.java | 40 + .../java/com/zioinfo/mro/auth/JwtUtil.java | 59 + .../java/com/zioinfo/mro/auth/MroUser.java | 22 + .../zioinfo/mro/auth/mapper/UserMapper.java | 14 + .../calibration/CalibrationController.java | 51 + .../mro/calibration/CalibrationService.java | 72 + .../mro/calibration/MroCalibration.java | 26 + .../calibration/mapper/CalibrationMapper.java | 20 + .../com/zioinfo/mro/common/ApiResponse.java | 21 + .../com/zioinfo/mro/common/AuthSupport.java | 36 + .../com/zioinfo/mro/common/CryptoUtil.java | 93 + .../mro/common/GlobalExceptionHandler.java | 65 + .../mro/common/ai/ClaudeTextClient.java | 159 + .../zioinfo/mro/common/ai/TextAiClient.java | 22 + .../com/zioinfo/mro/config/CorsConfig.java | 25 + .../com/zioinfo/mro/config/MyBatisConfig.java | 36 + .../zioinfo/mro/config/SecurityConfig.java | 85 + .../mro/config/SpaForwardController.java | 26 + .../com/zioinfo/mro/cost/CostController.java | 55 + .../com/zioinfo/mro/cost/CostService.java | 74 + .../java/com/zioinfo/mro/cost/MroCost.java | 26 + .../zioinfo/mro/cost/mapper/CostMapper.java | 20 + .../mro/dashboard/DashboardController.java | 40 + .../mro/dashboard/DashboardService.java | 68 + .../mro/dashboard/mapper/DashboardMapper.java | 38 + .../mro/downtime/DowntimeController.java | 48 + .../zioinfo/mro/downtime/DowntimeService.java | 73 + .../com/zioinfo/mro/downtime/MroDowntime.java | 23 + .../mro/downtime/mapper/DowntimeMapper.java | 17 + .../mro/equipment/EquipmentController.java | 59 + .../mro/equipment/EquipmentService.java | 88 + .../zioinfo/mro/equipment/MroEquipment.java | 30 + .../mro/equipment/mapper/EquipmentMapper.java | 21 + .../mro/inventory/InventoryController.java | 49 + .../mro/inventory/InventoryService.java | 73 + .../zioinfo/mro/inventory/MroInventory.java | 24 + .../mro/inventory/mapper/InventoryMapper.java | 19 + .../inventorytxn/InventoryTxnController.java | 37 + .../mro/inventorytxn/InventoryTxnService.java | 79 + .../mro/inventorytxn/MroInventoryTxn.java | 27 + .../mapper/InventoryTxnMapper.java | 25 + .../maintenance/MaintenanceController.java | 50 + .../mro/maintenance/MaintenanceService.java | 75 + .../maintenance/MroMaintenanceHistory.java | 27 + .../maintenance/mapper/MaintenanceMapper.java | 18 + .../mro/material/MaterialController.java | 51 + .../zioinfo/mro/material/MaterialService.java | 73 + .../com/zioinfo/mro/material/MroMaterial.java | 31 + .../mro/material/mapper/MaterialMapper.java | 20 + .../materialbom/MaterialBomController.java | 50 + .../mro/materialbom/MaterialBomService.java | 62 + .../mro/materialbom/MroMaterialBom.java | 21 + .../materialbom/mapper/MaterialBomMapper.java | 17 + .../com/zioinfo/mro/partner/MroPartner.java | 34 + .../mro/partner/PartnerController.java | 51 + .../zioinfo/mro/partner/PartnerService.java | 92 + .../mro/partner/mapper/PartnerMapper.java | 20 + .../com/zioinfo/mro/pmplan/MroPmPlan.java | 27 + .../zioinfo/mro/pmplan/PmPlanController.java | 51 + .../com/zioinfo/mro/pmplan/PmPlanService.java | 72 + .../mro/pmplan/mapper/PmPlanMapper.java | 20 + .../mro/purchaseorder/MroPurchaseOrder.java | 30 + .../PurchaseOrderController.java | 63 + .../purchaseorder/PurchaseOrderService.java | 125 + .../mapper/PurchaseOrderMapper.java | 18 + .../purchaserequest/MroPurchaseRequest.java | 30 + .../PurchaseRequestController.java | 59 + .../PurchaseRequestService.java | 114 + .../mapper/PurchaseRequestMapper.java | 17 + .../zioinfo/mro/receiving/MroReceiving.java | 24 + .../mro/receiving/ReceivingController.java | 43 + .../mro/receiving/ReceivingService.java | 68 + .../mro/receiving/mapper/ReceivingMapper.java | 17 + .../reliability/ReliabilityController.java | 34 + .../mro/reliability/ReliabilityDto.java | 18 + .../mro/reliability/ReliabilityService.java | 116 + .../reliability/mapper/ReliabilityMapper.java | 23 + .../zioinfo/mro/stocktake/MroStocktake.java | 26 + .../mro/stocktake/StocktakeController.java | 54 + .../mro/stocktake/StocktakeService.java | 69 + .../mro/stocktake/mapper/StocktakeMapper.java | 17 + .../zioinfo/mro/workorder/MroWorkOrder.java | 35 + .../mro/workorder/WorkOrderController.java | 75 + .../mro/workorder/WorkOrderService.java | 142 + .../mro/workorder/mapper/WorkOrderMapper.java | 20 + backend/src/main/resources/application.yml | 68 + .../main/resources/db/104_seed_ai_config.sql | 33 + backend/src/main/resources/db/schema.sql | 411 ++ .../main/resources/mapper/AdminUserMapper.xml | 57 + .../main/resources/mapper/AuditLogMapper.xml | 31 + .../resources/mapper/CalibrationMapper.xml | 60 + .../src/main/resources/mapper/CostMapper.xml | 58 + .../main/resources/mapper/DashboardMapper.xml | 81 + .../main/resources/mapper/DowntimeMapper.xml | 43 + .../main/resources/mapper/EquipmentMapper.xml | 70 + .../main/resources/mapper/InventoryMapper.xml | 57 + .../resources/mapper/InventoryTxnMapper.xml | 58 + .../resources/mapper/MaintenanceMapper.xml | 57 + .../resources/mapper/MaterialBomMapper.xml | 43 + .../main/resources/mapper/MaterialMapper.xml | 69 + .../main/resources/mapper/PartnerMapper.xml | 61 + .../main/resources/mapper/PmPlanMapper.xml | 59 + .../resources/mapper/PurchaseOrderMapper.xml | 59 + .../mapper/PurchaseRequestMapper.xml | 58 + .../main/resources/mapper/ReceivingMapper.xml | 48 + .../resources/mapper/ReliabilityMapper.xml | 36 + .../main/resources/mapper/SettingMapper.xml | 25 + .../main/resources/mapper/StocktakeMapper.xml | 48 + .../src/main/resources/mapper/UserMapper.xml | 28 + .../main/resources/mapper/WorkOrderMapper.xml | 65 + .../resources/static/assets/index-BP-omnwj.js | 140 + .../static/assets/index-DdO2G96-.css | 1 + backend/src/main/resources/static/index.html | 13 + frontend/index.html | 12 + frontend/package-lock.json | 3395 +++++++++++++++++ frontend/package.json | 29 + frontend/postcss.config.js | 6 + frontend/src/App.tsx | 89 + frontend/src/api/adminAiConfig.ts | 47 + frontend/src/api/client.ts | 220 ++ frontend/src/components/Chart.tsx | 102 + frontend/src/components/Header.tsx | 43 + frontend/src/components/Layout.tsx | 35 + frontend/src/components/ProtectedRoute.tsx | 27 + frontend/src/components/Sidebar.tsx | 118 + frontend/src/components/StatusBadge.tsx | 64 + frontend/src/components/icons.tsx | 229 ++ frontend/src/components/rbac.ts | 22 + frontend/src/components/ui.tsx | 117 + frontend/src/index.css | 32 + frontend/src/main.tsx | 10 + frontend/src/pages/AiPlatformSettings.tsx | 226 ++ frontend/src/pages/AiTools.tsx | 196 + frontend/src/pages/AuditLog.tsx | 54 + frontend/src/pages/Calibrations.tsx | 126 + frontend/src/pages/Costs.tsx | 137 + frontend/src/pages/Dashboard.tsx | 143 + frontend/src/pages/Downtimes.tsx | 125 + frontend/src/pages/Equipment.tsx | 145 + frontend/src/pages/Inventory.tsx | 137 + frontend/src/pages/InventoryTxns.tsx | 108 + frontend/src/pages/Login.tsx | 121 + frontend/src/pages/MaintenanceHistory.tsx | 123 + frontend/src/pages/MaterialBoms.tsx | 99 + frontend/src/pages/Materials.tsx | 129 + frontend/src/pages/MobileApp.tsx | 122 + frontend/src/pages/MyPage.tsx | 240 ++ frontend/src/pages/Partners.tsx | 115 + frontend/src/pages/PmPlans.tsx | 209 + frontend/src/pages/PurchaseOrders.tsx | 121 + frontend/src/pages/PurchaseRequests.tsx | 102 + frontend/src/pages/Receivings.tsx | 85 + frontend/src/pages/Reliability.tsx | 96 + frontend/src/pages/Stocktakes.tsx | 99 + frontend/src/pages/SystemSettings.tsx | 72 + frontend/src/pages/UserManagement.tsx | 94 + frontend/src/pages/WorkOrders.tsx | 202 + frontend/tailwind.config.js | 19 + frontend/tsconfig.json | 20 + frontend/vite.config.ts | 19 + 185 files changed, 15521 insertions(+) create mode 100644 .gitignore create mode 100644 backend/pom.xml create mode 100644 backend/src/main/java/com/zioinfo/mro/MroApplication.java create mode 100644 backend/src/main/java/com/zioinfo/mro/admin/AdminController.java create mode 100644 backend/src/main/java/com/zioinfo/mro/admin/AdminUserService.java create mode 100644 backend/src/main/java/com/zioinfo/mro/admin/AuditService.java create mode 100644 backend/src/main/java/com/zioinfo/mro/admin/SettingService.java create mode 100644 backend/src/main/java/com/zioinfo/mro/admin/dto/AuditLog.java create mode 100644 backend/src/main/java/com/zioinfo/mro/admin/dto/MroSetting.java create mode 100644 backend/src/main/java/com/zioinfo/mro/admin/dto/UserDto.java create mode 100644 backend/src/main/java/com/zioinfo/mro/admin/mapper/AdminUserMapper.java create mode 100644 backend/src/main/java/com/zioinfo/mro/admin/mapper/AuditLogMapper.java create mode 100644 backend/src/main/java/com/zioinfo/mro/admin/mapper/SettingMapper.java create mode 100644 backend/src/main/java/com/zioinfo/mro/ai/AiController.java create mode 100644 backend/src/main/java/com/zioinfo/mro/ai/AiService.java create mode 100644 backend/src/main/java/com/zioinfo/mro/ai/OllamaClient.java create mode 100644 backend/src/main/java/com/zioinfo/mro/ai/controller/AiConfigController.java create mode 100644 backend/src/main/java/com/zioinfo/mro/ai/dto/AiConfigDto.java create mode 100644 backend/src/main/java/com/zioinfo/mro/ai/dto/AiConfigUpdateRequest.java create mode 100644 backend/src/main/java/com/zioinfo/mro/ai/learning/AiFeedbackController.java create mode 100644 backend/src/main/java/com/zioinfo/mro/ai/learning/AiLearningStore.java create mode 100644 backend/src/main/java/com/zioinfo/mro/ai/service/AiConfigService.java create mode 100644 backend/src/main/java/com/zioinfo/mro/ai/service/AiTextRouter.java create mode 100644 backend/src/main/java/com/zioinfo/mro/auth/AuthController.java create mode 100644 backend/src/main/java/com/zioinfo/mro/auth/AuthService.java create mode 100644 backend/src/main/java/com/zioinfo/mro/auth/JwtFilter.java create mode 100644 backend/src/main/java/com/zioinfo/mro/auth/JwtUtil.java create mode 100644 backend/src/main/java/com/zioinfo/mro/auth/MroUser.java create mode 100644 backend/src/main/java/com/zioinfo/mro/auth/mapper/UserMapper.java create mode 100644 backend/src/main/java/com/zioinfo/mro/calibration/CalibrationController.java create mode 100644 backend/src/main/java/com/zioinfo/mro/calibration/CalibrationService.java create mode 100644 backend/src/main/java/com/zioinfo/mro/calibration/MroCalibration.java create mode 100644 backend/src/main/java/com/zioinfo/mro/calibration/mapper/CalibrationMapper.java create mode 100644 backend/src/main/java/com/zioinfo/mro/common/ApiResponse.java create mode 100644 backend/src/main/java/com/zioinfo/mro/common/AuthSupport.java create mode 100644 backend/src/main/java/com/zioinfo/mro/common/CryptoUtil.java create mode 100644 backend/src/main/java/com/zioinfo/mro/common/GlobalExceptionHandler.java create mode 100644 backend/src/main/java/com/zioinfo/mro/common/ai/ClaudeTextClient.java create mode 100644 backend/src/main/java/com/zioinfo/mro/common/ai/TextAiClient.java create mode 100644 backend/src/main/java/com/zioinfo/mro/config/CorsConfig.java create mode 100644 backend/src/main/java/com/zioinfo/mro/config/MyBatisConfig.java create mode 100644 backend/src/main/java/com/zioinfo/mro/config/SecurityConfig.java create mode 100644 backend/src/main/java/com/zioinfo/mro/config/SpaForwardController.java create mode 100644 backend/src/main/java/com/zioinfo/mro/cost/CostController.java create mode 100644 backend/src/main/java/com/zioinfo/mro/cost/CostService.java create mode 100644 backend/src/main/java/com/zioinfo/mro/cost/MroCost.java create mode 100644 backend/src/main/java/com/zioinfo/mro/cost/mapper/CostMapper.java create mode 100644 backend/src/main/java/com/zioinfo/mro/dashboard/DashboardController.java create mode 100644 backend/src/main/java/com/zioinfo/mro/dashboard/DashboardService.java create mode 100644 backend/src/main/java/com/zioinfo/mro/dashboard/mapper/DashboardMapper.java create mode 100644 backend/src/main/java/com/zioinfo/mro/downtime/DowntimeController.java create mode 100644 backend/src/main/java/com/zioinfo/mro/downtime/DowntimeService.java create mode 100644 backend/src/main/java/com/zioinfo/mro/downtime/MroDowntime.java create mode 100644 backend/src/main/java/com/zioinfo/mro/downtime/mapper/DowntimeMapper.java create mode 100644 backend/src/main/java/com/zioinfo/mro/equipment/EquipmentController.java create mode 100644 backend/src/main/java/com/zioinfo/mro/equipment/EquipmentService.java create mode 100644 backend/src/main/java/com/zioinfo/mro/equipment/MroEquipment.java create mode 100644 backend/src/main/java/com/zioinfo/mro/equipment/mapper/EquipmentMapper.java create mode 100644 backend/src/main/java/com/zioinfo/mro/inventory/InventoryController.java create mode 100644 backend/src/main/java/com/zioinfo/mro/inventory/InventoryService.java create mode 100644 backend/src/main/java/com/zioinfo/mro/inventory/MroInventory.java create mode 100644 backend/src/main/java/com/zioinfo/mro/inventory/mapper/InventoryMapper.java create mode 100644 backend/src/main/java/com/zioinfo/mro/inventorytxn/InventoryTxnController.java create mode 100644 backend/src/main/java/com/zioinfo/mro/inventorytxn/InventoryTxnService.java create mode 100644 backend/src/main/java/com/zioinfo/mro/inventorytxn/MroInventoryTxn.java create mode 100644 backend/src/main/java/com/zioinfo/mro/inventorytxn/mapper/InventoryTxnMapper.java create mode 100644 backend/src/main/java/com/zioinfo/mro/maintenance/MaintenanceController.java create mode 100644 backend/src/main/java/com/zioinfo/mro/maintenance/MaintenanceService.java create mode 100644 backend/src/main/java/com/zioinfo/mro/maintenance/MroMaintenanceHistory.java create mode 100644 backend/src/main/java/com/zioinfo/mro/maintenance/mapper/MaintenanceMapper.java create mode 100644 backend/src/main/java/com/zioinfo/mro/material/MaterialController.java create mode 100644 backend/src/main/java/com/zioinfo/mro/material/MaterialService.java create mode 100644 backend/src/main/java/com/zioinfo/mro/material/MroMaterial.java create mode 100644 backend/src/main/java/com/zioinfo/mro/material/mapper/MaterialMapper.java create mode 100644 backend/src/main/java/com/zioinfo/mro/materialbom/MaterialBomController.java create mode 100644 backend/src/main/java/com/zioinfo/mro/materialbom/MaterialBomService.java create mode 100644 backend/src/main/java/com/zioinfo/mro/materialbom/MroMaterialBom.java create mode 100644 backend/src/main/java/com/zioinfo/mro/materialbom/mapper/MaterialBomMapper.java create mode 100644 backend/src/main/java/com/zioinfo/mro/partner/MroPartner.java create mode 100644 backend/src/main/java/com/zioinfo/mro/partner/PartnerController.java create mode 100644 backend/src/main/java/com/zioinfo/mro/partner/PartnerService.java create mode 100644 backend/src/main/java/com/zioinfo/mro/partner/mapper/PartnerMapper.java create mode 100644 backend/src/main/java/com/zioinfo/mro/pmplan/MroPmPlan.java create mode 100644 backend/src/main/java/com/zioinfo/mro/pmplan/PmPlanController.java create mode 100644 backend/src/main/java/com/zioinfo/mro/pmplan/PmPlanService.java create mode 100644 backend/src/main/java/com/zioinfo/mro/pmplan/mapper/PmPlanMapper.java create mode 100644 backend/src/main/java/com/zioinfo/mro/purchaseorder/MroPurchaseOrder.java create mode 100644 backend/src/main/java/com/zioinfo/mro/purchaseorder/PurchaseOrderController.java create mode 100644 backend/src/main/java/com/zioinfo/mro/purchaseorder/PurchaseOrderService.java create mode 100644 backend/src/main/java/com/zioinfo/mro/purchaseorder/mapper/PurchaseOrderMapper.java create mode 100644 backend/src/main/java/com/zioinfo/mro/purchaserequest/MroPurchaseRequest.java create mode 100644 backend/src/main/java/com/zioinfo/mro/purchaserequest/PurchaseRequestController.java create mode 100644 backend/src/main/java/com/zioinfo/mro/purchaserequest/PurchaseRequestService.java create mode 100644 backend/src/main/java/com/zioinfo/mro/purchaserequest/mapper/PurchaseRequestMapper.java create mode 100644 backend/src/main/java/com/zioinfo/mro/receiving/MroReceiving.java create mode 100644 backend/src/main/java/com/zioinfo/mro/receiving/ReceivingController.java create mode 100644 backend/src/main/java/com/zioinfo/mro/receiving/ReceivingService.java create mode 100644 backend/src/main/java/com/zioinfo/mro/receiving/mapper/ReceivingMapper.java create mode 100644 backend/src/main/java/com/zioinfo/mro/reliability/ReliabilityController.java create mode 100644 backend/src/main/java/com/zioinfo/mro/reliability/ReliabilityDto.java create mode 100644 backend/src/main/java/com/zioinfo/mro/reliability/ReliabilityService.java create mode 100644 backend/src/main/java/com/zioinfo/mro/reliability/mapper/ReliabilityMapper.java create mode 100644 backend/src/main/java/com/zioinfo/mro/stocktake/MroStocktake.java create mode 100644 backend/src/main/java/com/zioinfo/mro/stocktake/StocktakeController.java create mode 100644 backend/src/main/java/com/zioinfo/mro/stocktake/StocktakeService.java create mode 100644 backend/src/main/java/com/zioinfo/mro/stocktake/mapper/StocktakeMapper.java create mode 100644 backend/src/main/java/com/zioinfo/mro/workorder/MroWorkOrder.java create mode 100644 backend/src/main/java/com/zioinfo/mro/workorder/WorkOrderController.java create mode 100644 backend/src/main/java/com/zioinfo/mro/workorder/WorkOrderService.java create mode 100644 backend/src/main/java/com/zioinfo/mro/workorder/mapper/WorkOrderMapper.java create mode 100644 backend/src/main/resources/application.yml create mode 100644 backend/src/main/resources/db/104_seed_ai_config.sql create mode 100644 backend/src/main/resources/db/schema.sql create mode 100644 backend/src/main/resources/mapper/AdminUserMapper.xml create mode 100644 backend/src/main/resources/mapper/AuditLogMapper.xml create mode 100644 backend/src/main/resources/mapper/CalibrationMapper.xml create mode 100644 backend/src/main/resources/mapper/CostMapper.xml create mode 100644 backend/src/main/resources/mapper/DashboardMapper.xml create mode 100644 backend/src/main/resources/mapper/DowntimeMapper.xml create mode 100644 backend/src/main/resources/mapper/EquipmentMapper.xml create mode 100644 backend/src/main/resources/mapper/InventoryMapper.xml create mode 100644 backend/src/main/resources/mapper/InventoryTxnMapper.xml create mode 100644 backend/src/main/resources/mapper/MaintenanceMapper.xml create mode 100644 backend/src/main/resources/mapper/MaterialBomMapper.xml create mode 100644 backend/src/main/resources/mapper/MaterialMapper.xml create mode 100644 backend/src/main/resources/mapper/PartnerMapper.xml create mode 100644 backend/src/main/resources/mapper/PmPlanMapper.xml create mode 100644 backend/src/main/resources/mapper/PurchaseOrderMapper.xml create mode 100644 backend/src/main/resources/mapper/PurchaseRequestMapper.xml create mode 100644 backend/src/main/resources/mapper/ReceivingMapper.xml create mode 100644 backend/src/main/resources/mapper/ReliabilityMapper.xml create mode 100644 backend/src/main/resources/mapper/SettingMapper.xml create mode 100644 backend/src/main/resources/mapper/StocktakeMapper.xml create mode 100644 backend/src/main/resources/mapper/UserMapper.xml create mode 100644 backend/src/main/resources/mapper/WorkOrderMapper.xml create mode 100644 backend/src/main/resources/static/assets/index-BP-omnwj.js create mode 100644 backend/src/main/resources/static/assets/index-DdO2G96-.css create mode 100644 backend/src/main/resources/static/index.html create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/api/adminAiConfig.ts create mode 100644 frontend/src/api/client.ts create mode 100644 frontend/src/components/Chart.tsx create mode 100644 frontend/src/components/Header.tsx create mode 100644 frontend/src/components/Layout.tsx create mode 100644 frontend/src/components/ProtectedRoute.tsx create mode 100644 frontend/src/components/Sidebar.tsx create mode 100644 frontend/src/components/StatusBadge.tsx create mode 100644 frontend/src/components/icons.tsx create mode 100644 frontend/src/components/rbac.ts create mode 100644 frontend/src/components/ui.tsx create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/AiPlatformSettings.tsx create mode 100644 frontend/src/pages/AiTools.tsx create mode 100644 frontend/src/pages/AuditLog.tsx create mode 100644 frontend/src/pages/Calibrations.tsx create mode 100644 frontend/src/pages/Costs.tsx create mode 100644 frontend/src/pages/Dashboard.tsx create mode 100644 frontend/src/pages/Downtimes.tsx create mode 100644 frontend/src/pages/Equipment.tsx create mode 100644 frontend/src/pages/Inventory.tsx create mode 100644 frontend/src/pages/InventoryTxns.tsx create mode 100644 frontend/src/pages/Login.tsx create mode 100644 frontend/src/pages/MaintenanceHistory.tsx create mode 100644 frontend/src/pages/MaterialBoms.tsx create mode 100644 frontend/src/pages/Materials.tsx create mode 100644 frontend/src/pages/MobileApp.tsx create mode 100644 frontend/src/pages/MyPage.tsx create mode 100644 frontend/src/pages/Partners.tsx create mode 100644 frontend/src/pages/PmPlans.tsx create mode 100644 frontend/src/pages/PurchaseOrders.tsx create mode 100644 frontend/src/pages/PurchaseRequests.tsx create mode 100644 frontend/src/pages/Receivings.tsx create mode 100644 frontend/src/pages/Reliability.tsx create mode 100644 frontend/src/pages/Stocktakes.tsx create mode 100644 frontend/src/pages/SystemSettings.tsx create mode 100644 frontend/src/pages/UserManagement.tsx create mode 100644 frontend/src/pages/WorkOrders.tsx create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dc1cea2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +target/ +node_modules/ +dist/ +build/ +.gradle/ +*.log +__pycache__/ diff --git a/backend/pom.xml b/backend/pom.xml new file mode 100644 index 0000000..894170d --- /dev/null +++ b/backend/pom.xml @@ -0,0 +1,87 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.5.11 + + + com.zioinfo + guardia-mro + 1.0.0 + GUARDiA MRO + AI 기반 설비보전(EAM/CMMS) + MRO 자재 플랫폼 — Claude/Ollama AI(AiTextRouter) + ITSM/ERP/MES 연계 + + + 17 + 0.12.6 + 2.6.0 + 3.0.3 + 42.7.7 + 1.1.3 + + + + + org.springframework.bootspring-boot-starter-web + org.springframework.bootspring-boot-starter-security + org.springframework.bootspring-boot-starter-validation + org.springframework.bootspring-boot-starter-actuator + org.springframework.bootspring-boot-starter-aop + + + + org.mybatis.spring.boot + mybatis-spring-boot-starter + ${mybatis.version} + + + + org.postgresqlpostgresql${postgresql.version} + + + + org.duckdbduckdb_jdbc${duckdb.version}runtime + + + io.jsonwebtokenjjwt-api${jjwt.version} + io.jsonwebtokenjjwt-impl${jjwt.version}runtime + io.jsonwebtokenjjwt-jackson${jjwt.version}runtime + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + ${springdoc.version} + + + + org.projectlomboklomboktrue + + + org.springframework.bootspring-boot-starter-webflux + + + org.springframework.bootspring-boot-starter-testtest + org.springframework.securityspring-security-testtest + + + + guardia-mro-${project.version} + + + org.springframework.boot + spring-boot-maven-plugin + + + org.projectlomboklombok + + + + + + diff --git a/backend/src/main/java/com/zioinfo/mro/MroApplication.java b/backend/src/main/java/com/zioinfo/mro/MroApplication.java new file mode 100644 index 0000000..a2c0d64 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/MroApplication.java @@ -0,0 +1,27 @@ +package com.zioinfo.mro; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; + +/** + * GUARDiA MRO — AI 기반 설비보전(EAM/CMMS) + MRO 자재 플랫폼. + * + *

설비보전(EAM/CMMS): 설비마스터(위치/계층)·예방보전(PM)계획·작업지시(WO)·고장/정비이력· + * 계측기 교정·다운타임·신뢰성지표(MTBF/MTTR/가동률). + *

MRO 자재: 자재/공구/부품 마스터·자재BOM·재고(로케이션·안전재고)·입출고·재고실사· + * 구매요청(PR)→발주(PO)→입고·거래처·MRO 비용/예산. + * + *

보안 불변 규칙: 외부 AI API 금지(Ollama localhost + Claude(api.anthropic.com) 예외만), + * 거래처 PII는 AES-256-GCM 암호화·마스킹, API 응답에 자격증명·SSH·내부IP 제외, + * 스택트레이스 미노출(에러 코드만). + */ +@SpringBootApplication +@EnableScheduling +@EnableAsync +public class MroApplication { + public static void main(String[] args) { + SpringApplication.run(MroApplication.class, args); + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/admin/AdminController.java b/backend/src/main/java/com/zioinfo/mro/admin/AdminController.java new file mode 100644 index 0000000..b7f5798 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/admin/AdminController.java @@ -0,0 +1,96 @@ +package com.zioinfo.mro.admin; + +import com.zioinfo.mro.admin.dto.AuditLog; +import com.zioinfo.mro.admin.dto.MroSetting; +import com.zioinfo.mro.admin.dto.UserDto; +import com.zioinfo.mro.common.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * GUARDiA MRO 관리자 API. + * + *

RBAC(SecurityConfig requestMatchers 로 통제): + *

+ */ +@RestController +@RequestMapping("/api/admin") +@RequiredArgsConstructor +public class AdminController { + + private final AdminUserService userService; + private final AuditService auditService; + private final SettingService settingService; + + // ===================== 1. 사용자 관리 (SUPERADMIN) ===================== + + @GetMapping("/users") + public ApiResponse> listUsers() { + return ApiResponse.ok(userService.list()); + } + + @PostMapping("/users") + public ApiResponse createUser(@RequestBody CreateUserRequest req) { + return ApiResponse.ok(userService.create(req.username(), req.password(), req.displayName(), req.role())); + } + + @PutMapping("/users/{id}/role") + public ApiResponse updateRole(@PathVariable Long id, @RequestBody RoleRequest req) { + return ApiResponse.ok(userService.updateRole(id, req.role())); + } + + @PutMapping("/users/{id}/active") + public ApiResponse updateActive(@PathVariable Long id, @RequestBody ActiveRequest req) { + return ApiResponse.ok(userService.updateActive(id, req.active())); + } + + @PutMapping("/users/{id}/password") + public ApiResponse resetPassword(@PathVariable Long id, @RequestBody PasswordRequest req) { + return ApiResponse.ok(userService.resetPassword(id, req.password())); + } + + @DeleteMapping("/users/{id}") + public ApiResponse deleteUser(@PathVariable Long id, Authentication auth) { + String currentUsername = auth != null ? auth.getName() : null; + userService.delete(id, currentUsername); + return ApiResponse.ok(null); + } + + // ===================== 2. 감사 로그 (SUPERADMIN/MANAGER) ===================== + + @GetMapping("/audit") + public ApiResponse> audit( + @RequestParam(value = "action", required = false) String action, + @RequestParam(value = "actor", required = false) String actor, + @RequestParam(value = "limit", defaultValue = "100") int limit) { + return ApiResponse.ok(auditService.find(action, actor, limit)); + } + + // ===================== 3. 시스템 설정 ===================== + + @GetMapping("/settings") + public ApiResponse> settings() { + return ApiResponse.ok(settingService.list()); + } + + @PutMapping("/settings/{key}") + public ApiResponse updateSetting(@PathVariable String key, + @RequestBody SettingRequest req) { + return ApiResponse.ok(settingService.update(key, req.value())); + } + + // ===================== 요청 DTO ===================== + + record CreateUserRequest(String username, String password, String displayName, String role) {} + record RoleRequest(String role) {} + record ActiveRequest(boolean active) {} + record PasswordRequest(String password) {} + record SettingRequest(String value) {} +} diff --git a/backend/src/main/java/com/zioinfo/mro/admin/AdminUserService.java b/backend/src/main/java/com/zioinfo/mro/admin/AdminUserService.java new file mode 100644 index 0000000..d3225a6 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/admin/AdminUserService.java @@ -0,0 +1,118 @@ +package com.zioinfo.mro.admin; + +import com.zioinfo.mro.admin.dto.UserDto; +import com.zioinfo.mro.admin.mapper.AdminUserMapper; +import com.zioinfo.mro.auth.MroUser; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Set; + +/** + * 관리자 사용자 관리 서비스 (SUPERADMIN 전용). + * + *

RBAC 역할: SUPERADMIN / MANAGER / WORKER / VIEWER. + * 보안: 응답은 항상 {@link UserDto} 로 변환하여 password_hash 노출 차단. + */ +@Service +@RequiredArgsConstructor +public class AdminUserService { + + private static final Set VALID_ROLES = Set.of("SUPERADMIN", "MANAGER", "WORKER", "VIEWER"); + + private final AdminUserMapper mapper; + private final PasswordEncoder passwordEncoder; + private final AuditService auditService; + + public List list() { + return mapper.findAll().stream().map(UserDto::from).toList(); + } + + public UserDto create(String username, String password, String displayName, String role) { + if (username == null || username.isBlank()) { + throw new IllegalArgumentException("ERR-USR-400: username 필수"); + } + if (password == null || password.isBlank()) { + throw new IllegalArgumentException("ERR-USR-400: password 필수"); + } + String resolvedRole = normalizeRole(role); + if (mapper.countByUsername(username) > 0) { + throw new RuntimeException("ERR-USR-409: 이미 존재하는 username"); + } + MroUser user = new MroUser(); + user.setUsername(username); + user.setPasswordHash(passwordEncoder.encode(password)); + user.setDisplayName(displayName == null || displayName.isBlank() ? username : displayName); + user.setRole(resolvedRole); + user.setActive(true); + mapper.insert(user); + auditService.log("USER_CREATE", username, "role=" + resolvedRole); + return UserDto.from(user); + } + + public UserDto updateRole(Long id, String role) { + MroUser user = require(id); + String newRole = normalizeRole(role); + if ("SUPERADMIN".equals(user.getRole()) && !"SUPERADMIN".equals(newRole) + && user.isActive() && mapper.countSuperAdmins() <= 1) { + throw new RuntimeException("ERR-USR-423: 마지막 SUPERADMIN 계정의 역할은 변경할 수 없습니다"); + } + mapper.updateRole(id, newRole); + auditService.log("USER_ROLE_CHANGE", user.getUsername(), user.getRole() + " -> " + newRole); + return UserDto.from(require(id)); + } + + public UserDto updateActive(Long id, boolean active) { + MroUser user = require(id); + if (!active && "SUPERADMIN".equals(user.getRole()) && user.isActive() + && mapper.countSuperAdmins() <= 1) { + throw new RuntimeException("ERR-USR-423: 마지막 SUPERADMIN 계정은 비활성화할 수 없습니다"); + } + mapper.updateActive(id, active); + auditService.log("USER_ACTIVE_TOGGLE", user.getUsername(), "active=" + active); + return UserDto.from(require(id)); + } + + public UserDto resetPassword(Long id, String newPassword) { + if (newPassword == null || newPassword.isBlank()) { + throw new IllegalArgumentException("ERR-USR-400: password 필수"); + } + MroUser user = require(id); + mapper.updatePassword(id, passwordEncoder.encode(newPassword)); + auditService.log("USER_PASSWORD_RESET", user.getUsername(), "password reset"); + return UserDto.from(user); + } + + public void delete(Long id, String currentUsername) { + MroUser user = require(id); + if (user.getUsername().equals(currentUsername)) { + throw new RuntimeException("ERR-USR-423: 자기 자신은 삭제할 수 없습니다"); + } + if ("SUPERADMIN".equals(user.getRole()) && user.isActive() && mapper.countSuperAdmins() <= 1) { + throw new RuntimeException("ERR-USR-423: 마지막 SUPERADMIN 계정은 삭제할 수 없습니다"); + } + mapper.deleteById(id); + auditService.log("USER_DELETE", user.getUsername(), "role=" + user.getRole()); + } + + private MroUser require(Long id) { + MroUser user = mapper.findById(id); + if (user == null) { + throw new RuntimeException("ERR-USR-404: 사용자를 찾을 수 없습니다"); + } + return user; + } + + private String normalizeRole(String role) { + if (role == null || role.isBlank()) { + return "VIEWER"; + } + String upper = role.trim().toUpperCase(); + if (!VALID_ROLES.contains(upper)) { + throw new IllegalArgumentException("ERR-USR-400: 유효하지 않은 role (SUPERADMIN/MANAGER/WORKER/VIEWER)"); + } + return upper; + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/admin/AuditService.java b/backend/src/main/java/com/zioinfo/mro/admin/AuditService.java new file mode 100644 index 0000000..53f06aa --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/admin/AuditService.java @@ -0,0 +1,53 @@ +package com.zioinfo.mro.admin; + +import com.zioinfo.mro.admin.dto.AuditLog; +import com.zioinfo.mro.admin.mapper.AuditLogMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * 감사 로그 서비스 — 작업지시 전이·PM·입출고·검교정·관리자 작업 등 주요 변경을 mro_audit_log 에 기록. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AuditService { + + private final AuditLogMapper mapper; + + public void log(String action, String target, String detail) { + log(currentActor(), action, target, detail); + } + + public void log(String actor, String action, String target, String detail) { + try { + AuditLog entry = new AuditLog(); + entry.setActor(actor); + entry.setAction(action); + entry.setTarget(target); + entry.setDetail(detail); + mapper.insert(entry); + } catch (Exception e) { + log.warn("감사 로그 기록 실패 [{}]: {}", action, e.getMessage()); + } + } + + public List find(String action, String actor, int limit) { + int safeLimit = (limit <= 0 || limit > 1000) ? 100 : limit; + return mapper.find(action, actor, safeLimit); + } + + /** SecurityContext 의 JWT subject(username)를 추출. 없으면 system. */ + public static String currentActor() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth != null && auth.getName() != null && !auth.getName().isBlank()) { + return auth.getName(); + } + return "system"; + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/admin/SettingService.java b/backend/src/main/java/com/zioinfo/mro/admin/SettingService.java new file mode 100644 index 0000000..abd9177 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/admin/SettingService.java @@ -0,0 +1,35 @@ +package com.zioinfo.mro.admin; + +import com.zioinfo.mro.admin.dto.MroSetting; +import com.zioinfo.mro.admin.mapper.SettingMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class SettingService { + + private final SettingMapper mapper; + private final AuditService auditService; + + public List list() { + return mapper.findAll(); + } + + public MroSetting update(String key, String value) { + if (key == null || key.isBlank()) { + throw new IllegalArgumentException("ERR-SET-400: key 필수"); + } + mapper.upsert(key, value); + auditService.log("SETTING_UPDATE", key, "value=" + value); + return mapper.findByKey(key); + } + + /** 설정값 조회(없으면 기본값). 다른 서비스의 임계값 참조용. */ + public String get(String key, String defaultValue) { + MroSetting s = mapper.findByKey(key); + return (s == null || s.getValue() == null) ? defaultValue : s.getValue(); + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/admin/dto/AuditLog.java b/backend/src/main/java/com/zioinfo/mro/admin/dto/AuditLog.java new file mode 100644 index 0000000..6ca0109 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/admin/dto/AuditLog.java @@ -0,0 +1,16 @@ +package com.zioinfo.mro.admin.dto; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** 감사 로그 (mro_audit_log 테이블 매핑). */ +@Data +public class AuditLog { + private Long id; + private String actor; // 작업 수행자 (JWT subject = username) + private String action; // WORKORDER_RELEASE, PM_PLAN_CREATE, PO_RECEIVE 등 + private String target; // 대상 식별자 + private String detail; // 부가 설명 + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/com/zioinfo/mro/admin/dto/MroSetting.java b/backend/src/main/java/com/zioinfo/mro/admin/dto/MroSetting.java new file mode 100644 index 0000000..aed3f9e --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/admin/dto/MroSetting.java @@ -0,0 +1,13 @@ +package com.zioinfo.mro.admin.dto; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** 시스템 설정 (mro_setting 테이블 매핑). */ +@Data +public class MroSetting { + private String key; + private String value; + private LocalDateTime updatedAt; +} diff --git a/backend/src/main/java/com/zioinfo/mro/admin/dto/UserDto.java b/backend/src/main/java/com/zioinfo/mro/admin/dto/UserDto.java new file mode 100644 index 0000000..1f8f637 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/admin/dto/UserDto.java @@ -0,0 +1,30 @@ +package com.zioinfo.mro.admin.dto; + +import com.zioinfo.mro.auth.MroUser; +import lombok.Data; + +import java.time.LocalDateTime; + +/** 사용자 응답 DTO — password_hash 노출 차단. */ +@Data +public class UserDto { + private Long id; + private String username; + private String displayName; + private String email; + private String role; + private boolean active; + private LocalDateTime createdAt; + + public static UserDto from(MroUser u) { + UserDto d = new UserDto(); + d.id = u.getId(); + d.username = u.getUsername(); + d.displayName = u.getDisplayName(); + d.email = u.getEmail(); + d.role = u.getRole(); + d.active = u.isActive(); + d.createdAt = u.getCreatedAt(); + return d; + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/admin/mapper/AdminUserMapper.java b/backend/src/main/java/com/zioinfo/mro/admin/mapper/AdminUserMapper.java new file mode 100644 index 0000000..27bc1e1 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/admin/mapper/AdminUserMapper.java @@ -0,0 +1,31 @@ +package com.zioinfo.mro.admin.mapper; + +import com.zioinfo.mro.auth.MroUser; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** 관리자 사용자 관리 매퍼 (mro_user). @Mapper 필수. */ +@Mapper +public interface AdminUserMapper { + + List findAll(); + + MroUser findById(@Param("id") Long id); + + int insert(MroUser user); + + int updateRole(@Param("id") Long id, @Param("role") String role); + + int updateActive(@Param("id") Long id, @Param("active") boolean active); + + int updatePassword(@Param("id") Long id, @Param("passwordHash") String passwordHash); + + int deleteById(@Param("id") Long id); + + /** 활성 SUPERADMIN 계정 수 (마지막 관리자 삭제/강등 방지용). */ + int countSuperAdmins(); + + int countByUsername(@Param("username") String username); +} diff --git a/backend/src/main/java/com/zioinfo/mro/admin/mapper/AuditLogMapper.java b/backend/src/main/java/com/zioinfo/mro/admin/mapper/AuditLogMapper.java new file mode 100644 index 0000000..31b14c2 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/admin/mapper/AuditLogMapper.java @@ -0,0 +1,17 @@ +package com.zioinfo.mro.admin.mapper; + +import com.zioinfo.mro.admin.dto.AuditLog; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface AuditLogMapper { + + int insert(AuditLog log); + + List find(@Param("action") String action, + @Param("actor") String actor, + @Param("limit") int limit); +} diff --git a/backend/src/main/java/com/zioinfo/mro/admin/mapper/SettingMapper.java b/backend/src/main/java/com/zioinfo/mro/admin/mapper/SettingMapper.java new file mode 100644 index 0000000..c734e08 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/admin/mapper/SettingMapper.java @@ -0,0 +1,17 @@ +package com.zioinfo.mro.admin.mapper; + +import com.zioinfo.mro.admin.dto.MroSetting; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface SettingMapper { + + List findAll(); + + MroSetting findByKey(@Param("key") String key); + + int upsert(@Param("key") String key, @Param("value") String value); +} diff --git a/backend/src/main/java/com/zioinfo/mro/ai/AiController.java b/backend/src/main/java/com/zioinfo/mro/ai/AiController.java new file mode 100644 index 0000000..c127952 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/ai/AiController.java @@ -0,0 +1,67 @@ +package com.zioinfo.mro.ai; + +import com.zioinfo.mro.common.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** + * GUARDiA MRO AI 도구 API — 6개 설비보전·MRO 자재 AI(AiTextRouter 경유 + Java 폴백). + * + *

RBAC: 조회 Viewer+ (GET), 분석 트리거 Worker+ (POST). + */ +@RestController +@RequestMapping("/api/mro/ai") +@RequiredArgsConstructor +public class AiController { + + private final AiService ai; + + @GetMapping("/status") + public ApiResponse> status() { + return ApiResponse.ok(Map.of("ollamaAvailable", ai.ollamaAvailable())); + } + + @PostMapping("/fault-root-cause") + public ApiResponse> faultRootCause(@RequestBody FaultRequest req) { + return ApiResponse.ok(ai.faultRootCause(req.equipmentCode(), req.symptom(), req.history())); + } + + @PostMapping("/predictive-maintenance") + public ApiResponse> pdm(@RequestBody PdmRequest req) { + return ApiResponse.ok(ai.predictiveMaintenance( + req.equipmentCode(), req.availability(), req.downtimeCount(), req.mtbfHours())); + } + + @PostMapping("/demand-forecast") + public ApiResponse> forecast(@RequestBody ForecastRequest req) { + return ApiResponse.ok(ai.demandForecast(req.series(), req.horizon())); + } + + @PostMapping("/parse-query") + public ApiResponse> parseQuery(@RequestBody QueryRequest req) { + return ApiResponse.ok(ai.parseQuery(req.query())); + } + + @PostMapping("/safety-stock") + public ApiResponse> safetyStock(@RequestBody SafetyStockRequest req) { + return ApiResponse.ok(ai.safetyStock( + req.avgDailyDemand(), req.demandStdDev(), req.leadTimeDays(), req.serviceZ())); + } + + @PostMapping("/reliability-anomaly") + public ApiResponse> reliability(@RequestBody ReliabilityRequest req) { + return ApiResponse.ok(ai.reliabilityAnomaly( + req.mtbfHours(), req.mttrHours(), req.availability(), req.mtbfTarget())); + } + + // ── request DTO ── + record FaultRequest(String equipmentCode, String symptom, List> history) {} + record PdmRequest(String equipmentCode, double availability, int downtimeCount, double mtbfHours) {} + record ForecastRequest(List series, int horizon) {} + record QueryRequest(String query) {} + record SafetyStockRequest(double avgDailyDemand, double demandStdDev, double leadTimeDays, double serviceZ) {} + record ReliabilityRequest(double mtbfHours, double mttrHours, double availability, Double mtbfTarget) {} +} diff --git a/backend/src/main/java/com/zioinfo/mro/ai/AiService.java b/backend/src/main/java/com/zioinfo/mro/ai/AiService.java new file mode 100644 index 0000000..b9993f6 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/ai/AiService.java @@ -0,0 +1,214 @@ +package com.zioinfo.mro.ai; + +import com.zioinfo.mro.ai.service.AiTextRouter; +import com.zioinfo.mro.common.ai.TextAiClient.GenResult; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * GUARDiA MRO AI 서비스 — 6개 설비보전·MRO 자재 AI 기능. AiTextRouter(Claude→Ollama 폴백) 경유 + Java 폴백. + * + *

보안 불변 규칙: AI 호출은 AiTextRouter 경유(외부 API 는 anthropic 예외 외 금지). + * 라우터 degraded 시 모든 메서드가 규칙기반/통계 폴백으로 동작한다(예외 없음). + *

    + *
  1. 고장 원인 분석 — 증상/설비/이력 상관 → 추정 원인·조치
  2. + *
  3. 예지보전 — 가동률/다운타임/MTBF 패턴 이상 → 정비 권고(위험도)
  4. + *
  5. 자재 수요 예측 — 소비 추세(가중이동평균+추세) 폴백
  6. + *
  7. 자연어 조회 — Text→검색필터 추출
  8. + *
  9. 안전재고·재발주점 추천 — 수요 변동/리드타임 기반
  10. + *
  11. 신뢰성 지표 이상 감지 — MTBF/MTTR/가동률 임계 판정
  12. + *
+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AiService { + + private final OllamaClient ollama; + /** provider 라우팅(Claude→Ollama 폴백) + 로컬 학습 로그 경유. */ + private final AiTextRouter aiTextRouter; + + public boolean ollamaAvailable() { + return ollama.available(); + } + + /** 라우터 경유 텍스트 생성 — degraded 면 null(호출부가 Java 폴백 수행). */ + private String routeGenerate(String prompt) { + GenResult r = aiTextRouter.generate(prompt); + return (r != null && !r.degraded()) ? r.text() : null; + } + + // 1. 고장 원인 분석 (증상/설비/정비이력 상관) + public Map faultRootCause(String equipmentCode, String symptom, + List> history) { + Map result = new LinkedHashMap<>(); + String ctx = history == null ? "" : truncate(history.toString(), 1200); + String prompt = String.format( + "당신은 설비보전(CMMS) 신뢰성 엔지니어입니다. 설비 '%s' 의 고장 증상 '%s' 와 최근 정비이력: %s. " + + "가장 가능성 높은 추정 원인 1가지와 권고 정비 조치를 'CAUSE: ...\\nACTION: ...' 형식 한국어로 출력.", + equipmentCode, symptom, ctx); + String out = routeGenerate(prompt); + if (out != null && !out.isBlank()) { + result.put("cause", firstNonBlank(extractLine(out, "CAUSE:"), out)); + result.put("action", extractLine(out, "ACTION:")); + result.put("source", "ai"); + return result; + } + // Java 폴백: 증상 키워드 규칙 + String s = symptom == null ? "" : symptom.toLowerCase(); + String cause; String action; + if (s.contains("진동") || s.contains("vib")) { + cause = "베어링 마모/정렬 불량(진동 이상)"; action = "베어링 점검·교체 + 축 정렬 재조정"; + } else if (s.contains("과열") || s.contains("온도") || s.contains("heat")) { + cause = "윤활 부족 또는 냉각계통 이상(과열)"; action = "윤활유 보충·교환 + 냉각팬/필터 점검"; + } else if (s.contains("소음") || s.contains("noise")) { + cause = "기어/체인 마모 또는 이물 혼입(이상 소음)"; action = "구동부 분해 점검 + 소모품 교체"; + } else if (s.contains("누유") || s.contains("leak")) { + cause = "씰/개스킷 열화(누유)"; action = "씰·개스킷 교체 + 체결 토크 재점검"; + } else { + cause = "원인 미상(상관 데이터 부족 — 추가 점검 필요)"; action = "정비이력·다운타임 추이 확인 + 계측 점검"; + } + result.put("cause", cause); + result.put("action", action); + result.put("source", "fallback"); + return result; + } + + // 2. 예지보전 (가동률/다운타임/MTBF 이상 → 정비 권고) + public Map predictiveMaintenance(String equipmentCode, double availability, + int downtimeCount, double mtbfHours) { + Map result = new LinkedHashMap<>(); + double risk = (availability < 0.7 ? 0.4 : availability < 0.85 ? 0.2 : 0.0) + + Math.min(0.3, downtimeCount * 0.05) + + (mtbfHours > 0 && mtbfHours < 100 ? 0.3 : mtbfHours < 300 ? 0.15 : 0.0); + risk = Math.min(1.0, risk); + String level = risk >= 0.6 ? "HIGH" : risk >= 0.3 ? "MEDIUM" : "LOW"; + String recommend = switch (level) { + case "HIGH" -> "즉시 예방 정비 권고 — 가동률 저하 + 잦은 다운타임. ITSM SR 자동생성 대상."; + case "MEDIUM" -> "정비 주기 단축 검토 권고."; + default -> "정상 — 정기 점검(PM 계획) 유지."; + }; + result.put("equipmentCode", equipmentCode); + result.put("risk", round2(risk)); + result.put("level", level); + result.put("recommendation", recommend); + result.put("source", "fallback"); + return result; + } + + // 3. 자재 수요 예측 (가중이동평균 + 선형추세 폴백) + public Map demandForecast(List series, int horizon) { + Map result = new LinkedHashMap<>(); + int h = horizon <= 0 ? 7 : Math.min(horizon, 90); + List preds = new ArrayList<>(); + if (series == null || series.isEmpty()) { + for (int i = 0; i < h; i++) preds.add(0.0); + result.put("method", "empty"); + } else { + int n = series.size(); + double avg = series.stream().mapToDouble(Double::doubleValue).average().orElse(0); + double slope = n >= 2 ? (series.get(n - 1) - series.get(0)) / (n - 1) : 0; + double last = series.get(n - 1); + for (int i = 1; i <= h; i++) { + double p = Math.max(0, (last + slope * i) * 0.6 + avg * 0.4); + preds.add(round2(p)); + } + result.put("method", "weighted-moving-average+trend"); + } + result.put("horizon", h); + result.put("predictions", preds); + result.put("source", "fallback"); + return result; + } + + // 4. 자연어 조회 → 검색 필터 추출 + public Map parseQuery(String naturalQuery) { + Map filter = new LinkedHashMap<>(); + if (naturalQuery == null || naturalQuery.isBlank()) return filter; + String prompt = "다음 질의에서 검색 필터를 'STATUS:..\\nEQUIP:..\\nMATERIAL:..\\nDATE:..' 형식으로만 추출:\\n" + naturalQuery; + String out = routeGenerate(prompt); + if (out != null && !out.isBlank()) { + putIf(filter, "status", extractLine(out, "STATUS:")); + putIf(filter, "equipment", extractLine(out, "EQUIP:")); + putIf(filter, "material", extractLine(out, "MATERIAL:")); + putIf(filter, "date", extractLine(out, "DATE:")); + if (!filter.isEmpty()) { filter.put("source", "ai"); return filter; } + } + // Java 폴백: 키워드 매칭 + String q = naturalQuery.toLowerCase(); + if (q.contains("진행") || q.contains("배정") || q.contains("assigned")) filter.put("status", "ASSIGNED"); + else if (q.contains("완료") || q.contains("done")) filter.put("status", "DONE"); + else if (q.contains("대기") || q.contains("접수") || q.contains("created")) filter.put("status", "CREATED"); + if (q.contains("부족") || q.contains("안전재고")) filter.put("belowSafety", true); + if (q.contains("고장") || q.contains("정지") || q.contains("down")) filter.put("equipment", "DOWN"); + filter.put("source", "fallback"); + return filter; + } + + // 5. 안전재고·재발주점 추천 (수요 변동/리드타임 기반) + public Map safetyStock(double avgDailyDemand, double demandStdDev, + double leadTimeDays, double serviceZ) { + Map result = new LinkedHashMap<>(); + double z = serviceZ <= 0 ? 1.65 : serviceZ; // 95% 서비스 수준 기본 + double safety = z * demandStdDev * Math.sqrt(Math.max(0, leadTimeDays)); + double reorderPoint = avgDailyDemand * leadTimeDays + safety; + result.put("safetyStock", round2(Math.max(0, safety))); + result.put("reorderPoint", round2(Math.max(0, reorderPoint))); + result.put("serviceZ", z); + result.put("source", "fallback"); + return result; + } + + // 6. 신뢰성 지표 이상 감지 (MTBF/MTTR/가동률 임계 판정) + public Map reliabilityAnomaly(double mtbfHours, double mttrHours, + double availability, Double mtbfTarget) { + Map result = new LinkedHashMap<>(); + List flags = new ArrayList<>(); + if (availability < 0.85) flags.add("가동률 저하(" + round2(availability * 100) + "% < 85%)"); + if (mttrHours > 8) flags.add("MTTR 과다(" + round2(mttrHours) + "h > 8h)"); + double target = mtbfTarget != null && mtbfTarget > 0 ? mtbfTarget : 300; + if (mtbfHours > 0 && mtbfHours < target) flags.add("MTBF 목표 미달(" + round2(mtbfHours) + "h < " + round2(target) + "h)"); + result.put("anomaly", !flags.isEmpty()); + result.put("flags", flags); + result.put("availability", round2(availability)); + result.put("mtbfHours", round2(mtbfHours)); + result.put("mttrHours", round2(mttrHours)); + result.put("source", "fallback"); + return result; + } + + // ── helpers ── + private void putIf(Map m, String k, String v) { + if (v != null && !v.isBlank()) m.put(k, v.trim()); + } + + private String extractLine(String out, String tag) { + if (out == null) return ""; + for (String line : out.split("[\\n\\r]+")) { + String l = line.trim(); + if (l.toUpperCase().startsWith(tag.toUpperCase())) { + return l.substring(tag.length()).trim(); + } + } + return ""; + } + + private String firstNonBlank(String a, String b) { + return (a != null && !a.isBlank()) ? a : b; + } + + private String truncate(String s, int max) { + if (s == null) return ""; + return s.length() <= max ? s : s.substring(0, max); + } + + private double round2(double d) { + return Math.round(d * 100.0) / 100.0; + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/ai/OllamaClient.java b/backend/src/main/java/com/zioinfo/mro/ai/OllamaClient.java new file mode 100644 index 0000000..4e73509 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/ai/OllamaClient.java @@ -0,0 +1,100 @@ +package com.zioinfo.mro.ai; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; + +import java.time.Duration; +import java.util.List; +import java.util.Map; + +/** + * Ollama 온프레미스 LLM 클라이언트. + * + *

보안 불변 규칙: localhost Ollama만 호출. 외부 AI API 절대 금지. + * 오프라인/장애 시 절대 예외를 던지지 않고 빈 문자열을 반환한다(서비스 계층이 Java 폴백 수행). + */ +@Slf4j +@Component +public class OllamaClient { + + private final WebClient.Builder builder; + private final String ollamaUrl; + private final String textModel; + private final String visionModel; + + public OllamaClient(WebClient.Builder builder, + @Value("${guardia.ollama-url:http://localhost:11434}") String ollamaUrl, + @Value("${guardia.ollama-text-model:llama3.2:1b}") String textModel, + @Value("${guardia.ollama-vision-model:moondream}") String visionModel) { + this.builder = builder; + this.ollamaUrl = ollamaUrl; + this.textModel = textModel; + this.visionModel = visionModel; + } + + /** 프롬프트로 텍스트 생성(기본 텍스트 모델). 실패 시 빈 문자열 반환(예외 없음). */ + public String generate(String prompt) { + return generate(prompt, textModel); + } + + /** 지정 모델로 텍스트 생성(AiTextRouter 의 provider 별 소형 모델 선택 경유). 실패 시 빈 문자열. */ + @SuppressWarnings("unchecked") + public String generate(String prompt, String model) { + String useModel = (model == null || model.isBlank()) ? textModel : model.trim(); + try { + Map body = Map.of("model", useModel, "prompt", prompt, "stream", false); + Map res = builder.baseUrl(ollamaUrl).build() + .post().uri("/api/generate") + .bodyValue(body) + .retrieve() + .bodyToMono(Map.class) + .timeout(Duration.ofSeconds(120)) + .map(m -> (Map) m) + .block(); + if (res == null) return ""; + Object r = res.get("response"); + return r == null ? "" : String.valueOf(r).trim(); + } catch (Exception e) { + log.warn("Ollama 일시 불가 — Java 폴백 사용: {}", e.getMessage()); + return ""; + } + } + + /** 비전 모델로 이미지(base64) 분석. 실패 시 빈 문자열. */ + @SuppressWarnings("unchecked") + public String vision(String prompt, String imageBase64) { + try { + Map body = Map.of( + "model", visionModel, + "prompt", prompt, + "images", List.of(imageBase64), + "stream", false); + Map res = builder.baseUrl(ollamaUrl).build() + .post().uri("/api/generate") + .bodyValue(body) + .retrieve() + .bodyToMono(Map.class) + .timeout(Duration.ofSeconds(120)) + .map(m -> (Map) m) + .block(); + if (res == null) return ""; + Object r = res.get("response"); + return r == null ? "" : String.valueOf(r).trim(); + } catch (Exception e) { + log.warn("Ollama 비전 일시 불가 — Java 폴백 사용: {}", e.getMessage()); + return ""; + } + } + + public boolean available() { + try { + builder.baseUrl(ollamaUrl).build().get().uri("/api/tags") + .retrieve().bodyToMono(String.class).timeout(Duration.ofSeconds(3)).block(); + return true; + } catch (Exception e) { + return false; + } + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/ai/controller/AiConfigController.java b/backend/src/main/java/com/zioinfo/mro/ai/controller/AiConfigController.java new file mode 100644 index 0000000..f8981a7 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/ai/controller/AiConfigController.java @@ -0,0 +1,50 @@ +package com.zioinfo.mro.ai.controller; + +import com.zioinfo.mro.admin.AuditService; +import com.zioinfo.mro.ai.dto.AiConfigDto; +import com.zioinfo.mro.ai.dto.AiConfigUpdateRequest; +import com.zioinfo.mro.ai.service.AiConfigService; +import com.zioinfo.mro.common.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * AI 플랫폼(LLM provider) 설정 관리(ADMIN). [GUARDiA-MRO] + * SecurityConfig {@code /api/admin/** hasRole(SUPERADMIN)} 게이트로 보호된다. + * + *

    + *
  • GET /api/admin/ai-config 현재 효과 설정(키 값 제외, keySet 불리언만)
  • + *
  • PUT /api/admin/ai-config provider/모델 갱신(화이트리스트 검증)
  • + *
  • POST /api/admin/ai-config/test 저장 설정 기준 선택 provider 연결 테스트(요약 결과만)
  • + *
+ */ +@RestController +@RequestMapping("/api/admin/ai-config") +@RequiredArgsConstructor +public class AiConfigController { + + private final AiConfigService service; + + @GetMapping + public ApiResponse get() { + return ApiResponse.ok(service.getConfig()); + } + + @PutMapping + public ApiResponse update(@RequestBody AiConfigUpdateRequest req) { + return ApiResponse.ok(service.update(req, AuditService.currentActor())); + } + + @PostMapping("/test") + public ApiResponse test() { + AiConfigService.TestResult result = service.test(); + return result.ok() + ? ApiResponse.ok(result) + : new ApiResponse<>(false, result.message(), result); + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/ai/dto/AiConfigDto.java b/backend/src/main/java/com/zioinfo/mro/ai/dto/AiConfigDto.java new file mode 100644 index 0000000..b52e001 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/ai/dto/AiConfigDto.java @@ -0,0 +1,18 @@ +package com.zioinfo.mro.ai.dto; + +/** + * AI 설정 조회 DTO — API 키 값 미반환(claudeKeySet 으로 설정 여부만). [GUARDiA-MRO] + * + * @param provider 효과 provider(ollama/claude/qwen3/deepseek/glm) + * @param ollamaTextModel 효과 Ollama 텍스트 모델(provider 별 해석 결과) + * @param claudeModel 효과 Claude 모델 ID + * @param claudeKeySet 서버 환경변수 ANTHROPIC_API_KEY 존재 여부(값/길이/마스킹 일절 미포함) + * @param aiEnabled 전역 AI 사용 여부(off 시 규칙 기반 degraded) + */ +public record AiConfigDto( + String provider, + String ollamaTextModel, + String claudeModel, + boolean claudeKeySet, + boolean aiEnabled) { +} diff --git a/backend/src/main/java/com/zioinfo/mro/ai/dto/AiConfigUpdateRequest.java b/backend/src/main/java/com/zioinfo/mro/ai/dto/AiConfigUpdateRequest.java new file mode 100644 index 0000000..71d3a81 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/ai/dto/AiConfigUpdateRequest.java @@ -0,0 +1,14 @@ +package com.zioinfo.mro.ai.dto; + +/** + * AI 설정 저장 요청 — 화이트리스트 검증. [GUARDiA-MRO] + * + * @param provider ollama/claude/qwen3/deepseek/glm (필수) + * @param claudeModel Claude 모델 ID(선택, 미제공 시 기존 유지) + * @param ollamaTextModel Ollama 텍스트 모델(선택, 미제공 시 기존 유지) + */ +public record AiConfigUpdateRequest( + String provider, + String claudeModel, + String ollamaTextModel) { +} diff --git a/backend/src/main/java/com/zioinfo/mro/ai/learning/AiFeedbackController.java b/backend/src/main/java/com/zioinfo/mro/ai/learning/AiFeedbackController.java new file mode 100644 index 0000000..3a6d331 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/ai/learning/AiFeedbackController.java @@ -0,0 +1,91 @@ +package com.zioinfo.mro.ai.learning; + +import com.zioinfo.mro.admin.AuditService; +import com.zioinfo.mro.common.ApiResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.reactive.function.client.WebClient; + +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * AI 답변 피드백 수집(👍/👎 + 교정). [GUARDiA-MRO] + * + *

{@code POST /api/ai/feedback} — ① 로컬 DuckDB({@link AiLearningStore}) 적재 + * + ② 중앙 guardia-rag {@code /feedback} 전달. 둘 중 하나가 실패해도 200 을 유지한다. + * 보안 불변: 중앙 rag 는 온프레미스 루프백 전용. 응답에 키/스택트레이스 미포함. PII 는 적재 전 마스킹. + */ +@Slf4j +@RestController +@RequestMapping("/api/ai/feedback") +@RequiredArgsConstructor +public class AiFeedbackController { + + private final AiLearningStore store; + private final WebClient.Builder webClientBuilder; + + @Value("${guardia.rag.base-url:http://127.0.0.1:8020}") + private String ragBaseUrl; + + @Value("${guardia.rag.enabled:true}") + private boolean ragEnabled; + + @PostMapping + public ApiResponse> feedback(@RequestBody FeedbackRequest req) { + String actor = AuditService.currentActor(); + + store.saveFeedback(req.feature(), req.question(), req.answer(), + req.verdict(), req.correction(), actor); + + boolean forwarded = forwardToCentralRag(req); + + Map out = new LinkedHashMap<>(); + out.put("stored", store.isEnabled()); + out.put("forwarded", forwarded); + return ApiResponse.ok(out); + } + + private boolean forwardToCentralRag(FeedbackRequest req) { + if (!ragEnabled) { + return false; + } + try { + Map body = new LinkedHashMap<>(); + body.put("solution", "mro"); + body.put("feature", req.feature()); + body.put("question", req.question()); + body.put("answer", req.answer()); + body.put("verdict", req.verdict()); + body.put("correction", req.correction()); + webClientBuilder.baseUrl(ragBaseUrl).build() + .post().uri("/feedback") + .bodyValue(body) + .retrieve() + .toBodilessEntity() + .timeout(Duration.ofSeconds(5)) + .subscribe( + ok -> {}, + err -> log.debug("중앙 rag /feedback 전달 실패(무시): {}", err.getClass().getSimpleName())); + return true; + } catch (Exception e) { + log.debug("중앙 rag /feedback 전달 예외(무시): {}", e.getClass().getSimpleName()); + return false; + } + } + + /** 피드백 요청 — verdict: up/down, correction: 교정문(선택). */ + public record FeedbackRequest( + String feature, + String question, + String answer, + String verdict, + String correction) { + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/ai/learning/AiLearningStore.java b/backend/src/main/java/com/zioinfo/mro/ai/learning/AiLearningStore.java new file mode 100644 index 0000000..a2f1c90 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/ai/learning/AiLearningStore.java @@ -0,0 +1,163 @@ +package com.zioinfo.mro.ai.learning; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.io.File; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.Statement; +import java.sql.Timestamp; +import java.time.Instant; +import java.util.regex.Pattern; + +/** + * MRO AI 로컬 학습 저장소 — 임베디드 DuckDB(단일 파일). [GUARDiA-MRO] + * + *

AI 피드백(👍/👎+교정)과 추론 로그를 솔루션 로컬 DuckDB 파일에 적재해 오프라인 AI 분석/학습 데이터셋으로 + * 사용한다. 스키마(멱등): ai_feedback / ai_infer_log. + * + *

안전성: 드라이버는 리플렉션 로드(컴파일 타임 의존 0). 파일/디렉터리 미가용이면 + * {@code enabled=false} 로 조용히 비활성 — 절대 예외를 던지지 않아 AI 기능은 그대로 동작한다. + * 보안: 저장 전 PII(주민번호·카드·전화·이메일)·자격증명 마스킹. 솔루션별 파일 격리. + */ +@Slf4j +@Component +public class AiLearningStore { + + private static final String SOLUTION = "mro"; + + private static final Pattern RRN = Pattern.compile("\\d{6}[- ]?\\d{7}"); + private static final Pattern CARD = Pattern.compile("\\b(?:\\d[ -]?){13,16}\\b"); + private static final Pattern PHONE = Pattern.compile("01[016789][- ]?\\d{3,4}[- ]?\\d{4}"); + private static final Pattern EMAIL = Pattern.compile("[\\w.+-]+@[\\w.-]+\\.[A-Za-z]{2,}"); + private static final int MAX_TEXT = 4000; + + @Value("${mro.ai.duckdb-path:/opt/guardia-mro/data/mro_learning.duckdb}") + private String dbPath; + + private volatile boolean enabled = false; + private Connection conn; + private final Object lock = new Object(); + + @PostConstruct + void init() { + try { + Class.forName("org.duckdb.DuckDBDriver"); + File f = new File(dbPath); + File dir = f.getParentFile(); + if (dir != null && !dir.exists() && !dir.mkdirs()) { + log.warn("AiLearningStore(DuckDB) 비활성 — 디렉터리 생성 불가(로컬 AI 분석 저장 생략, AI 기능은 정상)"); + return; + } + conn = DriverManager.getConnection("jdbc:duckdb:" + dbPath); + try (Statement st = conn.createStatement()) { + st.execute("CREATE SEQUENCE IF NOT EXISTS ai_feedback_seq START 1"); + st.execute("CREATE TABLE IF NOT EXISTS ai_feedback (" + + "id BIGINT, ts TIMESTAMP, solution VARCHAR, feature VARCHAR, question VARCHAR, " + + "answer VARCHAR, verdict VARCHAR, correction VARCHAR, user_masked VARCHAR)"); + st.execute("CREATE SEQUENCE IF NOT EXISTS ai_infer_log_seq START 1"); + st.execute("CREATE TABLE IF NOT EXISTS ai_infer_log (" + + "id BIGINT, ts TIMESTAMP, provider VARCHAR, model VARCHAR, latency_ms BIGINT, degraded BOOLEAN)"); + } + enabled = true; + log.info("AiLearningStore(DuckDB) 준비 완료 (solution=mro)"); + } catch (Throwable t) { + enabled = false; // duckdb 미로딩/파일 미가용 — 조용히 비활성(AI 기능 무영향) + log.warn("AiLearningStore(DuckDB) 비활성 ({}) — 로컬 AI 학습 저장 생략, AI 기능은 정상 동작", + t.getClass().getSimpleName()); + } + } + + public boolean isEnabled() { + return enabled; + } + + /** 추론 1건 로그(provider/model/지연/폴백여부). 실패해도 예외 없음. */ + public void logInfer(String provider, String model, long latencyMs, boolean degraded) { + if (!enabled) { + return; + } + synchronized (lock) { + try (PreparedStatement ps = conn.prepareStatement( + "INSERT INTO ai_infer_log VALUES (nextval('ai_infer_log_seq'), ?, ?, ?, ?, ?)")) { + ps.setTimestamp(1, Timestamp.from(Instant.now())); + ps.setString(2, provider); + ps.setString(3, model); + ps.setLong(4, Math.max(0, latencyMs)); + ps.setBoolean(5, degraded); + ps.executeUpdate(); + } catch (Throwable t) { + log.debug("ai_infer_log insert skip: {}", t.getClass().getSimpleName()); + } + } + } + + /** 피드백 1건 저장(로컬). PII 마스킹 후 적재. 실패해도 예외 없음. */ + public void saveFeedback(String feature, String question, String answer, + String verdict, String correction, String actor) { + if (!enabled) { + return; + } + synchronized (lock) { + try (PreparedStatement ps = conn.prepareStatement( + "INSERT INTO ai_feedback VALUES (nextval('ai_feedback_seq'), ?, ?, ?, ?, ?, ?, ?, ?)")) { + ps.setTimestamp(1, Timestamp.from(Instant.now())); + ps.setString(2, SOLUTION); + ps.setString(3, clip(feature)); + ps.setString(4, mask(question)); + ps.setString(5, mask(answer)); + ps.setString(6, clip(verdict)); + ps.setString(7, mask(correction)); + ps.setString(8, maskActor(actor)); + ps.executeUpdate(); + } catch (Throwable t) { + log.debug("ai_feedback insert skip: {}", t.getClass().getSimpleName()); + } + } + } + + @PreDestroy + void close() { + if (conn != null) { + try { + conn.close(); + } catch (Throwable ignored) { + // best-effort + } + } + } + + // ── PII/자격증명 마스킹 ── + static String mask(String s) { + if (s == null || s.isBlank()) { + return null; + } + String v = s.length() > MAX_TEXT ? s.substring(0, MAX_TEXT) : s; + v = RRN.matcher(v).replaceAll("######-#######"); + v = CARD.matcher(v).replaceAll("****-****-****-****"); + v = PHONE.matcher(v).replaceAll("***-****-****"); + v = EMAIL.matcher(v).replaceAll("***@***"); + return v; + } + + /** 작성자 식별자는 앞 2자만 남기고 마스킹(감사 최소화). */ + static String maskActor(String actor) { + if (actor == null || actor.isBlank()) { + return "anon"; + } + String a = actor.trim(); + return a.length() <= 2 ? a.charAt(0) + "*" : a.substring(0, 2) + "***"; + } + + private static String clip(String s) { + if (s == null) { + return null; + } + return s.length() > 200 ? s.substring(0, 200) : s; + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/ai/service/AiConfigService.java b/backend/src/main/java/com/zioinfo/mro/ai/service/AiConfigService.java new file mode 100644 index 0000000..eb63f9c --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/ai/service/AiConfigService.java @@ -0,0 +1,215 @@ +package com.zioinfo.mro.ai.service; + +import com.zioinfo.mro.admin.AuditService; +import com.zioinfo.mro.admin.dto.MroSetting; +import com.zioinfo.mro.admin.mapper.SettingMapper; +import com.zioinfo.mro.ai.OllamaClient; +import com.zioinfo.mro.ai.dto.AiConfigDto; +import com.zioinfo.mro.ai.dto.AiConfigUpdateRequest; +import com.zioinfo.mro.common.ai.ClaudeTextClient; +import com.zioinfo.mro.common.ai.TextAiClient.GenResult; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.Set; + +/** + * AI provider 런타임 설정(mro_setting, key='ai.*') 단일 출처 서비스. [GUARDiA-MRO] + * + *

해상도: DB(mro_setting) 우선 → 미설정 시 기본값/env 폴백. 시드(db/104) 미적용/키 비움 + * 상태에서도 기존 Ollama 동작이 바이트 동일하게 유지된다(provider 기본 ollama). + * + *

보안: Claude API 키는 본 서비스가 다루지 않는다. {@code claudeKeySet} 은 + * {@link ClaudeTextClient#isConfigured()}(환경변수 존재 여부)만 반환. + * + *

프로바이더: claude / qwen3 / deepseek / glm / ollama. + */ +@Service +@RequiredArgsConstructor +public class AiConfigService { + + public static final String K_PROVIDER = "ai.provider"; + public static final String K_CLAUDE_MODEL = "ai.claude.model"; + public static final String K_OLLAMA_TEXT_MODEL = "ai.ollama.textModel"; + public static final String K_ENABLED = "ai.enabled"; + + public static final String PROVIDER_OLLAMA = "ollama"; + public static final String PROVIDER_CLAUDE = "claude"; + public static final String PROVIDER_QWEN3 = "qwen3"; + public static final String PROVIDER_DEEPSEEK = "deepseek"; + public static final String PROVIDER_GLM = "glm"; + public static final String DEFAULT_CLAUDE_MODEL = "claude-sonnet-4-6"; + + public static final String MODEL_QWEN3 = "qwen3:1.7b"; + public static final String MODEL_DEEPSEEK = "deepseek-r1:1.5b"; + public static final String MODEL_GLM = "glm4:9b"; + + public static final Set ALLOWED_PROVIDERS = + Set.of(PROVIDER_OLLAMA, PROVIDER_CLAUDE, PROVIDER_QWEN3, PROVIDER_DEEPSEEK, PROVIDER_GLM); + public static final Set ALLOWED_CLAUDE_MODELS = + Set.of("claude-sonnet-4-6", "claude-haiku-4-5", "claude-opus-4-8"); + public static final Set ALLOWED_OLLAMA_MODELS = + Set.of("qwen3:1.7b", "deepseek-r1:1.5b", "glm4:9b", "llama3.2:1b"); + + private final SettingMapper repo; + private final ClaudeTextClient claudeClient; + private final OllamaClient ollamaClient; + private final AuditService auditService; + + @Value("${guardia.ollama-text-model:llama3.2:1b}") + private String defaultOllamaTextModel; + + // ---------------------------------------------------------------- 효과값(DB 우선 → 기본) + + public String provider() { + String v = dbVal(K_PROVIDER); + if (v != null) { + String p = v.trim().toLowerCase(); + if (ALLOWED_PROVIDERS.contains(p)) { + return p; + } + } + return PROVIDER_OLLAMA; + } + + public String claudeModel() { + String v = dbVal(K_CLAUDE_MODEL); + if (v != null) { + String m = v.trim(); + if (ALLOWED_CLAUDE_MODELS.contains(m)) { + return m; + } + } + return DEFAULT_CLAUDE_MODEL; + } + + public String ollamaTextModel() { + String p = provider(); + if (PROVIDER_QWEN3.equals(p)) { + return MODEL_QWEN3; + } + if (PROVIDER_DEEPSEEK.equals(p)) { + return MODEL_DEEPSEEK; + } + if (PROVIDER_GLM.equals(p)) { + return MODEL_GLM; + } + String v = dbVal(K_OLLAMA_TEXT_MODEL); + if (v != null && ALLOWED_OLLAMA_MODELS.contains(v.trim())) { + return v.trim(); + } + return defaultOllamaTextModel; + } + + public boolean aiEnabled() { + String v = dbVal(K_ENABLED); + return v == null || !"false".equalsIgnoreCase(v.trim()); + } + + public boolean claudeKeySet() { + return claudeClient.isConfigured(); + } + + public boolean isClaudeActive() { + return PROVIDER_CLAUDE.equals(provider()) && claudeKeySet() && aiEnabled(); + } + + // ---------------------------------------------------------------- ADMIN: 조회 / 갱신 + + public AiConfigDto getConfig() { + return new AiConfigDto( + provider(), + ollamaTextModel(), + claudeModel(), + claudeKeySet(), + aiEnabled()); + } + + public AiConfigDto update(AiConfigUpdateRequest req, String actor) { + String provider = req.provider() == null ? "" : req.provider().trim().toLowerCase(); + if (!ALLOWED_PROVIDERS.contains(provider)) { + throw new IllegalArgumentException("ERR-AI-400: provider 는 ollama/claude/qwen3/deepseek/glm 중 하나여야 합니다."); + } + upsertAudited(K_PROVIDER, provider, actor); + + if (req.claudeModel() != null && !req.claudeModel().isBlank()) { + String model = req.claudeModel().trim(); + if (!ALLOWED_CLAUDE_MODELS.contains(model)) { + throw new IllegalArgumentException("ERR-AI-400: 허용되지 않은 Claude 모델 ID 입니다."); + } + upsertAudited(K_CLAUDE_MODEL, model, actor); + } + + if (req.ollamaTextModel() != null && !req.ollamaTextModel().isBlank()) { + String om = req.ollamaTextModel().trim(); + if (!ALLOWED_OLLAMA_MODELS.contains(om)) { + throw new IllegalArgumentException("ERR-AI-400: 허용되지 않은 Ollama 텍스트 모델입니다."); + } + upsertAudited(K_OLLAMA_TEXT_MODEL, om, actor); + } + return getConfig(); + } + + // ---------------------------------------------------------------- ADMIN: 연결 테스트 + + public TestResult test() { + if (isClaudeActive()) { + String model = claudeModel(); + long start = System.currentTimeMillis(); + GenResult r = claudeClient.generate("ping", model, 8); + long ms = System.currentTimeMillis() - start; + if (!r.degraded() && r.text() != null && !r.text().isBlank()) { + return new TestResult(true, false, "정상 · " + model + " · " + fmtMs(ms)); + } + return new TestResult(false, true, "Claude 연결 실패 · 모델/네트워크/키 설정을 확인하세요."); + } + if (PROVIDER_CLAUDE.equals(provider()) && !claudeKeySet()) { + return new TestResult(false, true, + "Claude API 키가 설정되지 않아 Ollama(온프레미스)로 폴백 동작합니다. 서버 환경변수 설정 후 다시 시도하세요."); + } + if (!aiEnabled()) { + return new TestResult(false, true, "AI 기능이 비활성 상태입니다. 모든 AI 결과는 규칙 기반(degraded)으로 동작합니다."); + } + String model = ollamaTextModel(); + long start = System.currentTimeMillis(); + String txt = ollamaClient.generate("ping", model); + long ms = System.currentTimeMillis() - start; + if (txt != null && !txt.isBlank()) { + return new TestResult(true, false, "정상 · " + model + " · " + fmtMs(ms)); + } + String hint = MODEL_GLM.equals(model) + ? "Ollama 연결 실패 또는 모델 미가용 — glm4:9b 는 RAM 여유가 필요합니다(설정 확인)." + : "Ollama 연결 실패 또는 모델 미가용 — 설정을 확인하세요."; + return new TestResult(false, true, hint); + } + + private static String fmtMs(long ms) { + return String.format("%.1fs", ms / 1000.0); + } + + /** 연결 테스트 결과(요약만 — 키/스택/IP 미노출). */ + public record TestResult(boolean ok, boolean degraded, String message) { + } + + // ---------------------------------------------------------------- helpers + + private String dbVal(String key) { + MroSetting c = repo.findByKey(key); + if (c == null) { + return null; + } + String v = c.getValue(); + return (v != null && !v.isBlank()) ? v : null; + } + + private void upsertAudited(String key, String val, String actor) { + MroSetting before = repo.findByKey(key); + String prev = before == null ? "(none)" : before.getValue(); + if (val.equals(prev)) { + return; + } + repo.upsert(key, val); + auditService.log(actor == null ? "SYSTEM" : actor, "AI_CONFIG_CHANGE", key, prev + " -> " + val); + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/ai/service/AiTextRouter.java b/backend/src/main/java/com/zioinfo/mro/ai/service/AiTextRouter.java new file mode 100644 index 0000000..d29bf28 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/ai/service/AiTextRouter.java @@ -0,0 +1,63 @@ +package com.zioinfo.mro.ai.service; + +import com.zioinfo.mro.ai.OllamaClient; +import com.zioinfo.mro.ai.learning.AiLearningStore; +import com.zioinfo.mro.common.ai.ClaudeTextClient; +import com.zioinfo.mro.common.ai.TextAiClient; +import com.zioinfo.mro.common.ai.TextAiClient.GenResult; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +/** + * AI provider 선택 라우팅(런타임). 평문 텍스트 생성 진입점(고장원인분석·자연어조회 등). [GUARDiA-MRO] + * {@link AiConfigService#provider()} 를 읽어 Claude ↔ Ollama(qwen3/deepseek/glm/기존소형) 를 선택한다. + * + *

선택/폴백 정책: + *

    + *
  • provider=claude · 키 설정됨 · AI 활성 → {@link ClaudeTextClient}. 실패(degraded)면 Ollama 자동 폴백.
  • + *
  • provider=qwen3/deepseek/glm/ollama → 해당 Ollama 텍스트 모델로 generate.
  • + *
  • provider=claude 인데 키 미설정 → 곧장 Ollama(폴백모델).
  • + *
+ * 두 경로 모두 실패 시 {@code GenResult.degraded=true·text=null} → 호출자가 기존 규칙기반 폴백 유지(무회귀). + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AiTextRouter implements TextAiClient { + + private final AiConfigService aiConfig; + private final ClaudeTextClient claudeClient; + private final OllamaClient ollamaClient; + private final AiLearningStore learningStore; + + @Override + public GenResult generate(String prompt) { + if (aiConfig.isClaudeActive()) { + String model = aiConfig.claudeModel(); + long start = System.currentTimeMillis(); + GenResult r = claudeClient.generate(prompt, model); + long ms = System.currentTimeMillis() - start; + if (!r.degraded() && r.text() != null && !r.text().isBlank()) { + learningStore.logInfer(AiConfigService.PROVIDER_CLAUDE, model, ms, false); + return r; + } + learningStore.logInfer(AiConfigService.PROVIDER_CLAUDE, model, ms, true); + log.warn("Claude path degraded -> Ollama fallback"); + return ollamaGenerate(prompt, aiConfig.ollamaTextModel()); + } + return ollamaGenerate(prompt, aiConfig.ollamaTextModel()); + } + + private GenResult ollamaGenerate(String prompt, String model) { + long start = System.currentTimeMillis(); + String txt = ollamaClient.generate(prompt, model); + long ms = System.currentTimeMillis() - start; + boolean degraded = (txt == null || txt.isBlank()); + learningStore.logInfer(aiConfig.provider(), model, ms, degraded); + if (degraded) { + return new GenResult(null, true); + } + return new GenResult(txt, false); + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/auth/AuthController.java b/backend/src/main/java/com/zioinfo/mro/auth/AuthController.java new file mode 100644 index 0000000..767af38 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/auth/AuthController.java @@ -0,0 +1,29 @@ +package com.zioinfo.mro.auth; + +import com.zioinfo.mro.common.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +/** MRO 인증 컨트롤러 — 로그인/내정보. */ +@RestController +@RequestMapping("/api/mro/auth") +@RequiredArgsConstructor +public class AuthController { + + private final AuthService authService; + + @PostMapping("/login") + public ApiResponse> login(@RequestBody LoginRequest req) { + return ApiResponse.ok(authService.login(req.username(), req.password())); + } + + @GetMapping("/me") + public ApiResponse> me(@RequestHeader("Authorization") String header) { + String token = header.replace("Bearer ", ""); + return ApiResponse.ok(authService.me(token)); + } + + record LoginRequest(String username, String password) {} +} diff --git a/backend/src/main/java/com/zioinfo/mro/auth/AuthService.java b/backend/src/main/java/com/zioinfo/mro/auth/AuthService.java new file mode 100644 index 0000000..50f3130 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/auth/AuthService.java @@ -0,0 +1,48 @@ +package com.zioinfo.mro.auth; + +import com.zioinfo.mro.auth.mapper.UserMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.Map; + +/** + * MRO 인증 서비스 — 단일 JWT 로그인. + * + *

Phase3 인증 에이전트가 2FA(이메일/OTP)·ADMIN_PASSWORD_ENC 재시드 레이어를 후속 추가한다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AuthService { + + private final UserMapper userMapper; + private final PasswordEncoder passwordEncoder; + private final JwtUtil jwtUtil; + + public Map login(String username, String password) { + MroUser user = userMapper.findByUsername(username); + if (user == null || !user.isActive()) { + throw new RuntimeException("ERR-AUTH-001: 존재하지 않거나 비활성 계정"); + } + if (!passwordEncoder.matches(password, user.getPasswordHash())) { + throw new RuntimeException("ERR-AUTH-002: 비밀번호 불일치"); + } + String token = jwtUtil.generate(username, user.getRole()); + return Map.of("token", token, "type", "Bearer", "role", user.getRole()); + } + + public Map me(String token) { + String username = jwtUtil.getUsername(token); + String role = jwtUtil.getRole(token); + MroUser u = userMapper.findByUsername(username); + Map m = new HashMap<>(); + m.put("username", username); + m.put("role", role); + m.put("displayName", u != null ? u.getDisplayName() : username); + return m; + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/auth/JwtFilter.java b/backend/src/main/java/com/zioinfo/mro/auth/JwtFilter.java new file mode 100644 index 0000000..542b9f4 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/auth/JwtFilter.java @@ -0,0 +1,40 @@ +package com.zioinfo.mro.auth; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class JwtFilter extends OncePerRequestFilter { + + private final JwtUtil jwtUtil; + + @Override + protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, + FilterChain chain) throws ServletException, IOException { + String header = req.getHeader("Authorization"); + if (header != null && header.startsWith("Bearer ")) { + String token = header.substring(7); + if (jwtUtil.isValid(token)) { + String username = jwtUtil.getUsername(token); + String role = jwtUtil.getRole(token); + var auth = new UsernamePasswordAuthenticationToken( + username, null, List.of(new SimpleGrantedAuthority("ROLE_" + role)) + ); + SecurityContextHolder.getContext().setAuthentication(auth); + } + } + chain.doFilter(req, res); + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/auth/JwtUtil.java b/backend/src/main/java/com/zioinfo/mro/auth/JwtUtil.java new file mode 100644 index 0000000..8ae0326 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/auth/JwtUtil.java @@ -0,0 +1,59 @@ +package com.zioinfo.mro.auth; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; + +@Slf4j +@Component +public class JwtUtil { + + @Value("${guardia.jwt.secret:guardia-mro-jwt-secret-2026-minimum-256bit-key-zioinfo}") + private String secret; + + @Value("${guardia.jwt.expiration:86400000}") + private long expirationMs; + + private SecretKey key() { + return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + } + + public String generate(String username, String role) { + return Jwts.builder() + .subject(username) + .claim("role", role) + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + expirationMs)) + .signWith(key()) + .compact(); + } + + public Claims parse(String token) { + return Jwts.parser().verifyWith(key()).build() + .parseSignedClaims(token).getPayload(); + } + + public boolean isValid(String token) { + try { + parse(token); + return true; + } catch (JwtException | IllegalArgumentException e) { + log.debug("JWT 검증 실패: {}", e.getMessage()); + return false; + } + } + + public String getUsername(String token) { + return parse(token).getSubject(); + } + + public String getRole(String token) { + return parse(token).get("role", String.class); + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/auth/MroUser.java b/backend/src/main/java/com/zioinfo/mro/auth/MroUser.java new file mode 100644 index 0000000..d2e05e7 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/auth/MroUser.java @@ -0,0 +1,22 @@ +package com.zioinfo.mro.auth; + +import lombok.Data; +import java.time.LocalDateTime; + +/** + * MRO 운영자 계정 (mro_user 테이블). + * + *

role: SUPERADMIN / MANAGER / WORKER / VIEWER. + *

Phase3 인증 에이전트가 2FA/OTP·ADMIN_PASSWORD_ENC 재시드 컬럼을 후속 ALTER 로 추가한다. + */ +@Data +public class MroUser { + private Long id; + private String username; + private String passwordHash; + private String displayName; + private String email; + private String role; + private boolean active; + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/com/zioinfo/mro/auth/mapper/UserMapper.java b/backend/src/main/java/com/zioinfo/mro/auth/mapper/UserMapper.java new file mode 100644 index 0000000..a035f7b --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/auth/mapper/UserMapper.java @@ -0,0 +1,14 @@ +package com.zioinfo.mro.auth.mapper; + +import com.zioinfo.mro.auth.MroUser; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +/** 인증용 사용자 매퍼 (mro_user). @Mapper 필수 (annotationClass=Mapper.class 스캔). */ +@Mapper +public interface UserMapper { + + MroUser findByUsername(@Param("username") String username); + + int insert(MroUser user); +} diff --git a/backend/src/main/java/com/zioinfo/mro/calibration/CalibrationController.java b/backend/src/main/java/com/zioinfo/mro/calibration/CalibrationController.java new file mode 100644 index 0000000..7f8e94c --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/calibration/CalibrationController.java @@ -0,0 +1,51 @@ +package com.zioinfo.mro.calibration; + +import com.zioinfo.mro.common.ApiResponse; +import com.zioinfo.mro.common.AuthSupport; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 계측기 교정 API. 조회 Viewer+, 생성/수정/삭제 Manager+(기준정보 가드). + */ +@RestController +@RequestMapping("/api/mro/calibrations") +@RequiredArgsConstructor +public class CalibrationController { + + private final CalibrationService service; + + @GetMapping + public ApiResponse> list(@RequestParam(required = false) String equipmentCode, + @RequestParam(required = false) String status, + @RequestParam(required = false) String keyword) { + return ApiResponse.ok(service.list(equipmentCode, status, keyword)); + } + + @GetMapping("/{id}") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.ok(service.get(id)); + } + + @PostMapping + public ApiResponse create(@RequestBody MroCalibration c, Authentication auth) { + AuthSupport.requireManager(auth); + return ApiResponse.ok(service.create(c, AuthSupport.actor(auth))); + } + + @PutMapping("/{id}") + public ApiResponse update(@PathVariable Long id, @RequestBody MroCalibration c, Authentication auth) { + AuthSupport.requireManager(auth); + return ApiResponse.ok(service.update(id, c, AuthSupport.actor(auth))); + } + + @DeleteMapping("/{id}") + public ApiResponse delete(@PathVariable Long id, Authentication auth) { + AuthSupport.requireManager(auth); + service.delete(id); + return ApiResponse.ok(null); + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/calibration/CalibrationService.java b/backend/src/main/java/com/zioinfo/mro/calibration/CalibrationService.java new file mode 100644 index 0000000..9d7f87c --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/calibration/CalibrationService.java @@ -0,0 +1,72 @@ +package com.zioinfo.mro.calibration; + +import com.zioinfo.mro.admin.AuditService; +import com.zioinfo.mro.calibration.mapper.CalibrationMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Set; + +/** + * 계측기 교정 서비스 — 기준정보(생성/수정/삭제는 컨트롤러 MANAGER+ 가드 병행). + */ +@Service +@RequiredArgsConstructor +public class CalibrationService { + + private static final Set CAL_RESULT = Set.of("PASS", "FAIL", "ADJUSTED"); + + private final CalibrationMapper mapper; + private final AuditService audit; + + public List list(String equipmentCode, String status, String keyword) { + return mapper.findAll(equipmentCode, status, keyword); + } + + public MroCalibration get(Long id) { + MroCalibration c = mapper.findById(id); + if (c == null) throw new RuntimeException("ERR-CAL-404: 계측기 교정 없음"); + return c; + } + + public MroCalibration create(MroCalibration c, String actor) { + validate(c); + if (mapper.countByCode(c.getInstrumentCode(), null) > 0) { + throw new RuntimeException("ERR-CAL-409: 중복 계측기코드"); + } + if (c.getStatus() == null) c.setStatus("ACTIVE"); + if (c.getCalResult() == null) c.setCalResult("PASS"); + c.setCreatedBy(actor); + mapper.insert(c); + audit.log("CALIBRATION_CREATE", c.getInstrumentCode(), "result=" + c.getCalResult()); + return mapper.findById(c.getId()); + } + + public MroCalibration update(Long id, MroCalibration c, String actor) { + MroCalibration cur = get(id); + validate(c); + if (mapper.countByCode(c.getInstrumentCode(), id) > 0) { + throw new RuntimeException("ERR-CAL-409: 중복 계측기코드"); + } + c.setId(id); + mapper.update(c); + audit.log("CALIBRATION_UPDATE", cur.getInstrumentCode(), "id=" + id); + return mapper.findById(id); + } + + public void delete(Long id) { + MroCalibration cur = get(id); + mapper.delete(id); + audit.log("CALIBRATION_DELETE", cur.getInstrumentCode(), "id=" + id); + } + + private void validate(MroCalibration c) { + if (c.getInstrumentCode() == null || c.getInstrumentCode().isBlank()) + throw new IllegalArgumentException("ERR-CAL-400: instrumentCode 필수"); + if (c.getInstrumentName() == null || c.getInstrumentName().isBlank()) + throw new IllegalArgumentException("ERR-CAL-400: instrumentName 필수"); + if (c.getCalResult() != null && !CAL_RESULT.contains(c.getCalResult().toUpperCase())) + throw new IllegalArgumentException("ERR-CAL-400: calResult 은 PASS/FAIL/ADJUSTED"); + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/calibration/MroCalibration.java b/backend/src/main/java/com/zioinfo/mro/calibration/MroCalibration.java new file mode 100644 index 0000000..61d2ab8 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/calibration/MroCalibration.java @@ -0,0 +1,26 @@ +package com.zioinfo.mro.calibration; + +import lombok.Data; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 계측기 교정 (mro_calibration) — 계측기 교정 주기·차기 교정일·교정 결과. + */ +@Data +public class MroCalibration { + private Long id; + private String instrumentCode; + private String instrumentName; + private String equipmentCode; + private Integer calCycleDays; + private LocalDate lastCalDate; + private LocalDate nextCalDate; + private String calResult; // PASS / FAIL / ADJUSTED + private String calAgency; + private String certificateNo; + private String status; // ACTIVE / INACTIVE + private String createdBy; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/backend/src/main/java/com/zioinfo/mro/calibration/mapper/CalibrationMapper.java b/backend/src/main/java/com/zioinfo/mro/calibration/mapper/CalibrationMapper.java new file mode 100644 index 0000000..6948ebc --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/calibration/mapper/CalibrationMapper.java @@ -0,0 +1,20 @@ +package com.zioinfo.mro.calibration.mapper; + +import com.zioinfo.mro.calibration.MroCalibration; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface CalibrationMapper { + List findAll(@Param("equipmentCode") String equipmentCode, + @Param("status") String status, + @Param("keyword") String keyword); + MroCalibration findById(@Param("id") Long id); + MroCalibration findByCode(@Param("instrumentCode") String instrumentCode); + int countByCode(@Param("instrumentCode") String instrumentCode, @Param("excludeId") Long excludeId); + int insert(MroCalibration c); + int update(MroCalibration c); + int delete(@Param("id") Long id); +} diff --git a/backend/src/main/java/com/zioinfo/mro/common/ApiResponse.java b/backend/src/main/java/com/zioinfo/mro/common/ApiResponse.java new file mode 100644 index 0000000..0bf4015 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/common/ApiResponse.java @@ -0,0 +1,21 @@ +package com.zioinfo.mro.common; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** 표준 API 응답 봉투 — { success, message, data }. */ +@Getter +@AllArgsConstructor +public class ApiResponse { + private boolean success; + private String message; + private T data; + + public static ApiResponse ok(T data) { + return new ApiResponse<>(true, "OK", data); + } + + public static ApiResponse fail(String message) { + return new ApiResponse<>(false, message, null); + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/common/AuthSupport.java b/backend/src/main/java/com/zioinfo/mro/common/AuthSupport.java new file mode 100644 index 0000000..1cc53d7 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/common/AuthSupport.java @@ -0,0 +1,36 @@ +package com.zioinfo.mro.common; + +import org.springframework.security.core.Authentication; + +import java.util.Set; + +/** + * 컨트롤러 공통 — Authentication 에서 actor(username)/role 추출 + MANAGER+ 가드. + * + *

기준정보 마감·승인 등 MANAGER 이상만 허용하는 작업은 {@link #requireManager}로 추가 가드한다 + * (SecurityConfig 의 URL 매칭은 WORKER+까지 허용하므로 메서드 레벨 보강). + */ +public final class AuthSupport { + + private static final Set MANAGER_ROLES = Set.of("MANAGER", "SUPERADMIN"); + + private AuthSupport() { + } + + public static String actor(Authentication a) { + return a != null ? a.getName() : "system"; + } + + public static String role(Authentication a) { + if (a == null || a.getAuthorities().isEmpty()) return "VIEWER"; + String r = a.getAuthorities().iterator().next().getAuthority(); + return r.startsWith("ROLE_") ? r.substring(5) : r; + } + + /** MANAGER 이상이 아니면 예외(기준정보/마감/승인 가드). */ + public static void requireManager(Authentication a) { + if (!MANAGER_ROLES.contains(role(a).toUpperCase())) { + throw new RuntimeException("ERR-MRO-403: 기준정보/마감/승인은 MANAGER 이상만 가능합니다"); + } + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/common/CryptoUtil.java b/backend/src/main/java/com/zioinfo/mro/common/CryptoUtil.java new file mode 100644 index 0000000..37c22c3 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/common/CryptoUtil.java @@ -0,0 +1,93 @@ +package com.zioinfo.mro.common; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Base64; + +/** + * 거래처 PII / 자격증명 암호화 유틸 — AES-256-GCM. + * + *

GUARDiA 보안 불변 규칙: 공급사 담당자 연락처 등 PII는 평문 저장 금지. + * {@code *_enc} 컬럼에 본 유틸로 암호화 저장하고, API 응답에는 마스킹/제외한다. + * + *

저장 포맷: Base64( IV(12B) || ciphertext || GCM tag(16B) ). + */ +@Component +public class CryptoUtil { + + private static final int IV_LEN = 12; + private static final int TAG_BITS = 128; + private final SecretKeySpec key; + private final SecureRandom random = new SecureRandom(); + + public CryptoUtil(@Value("${guardia.crypto.secret:guardia-mro-aes-256-gcm-master-key-2026-zioinfo}") String secret) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(secret.getBytes(StandardCharsets.UTF_8)); + this.key = new SecretKeySpec(digest, "AES"); + } catch (Exception e) { + throw new IllegalStateException("암호화 키 초기화 실패", e); + } + } + + /** 평문을 AES-256-GCM으로 암호화하여 Base64 문자열로 반환. null/빈 입력은 그대로 반환. */ + public String encrypt(String plain) { + if (plain == null || plain.isEmpty()) { + return plain; + } + try { + byte[] iv = new byte[IV_LEN]; + random.nextBytes(iv); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv)); + byte[] ct = cipher.doFinal(plain.getBytes(StandardCharsets.UTF_8)); + byte[] out = new byte[iv.length + ct.length]; + System.arraycopy(iv, 0, out, 0, iv.length); + System.arraycopy(ct, 0, out, iv.length, ct.length); + return Base64.getEncoder().encodeToString(out); + } catch (Exception e) { + throw new RuntimeException("ERR-MRO-CRYPTO-01: 암호화 실패"); + } + } + + /** Base64 암호문을 복호화. 복호화 실패 시 빈 문자열. */ + public String decrypt(String enc) { + if (enc == null || enc.isEmpty()) { + return enc; + } + try { + byte[] all = Base64.getDecoder().decode(enc); + byte[] iv = new byte[IV_LEN]; + System.arraycopy(all, 0, iv, 0, IV_LEN); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv)); + byte[] pt = cipher.doFinal(all, IV_LEN, all.length - IV_LEN); + return new String(pt, StandardCharsets.UTF_8); + } catch (Exception e) { + return ""; + } + } + + /** 이메일/전화 등 PII 마스킹 (응답용). 예: ab***@x.com, 010****5678. */ + public static String mask(String value) { + if (value == null || value.isEmpty()) { + return value; + } + int at = value.indexOf('@'); + if (at > 0) { + String local = value.substring(0, at); + String shown = local.length() <= 2 ? local.substring(0, 1) : local.substring(0, 2); + return shown + "***" + value.substring(at); + } + if (value.length() <= 4) { + return "****"; + } + return value.substring(0, value.length() - 4).replaceAll(".", "*") + value.substring(value.length() - 4); + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/common/GlobalExceptionHandler.java b/backend/src/main/java/com/zioinfo/mro/common/GlobalExceptionHandler.java new file mode 100644 index 0000000..a507dd3 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/common/GlobalExceptionHandler.java @@ -0,0 +1,65 @@ +package com.zioinfo.mro.common; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataAccessException; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.multipart.MaxUploadSizeExceededException; + +/** + * 전역 예외 처리. + * + *

보안 불변 규칙: 스택트레이스를 응답에 절대 노출하지 않는다. + * 에러 코드 + 요약 메시지만 반환한다. + */ +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(MaxUploadSizeExceededException.class) + @ResponseStatus(HttpStatus.PAYLOAD_TOO_LARGE) + public ApiResponse handleMaxSize(MaxUploadSizeExceededException e) { + log.warn("업로드 크기 초과: {}", e.getMessage()); + return ApiResponse.fail("ERR-MRO-413: 파일 크기 초과 (최대 20MB)"); + } + + @ExceptionHandler(AccessDeniedException.class) + @ResponseStatus(HttpStatus.FORBIDDEN) + public ApiResponse handleAccessDenied(AccessDeniedException e) { + log.warn("권한 거부: {}", e.getMessage()); + return ApiResponse.fail("ERR-MRO-403: 권한이 없습니다"); + } + + @ExceptionHandler(IllegalArgumentException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ApiResponse handleIllegalArg(IllegalArgumentException e) { + log.warn("잘못된 요청: {}", e.getMessage()); + return ApiResponse.fail(e.getMessage()); + } + + @ExceptionHandler(DataAccessException.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public ApiResponse handleDataAccess(DataAccessException e) { + // 보안 불변 규칙: SQL/테이블/쿼리/스택 상세 절대 미노출 — 내부 로그만 남기고 일반 메시지 반환 + log.error("DB 오류", e); + return ApiResponse.fail("ERR-MRO-DB: 데이터 처리 중 오류가 발생했습니다"); + } + + @ExceptionHandler(RuntimeException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ApiResponse handleRuntime(RuntimeException e) { + // 스택트레이스 미노출 — 에러 코드/요약만 반환 + log.warn("업무 오류: {}", e.getMessage()); + return ApiResponse.fail(e.getMessage()); + } + + @ExceptionHandler(Exception.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public ApiResponse handleGeneral(Exception e) { + log.error("시스템 오류", e); + return ApiResponse.fail("ERR-SYS-001"); + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/common/ai/ClaudeTextClient.java b/backend/src/main/java/com/zioinfo/mro/common/ai/ClaudeTextClient.java new file mode 100644 index 0000000..e9fe831 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/common/ai/ClaudeTextClient.java @@ -0,0 +1,159 @@ +package com.zioinfo.mro.common.ai; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.zioinfo.mro.common.ai.TextAiClient.GenResult; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; + +/** + * 외부 Claude(Anthropic) Messages API 텍스트 생성 클라이언트. [ISSUER=GUARDiA-MRO] + * + *

외부 호출 예외 허용: 본 클라이언트는 소유자 승인에 따라 {@code api.anthropic.com} + * 호출이 허용된 유일한 외부 경로다. 그 외 외부 API 호출은 여전히 금지(Ollama localhost 전용). + * + *

API 키 보안(최우선): 키는 환경변수 {@code ANTHROPIC_API_KEY} 에서만 로드하며, + * DB·코드·커밋·로그·응답·예외 메시지 어디에도 기록하지 않는다. 키는 오직 HTTP 헤더 {@code x-api-key} + * 로만 전달된다. 로그는 상태코드/예외 클래스명만 남긴다(본문·키 미기록). + * + *

실패 처리: 키 미설정/타임아웃/비200/예외 시 {@code GenResult.degraded=true·text=null} 반환 + * (예외를 던지지 않음 → 호출자는 Ollama 폴백). 신규 라이브러리 0 — JDK {@link HttpClient} 사용. + */ +@Slf4j +@Service +public class ClaudeTextClient implements TextAiClient { + + private static final String API_URL = "https://api.anthropic.com/v1/messages"; + private static final String ANTHROPIC_VERSION = "2023-06-01"; + static final String DEFAULT_MODEL = "claude-sonnet-4-6"; + private static final int DEFAULT_MAX_TOKENS = 1024; + private static final int TIMEOUT_SEC = 120; + private static final int CONNECT_TIMEOUT_SEC = 15; + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + /** 환경변수 전용 주입. 미설정 시 빈 문자열(=미구성). 값은 절대 외부 노출/로그 금지. */ + @Value("${ANTHROPIC_API_KEY:}") + private String apiKey; + + /** API 키가 환경변수로 설정되어 있는지(여부만 — 값/길이/마스킹 일절 미노출). */ + public boolean isConfigured() { + return apiKey != null && !apiKey.isBlank(); + } + + @Override + public GenResult generate(String prompt) { + return generate(prompt, DEFAULT_MODEL); + } + + public GenResult generate(String prompt, String model) { + return generate(prompt, model, DEFAULT_MAX_TOKENS); + } + + /** + * Anthropic Messages API 호출. 실패 시 {@code degraded=true·text=null}. + * 키·요청본문·응답본문은 로그에 남기지 않는다(상태코드/예외 클래스명만). + */ + public GenResult generate(String prompt, String model, int maxTokens) { + if (!isConfigured()) { + return new GenResult(null, true); + } + if (prompt == null || prompt.isBlank()) { + return new GenResult(null, true); + } + String useModel = (model == null || model.isBlank()) ? DEFAULT_MODEL : model.trim(); + int tokens = maxTokens > 0 ? maxTokens : DEFAULT_MAX_TOKENS; + try { + String payload = "{" + + "\"model\":" + str(useModel) + "," + + "\"max_tokens\":" + tokens + "," + + "\"messages\":[{\"role\":\"user\",\"content\":" + str(prompt) + "}]" + + "}"; + + HttpClient client = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(CONNECT_TIMEOUT_SEC)) + .build(); + HttpRequest req = HttpRequest.newBuilder() + .uri(URI.create(API_URL)) + .timeout(Duration.ofSeconds(TIMEOUT_SEC)) + .header("content-type", "application/json") + .header("x-api-key", apiKey) // 키는 헤더로만 — 로그/응답 미노출 + .header("anthropic-version", ANTHROPIC_VERSION) + .POST(HttpRequest.BodyPublishers.ofString(payload)) + .build(); + HttpResponse resp = client.send(req, HttpResponse.BodyHandlers.ofString()); + if (resp.statusCode() != 200) { + log.warn("Claude API status {} -> degraded fallback", resp.statusCode()); // 본문 미기록 + return new GenResult(null, true); + } + String text = extractText(resp.body()); + if (text == null || text.isBlank()) { + return new GenResult(null, true); + } + return new GenResult(text.trim(), false); + } catch (Exception e) { + log.warn("Claude API call failed -> degraded fallback: {}", e.getClass().getSimpleName()); // 메시지·키 미기록 + return new GenResult(null, true); + } + } + + /** Messages API 응답에서 content[].text(type=text) 추출·연결. */ + private static String extractText(String body) { + if (body == null || body.isBlank()) { + return null; + } + try { + JsonNode root = MAPPER.readTree(body); + JsonNode content = root.get("content"); + if (content == null || !content.isArray()) { + return null; + } + StringBuilder sb = new StringBuilder(); + for (JsonNode block : content) { + JsonNode type = block.get("type"); + if (type != null && "text".equals(type.asText())) { + JsonNode t = block.get("text"); + if (t != null && !t.isNull()) { + sb.append(t.asText()); + } + } + } + String out = sb.toString(); + return out.isBlank() ? null : out; + } catch (Exception e) { + return null; + } + } + + /** JSON 문자열 이스케이프(프롬프트 직렬화 규칙). */ + private static String str(String s) { + if (s == null) { + return "\"\""; + } + StringBuilder sb = new StringBuilder("\""); + for (char c : s.toCharArray()) { + switch (c) { + case '"' -> sb.append("\\\""); + case '\\' -> sb.append("\\\\"); + case '\n' -> sb.append("\\n"); + case '\r' -> sb.append("\\r"); + case '\t' -> sb.append("\\t"); + default -> { + if (c < 0x20) { + sb.append(String.format("\\u%04x", (int) c)); + } else { + sb.append(c); + } + } + } + } + return sb.append("\"").toString(); + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/common/ai/TextAiClient.java b/backend/src/main/java/com/zioinfo/mro/common/ai/TextAiClient.java new file mode 100644 index 0000000..4970b8d --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/common/ai/TextAiClient.java @@ -0,0 +1,22 @@ +package com.zioinfo.mro.common.ai; + +/** + * 텍스트 생성 공용 인터페이스 (AI provider 추상화). [GUARDiA-MRO] + * + *

온프레미스 Ollama({@code ai.OllamaClient#generate})와 외부 Claude({@link ClaudeTextClient})를 + * 동일 계약으로 다루기 위한 얇은 추상화. 구현체는 어떤 사유로든(비활성/실패/타임아웃/키미설정) 실패 시 + * {@link GenResult#degraded()}=true · {@link GenResult#text()}=null 을 반환하고, 호출자가 폴백을 책임진다. + * + *

provider 선택/폴백 라우팅은 {@code ai.service.AiTextRouter} 가 담당한다(이 인터페이스를 구현). + */ +public interface TextAiClient { + + /** + * 프롬프트로 텍스트를 생성한다. 실패 시 {@code degraded=true·text=null}(예외를 던지지 않음). + */ + GenResult generate(String prompt); + + /** 생성 결과: 텍스트(폴백 시 null) + degraded(폴백 여부). */ + record GenResult(String text, boolean degraded) { + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/config/CorsConfig.java b/backend/src/main/java/com/zioinfo/mro/config/CorsConfig.java new file mode 100644 index 0000000..2048d73 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/config/CorsConfig.java @@ -0,0 +1,25 @@ +package com.zioinfo.mro.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; + +@Configuration +public class CorsConfig { + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowedOriginPatterns(List.of("*")); + config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); + config.setAllowedHeaders(List.of("*")); + config.setAllowCredentials(true); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/config/MyBatisConfig.java b/backend/src/main/java/com/zioinfo/mro/config/MyBatisConfig.java new file mode 100644 index 0000000..8238015 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/config/MyBatisConfig.java @@ -0,0 +1,36 @@ +package com.zioinfo.mro.config; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.session.SqlSessionFactory; +import org.mybatis.spring.SqlSessionFactoryBean; +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; + +import javax.sql.DataSource; + +/** + * MyBatis 설정. + * + *

불변 규칙(형제 솔루션 TemplateMapper 빈누락 크래시 함정 준수): + * {@code @MapperScan(annotationClass=Mapper.class)} — basePackages 금지 대신 전체 베이스 패키지에서 + * {@code @Mapper} 인터페이스만 등록. 모든 매퍼 인터페이스에 {@code @Mapper} 필수, XML namespace=FQN. + */ +@Configuration +@MapperScan(basePackages = "com.zioinfo.mro", annotationClass = Mapper.class) +public class MyBatisConfig { + + @Bean + public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception { + SqlSessionFactoryBean factory = new SqlSessionFactoryBean(); + factory.setDataSource(dataSource); + factory.setMapperLocations( + new PathMatchingResourcePatternResolver().getResources("classpath:mapper/**/*.xml") + ); + org.apache.ibatis.session.Configuration config = new org.apache.ibatis.session.Configuration(); + config.setMapUnderscoreToCamelCase(true); + factory.setConfiguration(config); + return factory.getObject(); + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/config/SecurityConfig.java b/backend/src/main/java/com/zioinfo/mro/config/SecurityConfig.java new file mode 100644 index 0000000..3993fe0 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/config/SecurityConfig.java @@ -0,0 +1,85 @@ +package com.zioinfo.mro.config; + +import com.zioinfo.mro.auth.JwtFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +/** + * GUARDiA MRO 보안 설정 — JWT 무상태 인증 + RBAC. + * + *

RBAC 역할(상위 → 하위): SUPERADMIN ⊃ MANAGER ⊃ WORKER ⊃ VIEWER. + *

    + *
  • auth/health/swagger/정적 — permitAll
  • + *
  • 조회(GET /api/mro/**) — 인증 사용자 전체(Viewer+)
  • + *
  • 작업지시/실적/입출고/재고이동 입력(POST/PUT/PATCH/DELETE) — Worker 이상
  • + *
  • 기준정보 마감·승인 — Manager 이상(서비스/메서드 가드 병행)
  • + *
  • 관리자 API(/api/admin/**) — SuperAdmin 전용(감사로그 조회는 Manager+)
  • + *
+ */ +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtFilter jwtFilter; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(csrf -> csrf.disable()) + .cors(cors -> {}) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/mro/auth/**").permitAll() + .requestMatchers("/actuator/health").permitAll() + .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/api/mro/docs/**", "/api/mro/swagger/**").permitAll() + .requestMatchers(HttpMethod.GET, "/api/public/**").permitAll() + // 정적 프론트 번들 + .requestMatchers("/", "/index.html", "/assets/**", "/favicon.ico").permitAll() + + // 관리자 — 사용자/설정 관리는 SUPERADMIN 전용 + .requestMatchers("/api/admin/users/**").hasRole("SUPERADMIN") + .requestMatchers(HttpMethod.GET, "/api/admin/settings").hasAnyRole("SUPERADMIN", "MANAGER") + .requestMatchers("/api/admin/settings/**").hasRole("SUPERADMIN") + .requestMatchers("/api/admin/audit").hasAnyRole("SUPERADMIN", "MANAGER") + .requestMatchers("/api/admin/**").hasRole("SUPERADMIN") + + // 변경(작업지시·실적·입출고·재고이동) — WORKER 이상 (기준정보/마감/승인은 서비스에서 MANAGER+ 가드) + .requestMatchers(HttpMethod.POST, "/api/mro/**").hasAnyRole("SUPERADMIN", "MANAGER", "WORKER") + .requestMatchers(HttpMethod.PUT, "/api/mro/**").hasAnyRole("SUPERADMIN", "MANAGER", "WORKER") + .requestMatchers(HttpMethod.PATCH, "/api/mro/**").hasAnyRole("SUPERADMIN", "MANAGER", "WORKER") + .requestMatchers(HttpMethod.DELETE, "/api/mro/**").hasAnyRole("SUPERADMIN", "MANAGER", "WORKER") + // 조회 — 인증 사용자 전체(Viewer+) + .requestMatchers(HttpMethod.GET, "/api/mro/**").hasAnyRole("SUPERADMIN", "MANAGER", "WORKER", "VIEWER") + // AI 피드백 — 인증 사용자 + .requestMatchers("/api/ai/**").authenticated() + + .anyRequest().authenticated() + ) + .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { + return config.getAuthenticationManager(); + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/config/SpaForwardController.java b/backend/src/main/java/com/zioinfo/mro/config/SpaForwardController.java new file mode 100644 index 0000000..b9ee3ac --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/config/SpaForwardController.java @@ -0,0 +1,26 @@ +package com.zioinfo.mro.config; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.stereotype.Controller; + +/** + * SPA 포워딩 — vite 정적 번들(index.html)을 클라이언트 라우팅 경로로 포워드. + * + *

/api, /actuator, /swagger 등은 제외하고 그 외 비-정적 경로를 index.html 로 포워딩한다. + * (형제 솔루션 단일 jar 패턴: frontend → backend static) + */ +@Controller +public class SpaForwardController { + + @RequestMapping(value = {"/admin", "/admin/**"}) + public String adminSpa() { + return "forward:/index.html"; + } + + @GetMapping("/health-lite") + @org.springframework.web.bind.annotation.ResponseBody + public String healthLite() { + return "GUARDiA MRO UP"; + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/cost/CostController.java b/backend/src/main/java/com/zioinfo/mro/cost/CostController.java new file mode 100644 index 0000000..b87f24a --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/cost/CostController.java @@ -0,0 +1,55 @@ +package com.zioinfo.mro.cost; + +import com.zioinfo.mro.common.ApiResponse; +import com.zioinfo.mro.common.AuthSupport; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** + * MRO 비용 API — 조회/집계 Viewer+, 생성/수정 Worker+, 삭제 Manager+. + */ +@RestController +@RequestMapping("/api/mro/costs") +@RequiredArgsConstructor +public class CostController { + + private final CostService service; + + @GetMapping + public ApiResponse> list(@RequestParam(required = false) String costType, + @RequestParam(required = false) String equipmentCode, + @RequestParam(required = false) String deptCode) { + return ApiResponse.ok(service.list(costType, equipmentCode, deptCode)); + } + + @GetMapping("/summary") + public ApiResponse>> summary() { + return ApiResponse.ok(service.summary()); + } + + @GetMapping("/{id}") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.ok(service.get(id)); + } + + @PostMapping + public ApiResponse create(@RequestBody MroCost c, Authentication auth) { + return ApiResponse.ok(service.create(c, AuthSupport.actor(auth))); + } + + @PutMapping("/{id}") + public ApiResponse update(@PathVariable Long id, @RequestBody MroCost c, Authentication auth) { + return ApiResponse.ok(service.update(id, c, AuthSupport.actor(auth))); + } + + @DeleteMapping("/{id}") + public ApiResponse delete(@PathVariable Long id, Authentication auth) { + AuthSupport.requireManager(auth); + service.delete(id, AuthSupport.actor(auth)); + return ApiResponse.ok(null); + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/cost/CostService.java b/backend/src/main/java/com/zioinfo/mro/cost/CostService.java new file mode 100644 index 0000000..5592684 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/cost/CostService.java @@ -0,0 +1,74 @@ +package com.zioinfo.mro.cost; + +import com.zioinfo.mro.admin.AuditService; +import com.zioinfo.mro.cost.mapper.CostMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * MRO 비용 서비스 — CRUD + costType 별 합계 집계(summary). + */ +@Service +@RequiredArgsConstructor +public class CostService { + + private static final Set COST_TYPES = Set.of("LABOR", "PART", "OUTSOURCE", "OTHER"); + + private final CostMapper mapper; + private final AuditService audit; + + public List list(String costType, String equipmentCode, String deptCode) { + return mapper.findAll(costType, equipmentCode, deptCode); + } + + public MroCost get(Long id) { + MroCost c = mapper.findById(id); + if (c == null) throw new RuntimeException("ERR-CST-404: 비용 없음"); + return c; + } + + public MroCost create(MroCost c, String actor) { + validate(c); + if (c.getCostType() == null) c.setCostType("PART"); + if (c.getCurrency() == null) c.setCurrency("KRW"); + if (c.getCostNo() == null || c.getCostNo().isBlank()) { + c.setCostNo("CST-" + System.currentTimeMillis()); + } + c.setCreatedBy(actor); + mapper.insert(c); + audit.log(actor, "COST_CREATE", c.getCostNo(), "type=" + c.getCostType() + ",amount=" + c.getAmount()); + return mapper.findById(c.getId()); + } + + public MroCost update(Long id, MroCost c, String actor) { + MroCost cur = get(id); + validate(c); + if (c.getCostType() == null) c.setCostType(cur.getCostType()); + if (c.getCurrency() == null) c.setCurrency(cur.getCurrency()); + c.setId(id); + c.setCostNo(cur.getCostNo()); + mapper.update(c); + audit.log(actor, "COST_UPDATE", cur.getCostNo(), "id=" + id); + return mapper.findById(id); + } + + public void delete(Long id, String actor) { + MroCost cur = get(id); + mapper.delete(id); + audit.log(actor, "COST_DELETE", cur.getCostNo(), "id=" + id); + } + + /** costType 별 합계 amount 집계. */ + public List> summary() { + return mapper.summaryByType(); + } + + private void validate(MroCost c) { + if (c.getCostType() != null && !COST_TYPES.contains(c.getCostType().toUpperCase())) + throw new IllegalArgumentException("ERR-CST-400: costType 은 LABOR/PART/OUTSOURCE/OTHER"); + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/cost/MroCost.java b/backend/src/main/java/com/zioinfo/mro/cost/MroCost.java new file mode 100644 index 0000000..6379770 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/cost/MroCost.java @@ -0,0 +1,26 @@ +package com.zioinfo.mro.cost; + +import lombok.Data; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * MRO 비용 (mro_cost) — 인건비/부품/외주/기타 비용 기록 + 예산. + */ +@Data +public class MroCost { + private Long id; + private String costNo; + private String costType; // LABOR/PART/OUTSOURCE/OTHER + private String equipmentCode; + private String woNo; + private String deptCode; + private String budgetCode; + private Double amount; + private String currency; + private LocalDate costDate; + private String description; + private Double budgetAmount; + private String createdBy; + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/com/zioinfo/mro/cost/mapper/CostMapper.java b/backend/src/main/java/com/zioinfo/mro/cost/mapper/CostMapper.java new file mode 100644 index 0000000..da2d6de --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/cost/mapper/CostMapper.java @@ -0,0 +1,20 @@ +package com.zioinfo.mro.cost.mapper; + +import com.zioinfo.mro.cost.MroCost; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; +import java.util.Map; + +@Mapper +public interface CostMapper { + List findAll(@Param("costType") String costType, + @Param("equipmentCode") String equipmentCode, + @Param("deptCode") String deptCode); + MroCost findById(@Param("id") Long id); + int insert(MroCost c); + int update(MroCost c); + int delete(@Param("id") Long id); + List> summaryByType(); +} diff --git a/backend/src/main/java/com/zioinfo/mro/dashboard/DashboardController.java b/backend/src/main/java/com/zioinfo/mro/dashboard/DashboardController.java new file mode 100644 index 0000000..b17824b --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/dashboard/DashboardController.java @@ -0,0 +1,40 @@ +package com.zioinfo.mro.dashboard; + +import com.zioinfo.mro.common.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; + +/** + * 대시보드 KPI 집계 API — 전부 조회 전용(GET, Viewer+). 순수 매퍼 집계로 타 도메인 클래스에 의존하지 않는다. + */ +@RestController +@RequestMapping("/api/mro/dashboard") +@RequiredArgsConstructor +public class DashboardController { + + private final DashboardService service; + + /** 설비·WO·재고·구매·다운타임 핵심 지표 단일 요약. */ + @GetMapping("/summary") + public ApiResponse> summary() { + return ApiResponse.ok(service.summary()); + } + + /** 작업지시 status 별 카운트 목록(GROUP BY status). */ + @GetMapping("/wo-status") + public ApiResponse>> woStatus() { + return ApiResponse.ok(service.woStatus()); + } + + /** 향후 N일 내 next_due_date 도래 PM 계획 목록(기본 7일). */ + @GetMapping("/pm-due") + public ApiResponse>> pmDue(@RequestParam(defaultValue = "7") int days) { + return ApiResponse.ok(service.pmDue(days)); + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/dashboard/DashboardService.java b/backend/src/main/java/com/zioinfo/mro/dashboard/DashboardService.java new file mode 100644 index 0000000..f96942e --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/dashboard/DashboardService.java @@ -0,0 +1,68 @@ +package com.zioinfo.mro.dashboard; + +import com.zioinfo.mro.dashboard.mapper.DashboardMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 대시보드 KPI 조회 서비스 — 각 지표를 개별 매퍼 select 로 조회 후 단일 요약 맵으로 조립. + * 순수 집계라 타 도메인 서비스/엔티티에 의존하지 않는다. + */ +@Service +@RequiredArgsConstructor +public class DashboardService { + + private final DashboardMapper mapper; + + /** 설비·WO·재고·구매·다운타임 핵심 지표 단일 요약. */ + public Map summary() { + Map equip = mapper.equipmentCounts(); + Map wo = mapper.woStatusSummary(); + + Map out = new LinkedHashMap<>(); + out.put("totalEquipment", num(equip, "totalEquipment")); + out.put("runningEquipment", num(equip, "runCount")); + out.put("downEquipment", num(equip, "downCount")); + + Map woStatus = new LinkedHashMap<>(); + woStatus.put("CREATED", num(wo, "created")); + woStatus.put("ASSIGNED", num(wo, "assigned")); + woStatus.put("INPROGRESS", num(wo, "inprogress")); + woStatus.put("DONE", num(wo, "done")); + out.put("woStatus", woStatus); + + out.put("shortageMaterials", mapper.shortageMaterialCount()); + out.put("prPending", mapper.prPendingCount()); + out.put("poInProgress", mapper.poInProgressCount()); + out.put("monthDowntimeMin", mapper.monthDowntimeSum()); + return out; + } + + /** WO status 별 카운트 목록. */ + public List> woStatus() { + return mapper.woStatusCounts(); + } + + /** 향후 days 일 내 도래 PM 계획 목록(days<=0 이면 7일로 보정). */ + public List> pmDue(int days) { + int d = (days <= 0 || days > 3650) ? 7 : days; + return mapper.pmDue(d); + } + + /** 맵 값(Number/문자)을 long 으로 NULL 안전 변환. */ + private static long num(Map m, String key) { + if (m == null) return 0L; + Object v = m.get(key); + if (v instanceof Number n) return n.longValue(); + if (v == null) return 0L; + try { + return Long.parseLong(v.toString()); + } catch (NumberFormatException e) { + return 0L; + } + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/dashboard/mapper/DashboardMapper.java b/backend/src/main/java/com/zioinfo/mro/dashboard/mapper/DashboardMapper.java new file mode 100644 index 0000000..68c7177 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/dashboard/mapper/DashboardMapper.java @@ -0,0 +1,38 @@ +package com.zioinfo.mro.dashboard.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; +import java.util.Map; + +/** + * 대시보드 집계 매퍼 — 각 지표를 별도 select 로 조회(조회 전용). NULL 안전(COALESCE). + */ +@Mapper +public interface DashboardMapper { + + /** 설비 총수·가동중(RUN)·다운(DOWN) 카운트. */ + Map equipmentCounts(); + + /** WO 상태별 단일 요약(created/assigned/inprogress/done). */ + Map woStatusSummary(); + + /** WO status 별 카운트 목록(GROUP BY status). */ + List> woStatusCounts(); + + /** 안전재고 미달(available_qty < safety_stock) 자재 수. */ + long shortageMaterialCount(); + + /** 구매요청 대기 수(status='SUBMITTED'). */ + long prPendingCount(); + + /** 발주 진행 수(status IN ('ORDERED','PARTIAL')). */ + long poInProgressCount(); + + /** 이번 달 다운타임 합계(분). */ + double monthDowntimeSum(); + + /** 향후 days 일 내 next_due_date 도래 PM 계획 목록. */ + List> pmDue(@Param("days") int days); +} diff --git a/backend/src/main/java/com/zioinfo/mro/downtime/DowntimeController.java b/backend/src/main/java/com/zioinfo/mro/downtime/DowntimeController.java new file mode 100644 index 0000000..ad84eb0 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/downtime/DowntimeController.java @@ -0,0 +1,48 @@ +package com.zioinfo.mro.downtime; + +import com.zioinfo.mro.common.ApiResponse; +import com.zioinfo.mro.common.AuthSupport; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * MRO 다운타임 API — 조회 Viewer+, 등록/수정 Worker+, 삭제 Manager+. + */ +@RestController +@RequestMapping("/api/mro/downtimes") +@RequiredArgsConstructor +public class DowntimeController { + + private final DowntimeService service; + + @GetMapping + public ApiResponse> list(@RequestParam(required = false) String equipmentCode, + @RequestParam(required = false) String downtimeType) { + return ApiResponse.ok(service.list(equipmentCode, downtimeType)); + } + + @GetMapping("/{id}") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.ok(service.get(id)); + } + + @PostMapping + public ApiResponse create(@RequestBody MroDowntime dt, Authentication auth) { + return ApiResponse.ok(service.create(dt, AuthSupport.actor(auth))); + } + + @PutMapping("/{id}") + public ApiResponse update(@PathVariable Long id, @RequestBody MroDowntime dt, Authentication auth) { + return ApiResponse.ok(service.update(id, dt, AuthSupport.actor(auth))); + } + + @DeleteMapping("/{id}") + public ApiResponse delete(@PathVariable Long id, Authentication auth) { + AuthSupport.requireManager(auth); + service.delete(id, AuthSupport.actor(auth)); + return ApiResponse.ok(null); + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/downtime/DowntimeService.java b/backend/src/main/java/com/zioinfo/mro/downtime/DowntimeService.java new file mode 100644 index 0000000..371d1b0 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/downtime/DowntimeService.java @@ -0,0 +1,73 @@ +package com.zioinfo.mro.downtime; + +import com.zioinfo.mro.admin.AuditService; +import com.zioinfo.mro.downtime.mapper.DowntimeMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.List; +import java.util.Set; + +/** + * MRO 다운타임 서비스 — 등록/수정(Worker+)·삭제(Manager+). start/end 있으면 duration_min 자동계산. + */ +@Service +@RequiredArgsConstructor +public class DowntimeService { + + private static final Set DOWNTIME_TYPES = Set.of("FAULT", "PM", "SETUP", "MATERIAL", "OTHER"); + + private final DowntimeMapper mapper; + private final AuditService audit; + + public List list(String equipmentCode, String downtimeType) { + return mapper.findAll(equipmentCode, downtimeType); + } + + public MroDowntime get(Long id) { + MroDowntime dt = mapper.findById(id); + if (dt == null) throw new RuntimeException("ERR-DT-404: 다운타임 없음"); + return dt; + } + + public MroDowntime create(MroDowntime dt, String actor) { + validate(dt); + if (dt.getDowntimeType() == null) dt.setDowntimeType("FAULT"); + dt.setDurationMin(computeDuration(dt)); + dt.setCreatedBy(actor); + mapper.insert(dt); + audit.log(actor, "DOWNTIME_CREATE", dt.getEquipmentCode(), + "type=" + dt.getDowntimeType() + " min=" + dt.getDurationMin()); + return mapper.findById(dt.getId()); + } + + public MroDowntime update(Long id, MroDowntime dt, String actor) { + MroDowntime cur = get(id); + validate(dt); + dt.setId(id); + dt.setDurationMin(computeDuration(dt)); + mapper.update(dt); + audit.log(actor, "DOWNTIME_UPDATE", cur.getEquipmentCode(), "id=" + id); + return mapper.findById(id); + } + + public void delete(Long id, String actor) { + MroDowntime cur = get(id); + mapper.delete(id); + audit.log(actor, "DOWNTIME_DELETE", cur.getEquipmentCode(), "id=" + id); + } + + /** start_at/end_at 둘 다 있으면 분(minutes)으로 계산, 아니면 요청값 유지. */ + private Double computeDuration(MroDowntime dt) { + if (dt.getStartAt() != null && dt.getEndAt() != null) { + return (double) Duration.between(dt.getStartAt(), dt.getEndAt()).toMinutes(); + } + return dt.getDurationMin(); + } + + private void validate(MroDowntime dt) { + if (dt.getDowntimeType() != null && !DOWNTIME_TYPES.contains(dt.getDowntimeType().toUpperCase())) + throw new IllegalArgumentException("ERR-DT-400: downtimeType 은 FAULT/PM/SETUP/MATERIAL/OTHER"); + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/downtime/MroDowntime.java b/backend/src/main/java/com/zioinfo/mro/downtime/MroDowntime.java new file mode 100644 index 0000000..8e04af3 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/downtime/MroDowntime.java @@ -0,0 +1,23 @@ +package com.zioinfo.mro.downtime; + +import lombok.Data; +import java.time.LocalDateTime; + +/** + * MRO 다운타임 (mro_downtime) — 설비 비가동 이력(신뢰성지표 MTBF/MTTR 근거). + * + *

downtimeType: FAULT/PM/SETUP/MATERIAL/OTHER. start_at/end_at 있으면 duration_min 자동계산(분). + */ +@Data +public class MroDowntime { + private Long id; + private String equipmentCode; + private String woNo; + private String downtimeType; + private LocalDateTime startAt; + private LocalDateTime endAt; + private Double durationMin; + private String reason; + private String createdBy; + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/com/zioinfo/mro/downtime/mapper/DowntimeMapper.java b/backend/src/main/java/com/zioinfo/mro/downtime/mapper/DowntimeMapper.java new file mode 100644 index 0000000..e713cca --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/downtime/mapper/DowntimeMapper.java @@ -0,0 +1,17 @@ +package com.zioinfo.mro.downtime.mapper; + +import com.zioinfo.mro.downtime.MroDowntime; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface DowntimeMapper { + List findAll(@Param("equipmentCode") String equipmentCode, + @Param("downtimeType") String downtimeType); + MroDowntime findById(@Param("id") Long id); + int insert(MroDowntime dt); + int update(MroDowntime dt); + int delete(@Param("id") Long id); +} diff --git a/backend/src/main/java/com/zioinfo/mro/equipment/EquipmentController.java b/backend/src/main/java/com/zioinfo/mro/equipment/EquipmentController.java new file mode 100644 index 0000000..657b7a9 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/equipment/EquipmentController.java @@ -0,0 +1,59 @@ +package com.zioinfo.mro.equipment; + +import com.zioinfo.mro.common.ApiResponse; +import com.zioinfo.mro.common.AuthSupport; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** + * 설비 마스터 API. 조회 Viewer+, 생성/수정/삭제 Manager+(기준정보 가드), 가동상태 전이 Worker+. + */ +@RestController +@RequestMapping("/api/mro/equipment") +@RequiredArgsConstructor +public class EquipmentController { + + private final EquipmentService service; + + @GetMapping + public ApiResponse> list(@RequestParam(required = false) String category, + @RequestParam(required = false) String runStatus, + @RequestParam(required = false) String keyword) { + return ApiResponse.ok(service.list(category, runStatus, keyword)); + } + + @GetMapping("/{id}") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.ok(service.get(id)); + } + + @PostMapping + public ApiResponse create(@RequestBody MroEquipment e, Authentication auth) { + AuthSupport.requireManager(auth); + return ApiResponse.ok(service.create(e, AuthSupport.actor(auth))); + } + + @PutMapping("/{id}") + public ApiResponse update(@PathVariable Long id, @RequestBody MroEquipment e, Authentication auth) { + AuthSupport.requireManager(auth); + return ApiResponse.ok(service.update(id, e, AuthSupport.actor(auth))); + } + + /** 가동상태 전이 — Worker+ (기준정보 가드 없음). */ + @PatchMapping("/{id}/run-status") + public ApiResponse changeRunStatus(@PathVariable Long id, @RequestBody Map body, + Authentication auth) { + return ApiResponse.ok(service.changeRunStatus(id, body.get("runStatus"), AuthSupport.actor(auth))); + } + + @DeleteMapping("/{id}") + public ApiResponse delete(@PathVariable Long id, Authentication auth) { + AuthSupport.requireManager(auth); + service.delete(id); + return ApiResponse.ok(null); + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/equipment/EquipmentService.java b/backend/src/main/java/com/zioinfo/mro/equipment/EquipmentService.java new file mode 100644 index 0000000..594cd5c --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/equipment/EquipmentService.java @@ -0,0 +1,88 @@ +package com.zioinfo.mro.equipment; + +import com.zioinfo.mro.admin.AuditService; +import com.zioinfo.mro.equipment.mapper.EquipmentMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Set; + +/** + * 설비 마스터 서비스 — 기준정보(MANAGER+ 가드는 컨트롤러 병행). 가동상태 변경은 WORKER+. + */ +@Service +@RequiredArgsConstructor +public class EquipmentService { + + private static final Set RUN_STATUS = Set.of("RUN", "IDLE", "DOWN", "MAINT"); + private static final Set CRITICALITY = Set.of("A", "B", "C"); + + private final EquipmentMapper mapper; + private final AuditService audit; + + public List list(String category, String runStatus, String keyword) { + return mapper.findAll(category, runStatus, keyword); + } + + public MroEquipment get(Long id) { + MroEquipment e = mapper.findById(id); + if (e == null) throw new RuntimeException("ERR-EQ-404: 설비 없음"); + return e; + } + + public MroEquipment create(MroEquipment e, String actor) { + validate(e); + if (mapper.countByCode(e.getEquipmentCode(), null) > 0) { + throw new RuntimeException("ERR-EQ-409: 중복 설비코드"); + } + if (e.getStatus() == null) e.setStatus("ACTIVE"); + if (e.getRunStatus() == null) e.setRunStatus("IDLE"); + if (e.getCriticality() == null) e.setCriticality("B"); + e.setCreatedBy(actor); + mapper.insert(e); + audit.log("EQUIPMENT_CREATE", e.getEquipmentCode(), "criticality=" + e.getCriticality()); + return mapper.findById(e.getId()); + } + + public MroEquipment update(Long id, MroEquipment e, String actor) { + MroEquipment cur = get(id); + validate(e); + if (mapper.countByCode(e.getEquipmentCode(), id) > 0) { + throw new RuntimeException("ERR-EQ-409: 중복 설비코드"); + } + e.setId(id); + mapper.update(e); + audit.log("EQUIPMENT_UPDATE", cur.getEquipmentCode(), "id=" + id); + return mapper.findById(id); + } + + /** 가동상태 전이 (RUN/IDLE/DOWN/MAINT) — WORKER+. */ + public MroEquipment changeRunStatus(Long id, String runStatus, String actor) { + MroEquipment cur = get(id); + String s = runStatus == null ? "" : runStatus.trim().toUpperCase(); + if (!RUN_STATUS.contains(s)) { + throw new IllegalArgumentException("ERR-EQ-400: runStatus 은 RUN/IDLE/DOWN/MAINT"); + } + mapper.updateRunStatus(id, s); + audit.log(actor, "EQUIPMENT_RUNSTATUS", cur.getEquipmentCode(), cur.getRunStatus() + " -> " + s); + return mapper.findById(id); + } + + public void delete(Long id) { + MroEquipment cur = get(id); + mapper.delete(id); + audit.log("EQUIPMENT_DELETE", cur.getEquipmentCode(), "id=" + id); + } + + private void validate(MroEquipment e) { + if (e.getEquipmentCode() == null || e.getEquipmentCode().isBlank()) + throw new IllegalArgumentException("ERR-EQ-400: equipmentCode 필수"); + if (e.getEquipmentName() == null || e.getEquipmentName().isBlank()) + throw new IllegalArgumentException("ERR-EQ-400: equipmentName 필수"); + if (e.getCriticality() != null && !CRITICALITY.contains(e.getCriticality().toUpperCase())) + throw new IllegalArgumentException("ERR-EQ-400: criticality 은 A/B/C"); + if (e.getRunStatus() != null && !RUN_STATUS.contains(e.getRunStatus().toUpperCase())) + throw new IllegalArgumentException("ERR-EQ-400: runStatus 은 RUN/IDLE/DOWN/MAINT"); + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/equipment/MroEquipment.java b/backend/src/main/java/com/zioinfo/mro/equipment/MroEquipment.java new file mode 100644 index 0000000..4f5faa4 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/equipment/MroEquipment.java @@ -0,0 +1,30 @@ +package com.zioinfo.mro.equipment; + +import lombok.Data; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 설비 마스터 (mro_equipment) — 위치/계층(parent_code), 중요도(A/B/C), 가동상태. + */ +@Data +public class MroEquipment { + private Long id; + private String equipmentCode; + private String equipmentName; + private String category; + private String parentCode; // 상위 설비(계층) + private String location; + private String deptCode; + private String manufacturer; + private String modelNo; + private String serialNo; + private LocalDate installDate; + private String criticality; // A / B / C + private String runStatus; // RUN / IDLE / DOWN / MAINT + private String attributes; // JSONB + private String status; // ACTIVE / INACTIVE + private String createdBy; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/backend/src/main/java/com/zioinfo/mro/equipment/mapper/EquipmentMapper.java b/backend/src/main/java/com/zioinfo/mro/equipment/mapper/EquipmentMapper.java new file mode 100644 index 0000000..9de1a12 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/equipment/mapper/EquipmentMapper.java @@ -0,0 +1,21 @@ +package com.zioinfo.mro.equipment.mapper; + +import com.zioinfo.mro.equipment.MroEquipment; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface EquipmentMapper { + List findAll(@Param("category") String category, + @Param("runStatus") String runStatus, + @Param("keyword") String keyword); + MroEquipment findById(@Param("id") Long id); + MroEquipment findByCode(@Param("equipmentCode") String equipmentCode); + int countByCode(@Param("equipmentCode") String equipmentCode, @Param("excludeId") Long excludeId); + int insert(MroEquipment e); + int update(MroEquipment e); + int updateRunStatus(@Param("id") Long id, @Param("runStatus") String runStatus); + int delete(@Param("id") Long id); +} diff --git a/backend/src/main/java/com/zioinfo/mro/inventory/InventoryController.java b/backend/src/main/java/com/zioinfo/mro/inventory/InventoryController.java new file mode 100644 index 0000000..e0f238c --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/inventory/InventoryController.java @@ -0,0 +1,49 @@ +package com.zioinfo.mro.inventory; + +import com.zioinfo.mro.common.ApiResponse; +import com.zioinfo.mro.common.AuthSupport; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * MRO 재고 API — 조회 Viewer+, upsert/수량 조정 Worker+. + */ +@RestController +@RequestMapping("/api/mro/inventory") +@RequiredArgsConstructor +public class InventoryController { + + private final InventoryService service; + + @GetMapping + public ApiResponse> list(@RequestParam(required = false) String materialCode, + @RequestParam(required = false) String locationCode, + @RequestParam(required = false) Boolean belowSafety) { + return ApiResponse.ok(service.list(materialCode, locationCode, belowSafety)); + } + + @GetMapping("/lookup") + public ApiResponse lookup(@RequestParam String materialCode, + @RequestParam(required = false) String locationCode) { + return ApiResponse.ok(service.findByMaterialLoc(materialCode, locationCode)); + } + + @GetMapping("/{id}") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.ok(service.get(id)); + } + + @PostMapping + public ApiResponse upsert(@RequestBody MroInventory inv, Authentication auth) { + return ApiResponse.ok(service.upsert(inv, AuthSupport.actor(auth))); + } + + @PutMapping("/{id}") + public ApiResponse adjust(@PathVariable Long id, @RequestBody MroInventory patch, + Authentication auth) { + return ApiResponse.ok(service.adjust(id, patch, AuthSupport.actor(auth))); + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/inventory/InventoryService.java b/backend/src/main/java/com/zioinfo/mro/inventory/InventoryService.java new file mode 100644 index 0000000..320dff9 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/inventory/InventoryService.java @@ -0,0 +1,73 @@ +package com.zioinfo.mro.inventory; + +import com.zioinfo.mro.admin.AuditService; +import com.zioinfo.mro.inventory.mapper.InventoryMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * MRO 재고 서비스 — 조회(Viewer+)·upsert/수량 조정(Worker+). + * + *

available_qty = on_hand_qty - allocated_qty 를 저장 시 자동 계산한다. + */ +@Service +@RequiredArgsConstructor +public class InventoryService { + + private final InventoryMapper mapper; + private final AuditService audit; + + public List list(String materialCode, String locationCode, Boolean belowSafety) { + return mapper.findAll(materialCode, locationCode, belowSafety); + } + + public MroInventory get(Long id) { + MroInventory inv = mapper.findById(id); + if (inv == null) throw new RuntimeException("ERR-INV-404: 재고 없음"); + return inv; + } + + public MroInventory findByMaterialLoc(String materialCode, String locationCode) { + if (materialCode == null || materialCode.isBlank()) + throw new IllegalArgumentException("ERR-INV-400: materialCode 필수"); + String loc = (locationCode == null || locationCode.isBlank()) ? "WH-MRO" : locationCode; + MroInventory inv = mapper.findByMaterialLoc(materialCode, loc); + if (inv == null) throw new RuntimeException("ERR-INV-404: 재고 없음"); + return inv; + } + + /** 재고 upsert (멱등, ON CONFLICT material_code+location_code) — Worker+. */ + public MroInventory upsert(MroInventory inv, String actor) { + if (inv.getMaterialCode() == null || inv.getMaterialCode().isBlank()) + throw new IllegalArgumentException("ERR-INV-400: materialCode 필수"); + if (inv.getLocationCode() == null || inv.getLocationCode().isBlank()) inv.setLocationCode("WH-MRO"); + if (inv.getOnHandQty() == null) inv.setOnHandQty(0d); + if (inv.getAllocatedQty() == null) inv.setAllocatedQty(0d); + if (inv.getSafetyStock() == null) inv.setSafetyStock(0d); + if (inv.getUnit() == null) inv.setUnit("EA"); + inv.setAvailableQty(inv.getOnHandQty() - inv.getAllocatedQty()); + mapper.upsert(inv); + audit.log(actor, "INVENTORY_UPSERT", inv.getMaterialCode(), + "loc=" + inv.getLocationCode() + " onHand=" + inv.getOnHandQty()); + return mapper.findByMaterialLoc(inv.getMaterialCode(), inv.getLocationCode()); + } + + /** 안전재고/수량 조정 (PUT /{id}) — Worker+. */ + public MroInventory adjust(Long id, MroInventory patch, String actor) { + MroInventory cur = get(id); + if (patch.getOnHandQty() != null) cur.setOnHandQty(patch.getOnHandQty()); + if (patch.getAllocatedQty() != null) cur.setAllocatedQty(patch.getAllocatedQty()); + if (patch.getSafetyStock() != null) cur.setSafetyStock(patch.getSafetyStock()); + if (patch.getUnit() != null) cur.setUnit(patch.getUnit()); + if (patch.getLastCountDate() != null) cur.setLastCountDate(patch.getLastCountDate()); + double onHand = cur.getOnHandQty() == null ? 0d : cur.getOnHandQty(); + double alloc = cur.getAllocatedQty() == null ? 0d : cur.getAllocatedQty(); + cur.setAvailableQty(onHand - alloc); + mapper.update(cur); + audit.log(actor, "INVENTORY_ADJUST", cur.getMaterialCode(), + "id=" + id + " onHand=" + cur.getOnHandQty() + " safety=" + cur.getSafetyStock()); + return mapper.findById(id); + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/inventory/MroInventory.java b/backend/src/main/java/com/zioinfo/mro/inventory/MroInventory.java new file mode 100644 index 0000000..6b9e1c1 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/inventory/MroInventory.java @@ -0,0 +1,24 @@ +package com.zioinfo.mro.inventory; + +import lombok.Data; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * MRO 재고 (mro_inventory) — 자재/로케이션별 재고 현황. (material_code+location_code 유니크) + * + *

available_qty = on_hand_qty - allocated_qty 는 서비스에서 자동 계산한다. + */ +@Data +public class MroInventory { + private Long id; + private String materialCode; + private String locationCode; // default 'WH-MRO' + private Double onHandQty; + private Double allocatedQty; + private Double availableQty; + private Double safetyStock; + private String unit; + private LocalDate lastCountDate; + private LocalDateTime updatedAt; +} diff --git a/backend/src/main/java/com/zioinfo/mro/inventory/mapper/InventoryMapper.java b/backend/src/main/java/com/zioinfo/mro/inventory/mapper/InventoryMapper.java new file mode 100644 index 0000000..0747a10 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/inventory/mapper/InventoryMapper.java @@ -0,0 +1,19 @@ +package com.zioinfo.mro.inventory.mapper; + +import com.zioinfo.mro.inventory.MroInventory; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface InventoryMapper { + List findAll(@Param("materialCode") String materialCode, + @Param("locationCode") String locationCode, + @Param("belowSafety") Boolean belowSafety); + MroInventory findById(@Param("id") Long id); + MroInventory findByMaterialLoc(@Param("materialCode") String materialCode, + @Param("locationCode") String locationCode); + int upsert(MroInventory inv); + int update(MroInventory inv); +} diff --git a/backend/src/main/java/com/zioinfo/mro/inventorytxn/InventoryTxnController.java b/backend/src/main/java/com/zioinfo/mro/inventorytxn/InventoryTxnController.java new file mode 100644 index 0000000..f9ebcc7 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/inventorytxn/InventoryTxnController.java @@ -0,0 +1,37 @@ +package com.zioinfo.mro.inventorytxn; + +import com.zioinfo.mro.common.ApiResponse; +import com.zioinfo.mro.common.AuthSupport; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * MRO 입출고 API — 조회 Viewer+, 입출고 등록 Worker+(재고 반영). + */ +@RestController +@RequestMapping("/api/mro/inventory-txns") +@RequiredArgsConstructor +public class InventoryTxnController { + + private final InventoryTxnService service; + + @GetMapping + public ApiResponse> list(@RequestParam(required = false) String materialCode, + @RequestParam(required = false) String txnType, + @RequestParam(required = false) String keyword) { + return ApiResponse.ok(service.list(materialCode, txnType, keyword)); + } + + @GetMapping("/{id}") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.ok(service.get(id)); + } + + @PostMapping + public ApiResponse post(@RequestBody MroInventoryTxn txn, Authentication auth) { + return ApiResponse.ok(service.post(txn, AuthSupport.actor(auth))); + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/inventorytxn/InventoryTxnService.java b/backend/src/main/java/com/zioinfo/mro/inventorytxn/InventoryTxnService.java new file mode 100644 index 0000000..89f7145 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/inventorytxn/InventoryTxnService.java @@ -0,0 +1,79 @@ +package com.zioinfo.mro.inventorytxn; + +import com.zioinfo.mro.admin.AuditService; +import com.zioinfo.mro.inventorytxn.mapper.InventoryTxnMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Set; + +/** + * MRO 입출고 서비스 — 트랜잭션 등록(Worker+)과 동시에 mro_inventory 재고를 반영. + * + *

부호 규칙: IN/RETURN=+qty, OUT=-qty, ADJUST=요청 qty 그대로, MOVE=재고 미반영(단순 기록). + */ +@Service +@RequiredArgsConstructor +public class InventoryTxnService { + + private static final Set TXN_TYPES = Set.of("IN", "OUT", "ADJUST", "MOVE", "RETURN"); + + private final InventoryTxnMapper mapper; + private final AuditService audit; + + public List list(String materialCode, String txnType, String keyword) { + return mapper.findAll(materialCode, txnType, keyword); + } + + public MroInventoryTxn get(Long id) { + MroInventoryTxn txn = mapper.findById(id); + if (txn == null) throw new RuntimeException("ERR-TXN-404: 입출고 이력 없음"); + return txn; + } + + /** 입출고 등록 — 재고 반영과 함께 트랜잭션 처리(Worker+). */ + @Transactional + public MroInventoryTxn post(MroInventoryTxn txn, String actor) { + if (txn.getMaterialCode() == null || txn.getMaterialCode().isBlank()) + throw new IllegalArgumentException("ERR-TXN-400: materialCode 필수"); + String type = txn.getTxnType() == null ? "" : txn.getTxnType().trim().toUpperCase(); + if (!TXN_TYPES.contains(type)) + throw new IllegalArgumentException("ERR-TXN-400: txnType 은 IN/OUT/ADJUST/MOVE/RETURN"); + if (txn.getQty() == null) + throw new IllegalArgumentException("ERR-TXN-400: qty 필수"); + txn.setTxnType(type); + if (txn.getLocationCode() == null || txn.getLocationCode().isBlank()) txn.setLocationCode("WH-MRO"); + if (txn.getUnit() == null) txn.setUnit("EA"); + txn.setTxnNo("TXN-" + System.currentTimeMillis()); + txn.setTxnBy(actor); + + double qty = txn.getQty(); + double delta; + switch (type) { + case "IN": + case "RETURN": + delta = Math.abs(qty); + break; + case "OUT": + delta = -Math.abs(qty); + break; + case "ADJUST": + delta = qty; + break; + default: // MOVE — 단순 기록, 재고 미반영 + delta = 0d; + } + + if (delta != 0d) { + mapper.applyStockDelta(txn.getMaterialCode(), txn.getLocationCode(), delta); + } + Double balance = mapper.currentOnHand(txn.getMaterialCode(), txn.getLocationCode()); + txn.setBalanceAfter(balance); + mapper.insert(txn); + audit.log(actor, "INVENTORY_TXN", txn.getMaterialCode(), + type + " qty=" + qty + " balance=" + balance); + return mapper.findById(txn.getId()); + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/inventorytxn/MroInventoryTxn.java b/backend/src/main/java/com/zioinfo/mro/inventorytxn/MroInventoryTxn.java new file mode 100644 index 0000000..a65b7cc --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/inventorytxn/MroInventoryTxn.java @@ -0,0 +1,27 @@ +package com.zioinfo.mro.inventorytxn; + +import lombok.Data; +import java.time.LocalDateTime; + +/** + * MRO 입출고 트랜잭션 (mro_inventory_txn) — 입고/출고/조정/이동/반품 이력. + * + *

txnType: IN/OUT/ADJUST/MOVE/RETURN. 등록과 동시에 mro_inventory 재고를 반영한다. + */ +@Data +public class MroInventoryTxn { + private Long id; + private String txnNo; + private String materialCode; + private String locationCode; + private String txnType; + private Double qty; + private String unit; + private String refType; // PO/WO/STOCKTAKE/MANUAL + private String refNo; + private String woNo; + private String reason; + private Double balanceAfter; + private String txnBy; + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/com/zioinfo/mro/inventorytxn/mapper/InventoryTxnMapper.java b/backend/src/main/java/com/zioinfo/mro/inventorytxn/mapper/InventoryTxnMapper.java new file mode 100644 index 0000000..c1fc3ee --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/inventorytxn/mapper/InventoryTxnMapper.java @@ -0,0 +1,25 @@ +package com.zioinfo.mro.inventorytxn.mapper; + +import com.zioinfo.mro.inventorytxn.MroInventoryTxn; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface InventoryTxnMapper { + List findAll(@Param("materialCode") String materialCode, + @Param("txnType") String txnType, + @Param("keyword") String keyword); + MroInventoryTxn findById(@Param("id") Long id); + int insert(MroInventoryTxn txn); + + /** mro_inventory 재고 증감(멱등 upsert) — on_hand/available 에 delta 반영. */ + int applyStockDelta(@Param("materialCode") String materialCode, + @Param("locationCode") String locationCode, + @Param("delta") Double delta); + + /** 반영 후 on_hand 잔량 조회(balance_after 산출용). */ + Double currentOnHand(@Param("materialCode") String materialCode, + @Param("locationCode") String locationCode); +} diff --git a/backend/src/main/java/com/zioinfo/mro/maintenance/MaintenanceController.java b/backend/src/main/java/com/zioinfo/mro/maintenance/MaintenanceController.java new file mode 100644 index 0000000..5933f15 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/maintenance/MaintenanceController.java @@ -0,0 +1,50 @@ +package com.zioinfo.mro.maintenance; + +import com.zioinfo.mro.common.ApiResponse; +import com.zioinfo.mro.common.AuthSupport; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 정비이력 API — 조회 Viewer+, 생성/수정 Worker+, 삭제 Manager+. + */ +@RestController +@RequestMapping("/api/mro/maintenance-history") +@RequiredArgsConstructor +public class MaintenanceController { + + private final MaintenanceService service; + + @GetMapping + public ApiResponse> list(@RequestParam(required = false) String equipmentCode, + @RequestParam(required = false) String maintType, + @RequestParam(required = false) String keyword) { + return ApiResponse.ok(service.list(equipmentCode, maintType, keyword)); + } + + @GetMapping("/{id}") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.ok(service.get(id)); + } + + @PostMapping + public ApiResponse create(@RequestBody MroMaintenanceHistory m, Authentication auth) { + return ApiResponse.ok(service.create(m, AuthSupport.actor(auth))); + } + + @PutMapping("/{id}") + public ApiResponse update(@PathVariable Long id, @RequestBody MroMaintenanceHistory m, + Authentication auth) { + return ApiResponse.ok(service.update(id, m, AuthSupport.actor(auth))); + } + + @DeleteMapping("/{id}") + public ApiResponse delete(@PathVariable Long id, Authentication auth) { + AuthSupport.requireManager(auth); + service.delete(id, AuthSupport.actor(auth)); + return ApiResponse.ok(null); + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/maintenance/MaintenanceService.java b/backend/src/main/java/com/zioinfo/mro/maintenance/MaintenanceService.java new file mode 100644 index 0000000..abc79fe --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/maintenance/MaintenanceService.java @@ -0,0 +1,75 @@ +package com.zioinfo.mro.maintenance; + +import com.zioinfo.mro.admin.AuditService; +import com.zioinfo.mro.maintenance.mapper.MaintenanceMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.List; +import java.util.Set; + +/** + * 정비이력 서비스 — CRUD. fault_at/repaired_at 둘 다 있으면 repair_min 자동계산(분). + */ +@Service +@RequiredArgsConstructor +public class MaintenanceService { + + private static final Set MAINT_TYPES = Set.of("FAULT", "REPAIR", "PM"); + + private final MaintenanceMapper mapper; + private final AuditService audit; + + public List list(String equipmentCode, String maintType, String keyword) { + return mapper.findAll(equipmentCode, maintType, keyword); + } + + public MroMaintenanceHistory get(Long id) { + MroMaintenanceHistory m = mapper.findById(id); + if (m == null) throw new RuntimeException("ERR-MNT-404: 정비이력 없음"); + return m; + } + + public MroMaintenanceHistory create(MroMaintenanceHistory m, String actor) { + validate(m); + if (m.getMaintType() == null) m.setMaintType("REPAIR"); + computeRepairMin(m); + m.setCreatedBy(actor); + mapper.insert(m); + audit.log(actor, "MAINTENANCE_CREATE", m.getEquipmentCode(), "type=" + m.getMaintType()); + return mapper.findById(m.getId()); + } + + public MroMaintenanceHistory update(Long id, MroMaintenanceHistory m, String actor) { + MroMaintenanceHistory cur = get(id); + validate(m); + if (m.getMaintType() == null) m.setMaintType(cur.getMaintType()); + computeRepairMin(m); + m.setId(id); + mapper.update(m); + audit.log(actor, "MAINTENANCE_UPDATE", cur.getEquipmentCode(), "id=" + id); + return mapper.findById(id); + } + + public void delete(Long id, String actor) { + MroMaintenanceHistory cur = get(id); + mapper.delete(id); + audit.log(actor, "MAINTENANCE_DELETE", cur.getEquipmentCode(), "id=" + id); + } + + private void validate(MroMaintenanceHistory m) { + if (m.getEquipmentCode() == null || m.getEquipmentCode().isBlank()) + throw new IllegalArgumentException("ERR-MNT-400: equipmentCode 필수"); + if (m.getMaintType() != null && !MAINT_TYPES.contains(m.getMaintType().toUpperCase())) + throw new IllegalArgumentException("ERR-MNT-400: maintType 은 FAULT/REPAIR/PM"); + } + + /** fault_at/repaired_at 둘 다 있으면 repair_min 자동계산(분). */ + private void computeRepairMin(MroMaintenanceHistory m) { + if (m.getFaultAt() != null && m.getRepairedAt() != null) { + long minutes = Duration.between(m.getFaultAt(), m.getRepairedAt()).toMinutes(); + m.setRepairMin((double) minutes); + } + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/maintenance/MroMaintenanceHistory.java b/backend/src/main/java/com/zioinfo/mro/maintenance/MroMaintenanceHistory.java new file mode 100644 index 0000000..4f8adf5 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/maintenance/MroMaintenanceHistory.java @@ -0,0 +1,27 @@ +package com.zioinfo.mro.maintenance; + +import lombok.Data; +import java.time.LocalDateTime; + +/** + * 정비이력 (mro_maintenance_history) — 고장/정비/PM 이력. fault_at·repaired_at 로 repair_min 자동계산. + */ +@Data +public class MroMaintenanceHistory { + private Long id; + private String equipmentCode; + private String woNo; + private String maintType; // FAULT/REPAIR/PM + private String symptom; + private String cause; + private String action; + private String partUsed; + private LocalDateTime faultAt; + private LocalDateTime repairedAt; + private Double repairMin; + private Double downtimeMin; + private Double cost; + private String technician; + private String createdBy; + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/com/zioinfo/mro/maintenance/mapper/MaintenanceMapper.java b/backend/src/main/java/com/zioinfo/mro/maintenance/mapper/MaintenanceMapper.java new file mode 100644 index 0000000..6952434 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/maintenance/mapper/MaintenanceMapper.java @@ -0,0 +1,18 @@ +package com.zioinfo.mro.maintenance.mapper; + +import com.zioinfo.mro.maintenance.MroMaintenanceHistory; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface MaintenanceMapper { + List findAll(@Param("equipmentCode") String equipmentCode, + @Param("maintType") String maintType, + @Param("keyword") String keyword); + MroMaintenanceHistory findById(@Param("id") Long id); + int insert(MroMaintenanceHistory m); + int update(MroMaintenanceHistory m); + int delete(@Param("id") Long id); +} diff --git a/backend/src/main/java/com/zioinfo/mro/material/MaterialController.java b/backend/src/main/java/com/zioinfo/mro/material/MaterialController.java new file mode 100644 index 0000000..8154f23 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/material/MaterialController.java @@ -0,0 +1,51 @@ +package com.zioinfo.mro.material; + +import com.zioinfo.mro.common.ApiResponse; +import com.zioinfo.mro.common.AuthSupport; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * MRO 자재 마스터 API. 조회 Viewer+, 생성/수정/삭제 Manager+(기준정보 가드). + */ +@RestController +@RequestMapping("/api/mro/materials") +@RequiredArgsConstructor +public class MaterialController { + + private final MaterialService service; + + @GetMapping + public ApiResponse> list(@RequestParam(required = false) String materialType, + @RequestParam(required = false) String status, + @RequestParam(required = false) String keyword) { + return ApiResponse.ok(service.list(materialType, status, keyword)); + } + + @GetMapping("/{id}") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.ok(service.get(id)); + } + + @PostMapping + public ApiResponse create(@RequestBody MroMaterial m, Authentication auth) { + AuthSupport.requireManager(auth); + return ApiResponse.ok(service.create(m, AuthSupport.actor(auth))); + } + + @PutMapping("/{id}") + public ApiResponse update(@PathVariable Long id, @RequestBody MroMaterial m, Authentication auth) { + AuthSupport.requireManager(auth); + return ApiResponse.ok(service.update(id, m, AuthSupport.actor(auth))); + } + + @DeleteMapping("/{id}") + public ApiResponse delete(@PathVariable Long id, Authentication auth) { + AuthSupport.requireManager(auth); + service.delete(id); + return ApiResponse.ok(null); + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/material/MaterialService.java b/backend/src/main/java/com/zioinfo/mro/material/MaterialService.java new file mode 100644 index 0000000..9570ef6 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/material/MaterialService.java @@ -0,0 +1,73 @@ +package com.zioinfo.mro.material; + +import com.zioinfo.mro.admin.AuditService; +import com.zioinfo.mro.material.mapper.MaterialMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Set; + +/** + * MRO 자재 마스터 서비스 — 기준정보(생성/수정/삭제는 컨트롤러 MANAGER+ 가드 병행). + */ +@Service +@RequiredArgsConstructor +public class MaterialService { + + private static final Set MATERIAL_TYPE = Set.of("SPARE", "TOOL", "CONSUMABLE", "PART"); + + private final MaterialMapper mapper; + private final AuditService audit; + + public List list(String materialType, String status, String keyword) { + return mapper.findAll(materialType, status, keyword); + } + + public MroMaterial get(Long id) { + MroMaterial m = mapper.findById(id); + if (m == null) throw new RuntimeException("ERR-MAT-404: 자재 없음"); + return m; + } + + public MroMaterial create(MroMaterial m, String actor) { + validate(m); + if (mapper.countByCode(m.getMaterialCode(), null) > 0) { + throw new RuntimeException("ERR-MAT-409: 중복 자재코드"); + } + if (m.getStatus() == null) m.setStatus("ACTIVE"); + if (m.getMaterialType() == null) m.setMaterialType("SPARE"); + if (m.getUnit() == null) m.setUnit("EA"); + m.setCreatedBy(actor); + mapper.insert(m); + audit.log("MATERIAL_CREATE", m.getMaterialCode(), "type=" + m.getMaterialType()); + return mapper.findById(m.getId()); + } + + public MroMaterial update(Long id, MroMaterial m, String actor) { + MroMaterial cur = get(id); + validate(m); + if (mapper.countByCode(m.getMaterialCode(), id) > 0) { + throw new RuntimeException("ERR-MAT-409: 중복 자재코드"); + } + m.setId(id); + mapper.update(m); + audit.log("MATERIAL_UPDATE", cur.getMaterialCode(), "id=" + id); + return mapper.findById(id); + } + + public void delete(Long id) { + MroMaterial cur = get(id); + mapper.delete(id); + audit.log("MATERIAL_DELETE", cur.getMaterialCode(), "id=" + id); + } + + private void validate(MroMaterial m) { + if (m.getMaterialCode() == null || m.getMaterialCode().isBlank()) + throw new IllegalArgumentException("ERR-MAT-400: materialCode 필수"); + if (m.getMaterialName() == null || m.getMaterialName().isBlank()) + throw new IllegalArgumentException("ERR-MAT-400: materialName 필수"); + if (m.getMaterialType() != null && !MATERIAL_TYPE.contains(m.getMaterialType().toUpperCase())) + throw new IllegalArgumentException("ERR-MAT-400: materialType 은 SPARE/TOOL/CONSUMABLE/PART"); + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/material/MroMaterial.java b/backend/src/main/java/com/zioinfo/mro/material/MroMaterial.java new file mode 100644 index 0000000..337ea0c --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/material/MroMaterial.java @@ -0,0 +1,31 @@ +package com.zioinfo.mro.material; + +import lombok.Data; +import java.time.LocalDateTime; + +/** + * MRO 자재 마스터 (mro_material) — 예비품/공구/소모품/부품, 안전재고·재발주점·리드타임. + */ +@Data +public class MroMaterial { + private Long id; + private String materialCode; + private String materialName; + private String materialType; // SPARE / TOOL / CONSUMABLE / PART + private String category; + private String unit; + private String spec; + private String manufacturer; + private String barcode; + private Double unitPrice; + private Double safetyStock; + private Double reorderPoint; + private Integer leadTimeDays; + private String locationCode; + private String partnerCode; + private String attributes; // JSONB + private String status; // ACTIVE / INACTIVE + private String createdBy; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/backend/src/main/java/com/zioinfo/mro/material/mapper/MaterialMapper.java b/backend/src/main/java/com/zioinfo/mro/material/mapper/MaterialMapper.java new file mode 100644 index 0000000..68a9c3b --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/material/mapper/MaterialMapper.java @@ -0,0 +1,20 @@ +package com.zioinfo.mro.material.mapper; + +import com.zioinfo.mro.material.MroMaterial; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface MaterialMapper { + List findAll(@Param("materialType") String materialType, + @Param("status") String status, + @Param("keyword") String keyword); + MroMaterial findById(@Param("id") Long id); + MroMaterial findByCode(@Param("materialCode") String materialCode); + int countByCode(@Param("materialCode") String materialCode, @Param("excludeId") Long excludeId); + int insert(MroMaterial m); + int update(MroMaterial m); + int delete(@Param("id") Long id); +} diff --git a/backend/src/main/java/com/zioinfo/mro/materialbom/MaterialBomController.java b/backend/src/main/java/com/zioinfo/mro/materialbom/MaterialBomController.java new file mode 100644 index 0000000..a86068c --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/materialbom/MaterialBomController.java @@ -0,0 +1,50 @@ +package com.zioinfo.mro.materialbom; + +import com.zioinfo.mro.common.ApiResponse; +import com.zioinfo.mro.common.AuthSupport; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 자재 BOM API. 조회 Viewer+, 생성/수정/삭제 Manager+(기준정보 가드). + */ +@RestController +@RequestMapping("/api/mro/material-boms") +@RequiredArgsConstructor +public class MaterialBomController { + + private final MaterialBomService service; + + @GetMapping + public ApiResponse> list(@RequestParam(required = false) String equipmentCode, + @RequestParam(required = false) String materialCode) { + return ApiResponse.ok(service.list(equipmentCode, materialCode)); + } + + @GetMapping("/{id}") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.ok(service.get(id)); + } + + @PostMapping + public ApiResponse create(@RequestBody MroMaterialBom b, Authentication auth) { + AuthSupport.requireManager(auth); + return ApiResponse.ok(service.create(b, AuthSupport.actor(auth))); + } + + @PutMapping("/{id}") + public ApiResponse update(@PathVariable Long id, @RequestBody MroMaterialBom b, Authentication auth) { + AuthSupport.requireManager(auth); + return ApiResponse.ok(service.update(id, b, AuthSupport.actor(auth))); + } + + @DeleteMapping("/{id}") + public ApiResponse delete(@PathVariable Long id, Authentication auth) { + AuthSupport.requireManager(auth); + service.delete(id); + return ApiResponse.ok(null); + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/materialbom/MaterialBomService.java b/backend/src/main/java/com/zioinfo/mro/materialbom/MaterialBomService.java new file mode 100644 index 0000000..f983ef5 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/materialbom/MaterialBomService.java @@ -0,0 +1,62 @@ +package com.zioinfo.mro.materialbom; + +import com.zioinfo.mro.admin.AuditService; +import com.zioinfo.mro.materialbom.mapper.MaterialBomMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * 자재 BOM 서비스 — 설비-자재 소요관계 기준정보(생성/수정/삭제는 컨트롤러 MANAGER+ 가드 병행). + */ +@Service +@RequiredArgsConstructor +public class MaterialBomService { + + private final MaterialBomMapper mapper; + private final AuditService audit; + + public List list(String equipmentCode, String materialCode) { + return mapper.findAll(equipmentCode, materialCode); + } + + public MroMaterialBom get(Long id) { + MroMaterialBom b = mapper.findById(id); + if (b == null) throw new RuntimeException("ERR-MBOM-404: BOM 없음"); + return b; + } + + public MroMaterialBom create(MroMaterialBom b, String actor) { + validate(b); + if (b.getStatus() == null) b.setStatus("ACTIVE"); + if (b.getUnit() == null) b.setUnit("EA"); + if (b.getQtyPer() == null) b.setQtyPer(1.0); + b.setCreatedBy(actor); + mapper.insert(b); + audit.log("MATERIAL_BOM_CREATE", b.getEquipmentCode(), "material=" + b.getMaterialCode()); + return mapper.findById(b.getId()); + } + + public MroMaterialBom update(Long id, MroMaterialBom b, String actor) { + MroMaterialBom cur = get(id); + validate(b); + b.setId(id); + mapper.update(b); + audit.log("MATERIAL_BOM_UPDATE", cur.getEquipmentCode(), "id=" + id); + return mapper.findById(id); + } + + public void delete(Long id) { + MroMaterialBom cur = get(id); + mapper.delete(id); + audit.log("MATERIAL_BOM_DELETE", cur.getEquipmentCode(), "id=" + id); + } + + private void validate(MroMaterialBom b) { + if (b.getEquipmentCode() == null || b.getEquipmentCode().isBlank()) + throw new IllegalArgumentException("ERR-MBOM-400: equipmentCode 필수"); + if (b.getMaterialCode() == null || b.getMaterialCode().isBlank()) + throw new IllegalArgumentException("ERR-MBOM-400: materialCode 필수"); + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/materialbom/MroMaterialBom.java b/backend/src/main/java/com/zioinfo/mro/materialbom/MroMaterialBom.java new file mode 100644 index 0000000..c7c9e48 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/materialbom/MroMaterialBom.java @@ -0,0 +1,21 @@ +package com.zioinfo.mro.materialbom; + +import lombok.Data; +import java.time.LocalDateTime; + +/** + * 자재 BOM (mro_material_bom) — 설비별 소요 부품/자재. + */ +@Data +public class MroMaterialBom { + private Long id; + private String equipmentCode; + private String materialCode; + private Double qtyPer; + private String unit; + private String position; + private String remark; + private String status; // ACTIVE / INACTIVE + private String createdBy; + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/com/zioinfo/mro/materialbom/mapper/MaterialBomMapper.java b/backend/src/main/java/com/zioinfo/mro/materialbom/mapper/MaterialBomMapper.java new file mode 100644 index 0000000..e99467f --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/materialbom/mapper/MaterialBomMapper.java @@ -0,0 +1,17 @@ +package com.zioinfo.mro.materialbom.mapper; + +import com.zioinfo.mro.materialbom.MroMaterialBom; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface MaterialBomMapper { + List findAll(@Param("equipmentCode") String equipmentCode, + @Param("materialCode") String materialCode); + MroMaterialBom findById(@Param("id") Long id); + int insert(MroMaterialBom b); + int update(MroMaterialBom b); + int delete(@Param("id") Long id); +} diff --git a/backend/src/main/java/com/zioinfo/mro/partner/MroPartner.java b/backend/src/main/java/com/zioinfo/mro/partner/MroPartner.java new file mode 100644 index 0000000..7f6a098 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/partner/MroPartner.java @@ -0,0 +1,34 @@ +package com.zioinfo.mro.partner; + +import lombok.Data; +import java.time.LocalDateTime; + +/** + * 거래처 마스터 (mro_partner) — 공급사/벤더/서비스사. PII(연락처·이메일) AES-256-GCM 암호화 저장. + * + *

{@code contact}/{@code email}은 입출력용 transient 평문 필드. 저장 시 암호화하여 + * {@code contactEnc}/{@code emailEnc}에 넣고, 조회 응답 시 복호→마스킹하여 평문 필드에만 세팅하고 + * enc 필드는 null로 비워 반환한다(원문 enc·평문 미노출). + */ +@Data +public class MroPartner { + private Long id; + private String partnerCode; + private String partnerName; + private String partnerType; // SUPPLIER / VENDOR / SERVICE + private String bizNo; + private String managerName; + private String contactEnc; // 암호화 저장 컬럼(응답 시 null) + private String emailEnc; // 암호화 저장 컬럼(응답 시 null) + private String address; + private String paymentTerms; + private String rating; + private String status; // ACTIVE / INACTIVE + private String createdBy; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + // 입출력용 평문(transient) — DB 컬럼 아님 + private transient String contact; + private transient String email; +} diff --git a/backend/src/main/java/com/zioinfo/mro/partner/PartnerController.java b/backend/src/main/java/com/zioinfo/mro/partner/PartnerController.java new file mode 100644 index 0000000..c86843f --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/partner/PartnerController.java @@ -0,0 +1,51 @@ +package com.zioinfo.mro.partner; + +import com.zioinfo.mro.common.ApiResponse; +import com.zioinfo.mro.common.AuthSupport; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 거래처 API. 조회 Viewer+, 생성/수정/삭제 Manager+(기준정보 가드). PII는 마스킹 응답. + */ +@RestController +@RequestMapping("/api/mro/partners") +@RequiredArgsConstructor +public class PartnerController { + + private final PartnerService service; + + @GetMapping + public ApiResponse> list(@RequestParam(required = false) String partnerType, + @RequestParam(required = false) String status, + @RequestParam(required = false) String keyword) { + return ApiResponse.ok(service.list(partnerType, status, keyword)); + } + + @GetMapping("/{id}") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.ok(service.get(id)); + } + + @PostMapping + public ApiResponse create(@RequestBody MroPartner p, Authentication auth) { + AuthSupport.requireManager(auth); + return ApiResponse.ok(service.create(p, AuthSupport.actor(auth))); + } + + @PutMapping("/{id}") + public ApiResponse update(@PathVariable Long id, @RequestBody MroPartner p, Authentication auth) { + AuthSupport.requireManager(auth); + return ApiResponse.ok(service.update(id, p, AuthSupport.actor(auth))); + } + + @DeleteMapping("/{id}") + public ApiResponse delete(@PathVariable Long id, Authentication auth) { + AuthSupport.requireManager(auth); + service.delete(id); + return ApiResponse.ok(null); + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/partner/PartnerService.java b/backend/src/main/java/com/zioinfo/mro/partner/PartnerService.java new file mode 100644 index 0000000..02b6284 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/partner/PartnerService.java @@ -0,0 +1,92 @@ +package com.zioinfo.mro.partner; + +import com.zioinfo.mro.admin.AuditService; +import com.zioinfo.mro.common.CryptoUtil; +import com.zioinfo.mro.partner.mapper.PartnerMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Set; + +/** + * 거래처 서비스 — PII(연락처·이메일) AES-256-GCM 암호화 저장, 조회 시 복호→마스킹 후 enc 필드 제거. + */ +@Service +@RequiredArgsConstructor +public class PartnerService { + + private static final Set PARTNER_TYPE = Set.of("SUPPLIER", "VENDOR", "SERVICE"); + + private final PartnerMapper mapper; + private final AuditService audit; + private final CryptoUtil cryptoUtil; + + public List list(String partnerType, String status, String keyword) { + List rows = mapper.findAll(partnerType, status, keyword); + rows.forEach(this::maskForResponse); + return rows; + } + + public MroPartner get(Long id) { + MroPartner p = mapper.findById(id); + if (p == null) throw new RuntimeException("ERR-PTN-404: 거래처 없음"); + maskForResponse(p); + return p; + } + + public MroPartner create(MroPartner p, String actor) { + validate(p); + if (mapper.countByCode(p.getPartnerCode(), null) > 0) { + throw new RuntimeException("ERR-PTN-409: 중복 거래처코드"); + } + if (p.getStatus() == null) p.setStatus("ACTIVE"); + if (p.getPartnerType() == null) p.setPartnerType("SUPPLIER"); + p.setContactEnc(cryptoUtil.encrypt(p.getContact())); + p.setEmailEnc(cryptoUtil.encrypt(p.getEmail())); + p.setCreatedBy(actor); + mapper.insert(p); + audit.log("PARTNER_CREATE", p.getPartnerCode(), "type=" + p.getPartnerType()); + return get(p.getId()); + } + + public MroPartner update(Long id, MroPartner p, String actor) { + MroPartner cur = mapper.findById(id); + if (cur == null) throw new RuntimeException("ERR-PTN-404: 거래처 없음"); + validate(p); + if (mapper.countByCode(p.getPartnerCode(), id) > 0) { + throw new RuntimeException("ERR-PTN-409: 중복 거래처코드"); + } + p.setId(id); + p.setContactEnc(cryptoUtil.encrypt(p.getContact())); + p.setEmailEnc(cryptoUtil.encrypt(p.getEmail())); + mapper.update(p); + audit.log("PARTNER_UPDATE", cur.getPartnerCode(), "id=" + id); + return get(id); + } + + public void delete(Long id) { + MroPartner cur = mapper.findById(id); + if (cur == null) throw new RuntimeException("ERR-PTN-404: 거래처 없음"); + mapper.delete(id); + audit.log("PARTNER_DELETE", cur.getPartnerCode(), "id=" + id); + } + + /** 응답용: enc 복호→마스킹하여 평문 필드에 세팅하고 enc 필드는 제거. */ + private void maskForResponse(MroPartner p) { + if (p == null) return; + p.setContact(CryptoUtil.mask(cryptoUtil.decrypt(p.getContactEnc()))); + p.setEmail(CryptoUtil.mask(cryptoUtil.decrypt(p.getEmailEnc()))); + p.setContactEnc(null); + p.setEmailEnc(null); + } + + private void validate(MroPartner p) { + if (p.getPartnerCode() == null || p.getPartnerCode().isBlank()) + throw new IllegalArgumentException("ERR-PTN-400: partnerCode 필수"); + if (p.getPartnerName() == null || p.getPartnerName().isBlank()) + throw new IllegalArgumentException("ERR-PTN-400: partnerName 필수"); + if (p.getPartnerType() != null && !PARTNER_TYPE.contains(p.getPartnerType().toUpperCase())) + throw new IllegalArgumentException("ERR-PTN-400: partnerType 은 SUPPLIER/VENDOR/SERVICE"); + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/partner/mapper/PartnerMapper.java b/backend/src/main/java/com/zioinfo/mro/partner/mapper/PartnerMapper.java new file mode 100644 index 0000000..f10d5bb --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/partner/mapper/PartnerMapper.java @@ -0,0 +1,20 @@ +package com.zioinfo.mro.partner.mapper; + +import com.zioinfo.mro.partner.MroPartner; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface PartnerMapper { + List findAll(@Param("partnerType") String partnerType, + @Param("status") String status, + @Param("keyword") String keyword); + MroPartner findById(@Param("id") Long id); + MroPartner findByCode(@Param("partnerCode") String partnerCode); + int countByCode(@Param("partnerCode") String partnerCode, @Param("excludeId") Long excludeId); + int insert(MroPartner p); + int update(MroPartner p); + int delete(@Param("id") Long id); +} diff --git a/backend/src/main/java/com/zioinfo/mro/pmplan/MroPmPlan.java b/backend/src/main/java/com/zioinfo/mro/pmplan/MroPmPlan.java new file mode 100644 index 0000000..563542c --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/pmplan/MroPmPlan.java @@ -0,0 +1,27 @@ +package com.zioinfo.mro.pmplan; + +import lombok.Data; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 예방보전(PM) 계획 (mro_pm_plan) — TBM/CBM/INSPECTION, 주기·차기 예정일·표준 작업시간. + */ +@Data +public class MroPmPlan { + private Long id; + private String planCode; + private String planName; + private String equipmentCode; + private String pmType; // TBM / CBM / INSPECTION + private Integer cycleDays; + private LocalDate lastDoneDate; + private LocalDate nextDueDate; + private String taskDesc; + private Double standardTimeMin; + private String assignee; + private String status; // ACTIVE / INACTIVE + private String createdBy; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/backend/src/main/java/com/zioinfo/mro/pmplan/PmPlanController.java b/backend/src/main/java/com/zioinfo/mro/pmplan/PmPlanController.java new file mode 100644 index 0000000..45c1e02 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/pmplan/PmPlanController.java @@ -0,0 +1,51 @@ +package com.zioinfo.mro.pmplan; + +import com.zioinfo.mro.common.ApiResponse; +import com.zioinfo.mro.common.AuthSupport; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 예방보전(PM) 계획 API. 조회 Viewer+, 생성/수정/삭제 Manager+(기준정보 가드). + */ +@RestController +@RequestMapping("/api/mro/pm-plans") +@RequiredArgsConstructor +public class PmPlanController { + + private final PmPlanService service; + + @GetMapping + public ApiResponse> list(@RequestParam(required = false) String equipmentCode, + @RequestParam(required = false) String pmType, + @RequestParam(required = false) String status) { + return ApiResponse.ok(service.list(equipmentCode, pmType, status)); + } + + @GetMapping("/{id}") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.ok(service.get(id)); + } + + @PostMapping + public ApiResponse create(@RequestBody MroPmPlan p, Authentication auth) { + AuthSupport.requireManager(auth); + return ApiResponse.ok(service.create(p, AuthSupport.actor(auth))); + } + + @PutMapping("/{id}") + public ApiResponse update(@PathVariable Long id, @RequestBody MroPmPlan p, Authentication auth) { + AuthSupport.requireManager(auth); + return ApiResponse.ok(service.update(id, p, AuthSupport.actor(auth))); + } + + @DeleteMapping("/{id}") + public ApiResponse delete(@PathVariable Long id, Authentication auth) { + AuthSupport.requireManager(auth); + service.delete(id); + return ApiResponse.ok(null); + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/pmplan/PmPlanService.java b/backend/src/main/java/com/zioinfo/mro/pmplan/PmPlanService.java new file mode 100644 index 0000000..18f650c --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/pmplan/PmPlanService.java @@ -0,0 +1,72 @@ +package com.zioinfo.mro.pmplan; + +import com.zioinfo.mro.admin.AuditService; +import com.zioinfo.mro.pmplan.mapper.PmPlanMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Set; + +/** + * 예방보전(PM) 계획 서비스 — 기준정보(생성/수정/삭제는 컨트롤러 MANAGER+ 가드 병행). + */ +@Service +@RequiredArgsConstructor +public class PmPlanService { + + private static final Set PM_TYPE = Set.of("TBM", "CBM", "INSPECTION"); + + private final PmPlanMapper mapper; + private final AuditService audit; + + public List list(String equipmentCode, String pmType, String status) { + return mapper.findAll(equipmentCode, pmType, status); + } + + public MroPmPlan get(Long id) { + MroPmPlan p = mapper.findById(id); + if (p == null) throw new RuntimeException("ERR-PM-404: PM 계획 없음"); + return p; + } + + public MroPmPlan create(MroPmPlan p, String actor) { + validate(p); + if (mapper.countByCode(p.getPlanCode(), null) > 0) { + throw new RuntimeException("ERR-PM-409: 중복 PM계획코드"); + } + if (p.getStatus() == null) p.setStatus("ACTIVE"); + if (p.getPmType() == null) p.setPmType("TBM"); + p.setCreatedBy(actor); + mapper.insert(p); + audit.log("PM_PLAN_CREATE", p.getPlanCode(), "type=" + p.getPmType()); + return mapper.findById(p.getId()); + } + + public MroPmPlan update(Long id, MroPmPlan p, String actor) { + MroPmPlan cur = get(id); + validate(p); + if (mapper.countByCode(p.getPlanCode(), id) > 0) { + throw new RuntimeException("ERR-PM-409: 중복 PM계획코드"); + } + p.setId(id); + mapper.update(p); + audit.log("PM_PLAN_UPDATE", cur.getPlanCode(), "id=" + id); + return mapper.findById(id); + } + + public void delete(Long id) { + MroPmPlan cur = get(id); + mapper.delete(id); + audit.log("PM_PLAN_DELETE", cur.getPlanCode(), "id=" + id); + } + + private void validate(MroPmPlan p) { + if (p.getPlanCode() == null || p.getPlanCode().isBlank()) + throw new IllegalArgumentException("ERR-PM-400: planCode 필수"); + if (p.getPlanName() == null || p.getPlanName().isBlank()) + throw new IllegalArgumentException("ERR-PM-400: planName 필수"); + if (p.getPmType() != null && !PM_TYPE.contains(p.getPmType().toUpperCase())) + throw new IllegalArgumentException("ERR-PM-400: pmType 은 TBM/CBM/INSPECTION"); + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/pmplan/mapper/PmPlanMapper.java b/backend/src/main/java/com/zioinfo/mro/pmplan/mapper/PmPlanMapper.java new file mode 100644 index 0000000..3256c9e --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/pmplan/mapper/PmPlanMapper.java @@ -0,0 +1,20 @@ +package com.zioinfo.mro.pmplan.mapper; + +import com.zioinfo.mro.pmplan.MroPmPlan; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface PmPlanMapper { + List findAll(@Param("equipmentCode") String equipmentCode, + @Param("pmType") String pmType, + @Param("status") String status); + MroPmPlan findById(@Param("id") Long id); + MroPmPlan findByCode(@Param("planCode") String planCode); + int countByCode(@Param("planCode") String planCode, @Param("excludeId") Long excludeId); + int insert(MroPmPlan p); + int update(MroPmPlan p); + int delete(@Param("id") Long id); +} diff --git a/backend/src/main/java/com/zioinfo/mro/purchaseorder/MroPurchaseOrder.java b/backend/src/main/java/com/zioinfo/mro/purchaseorder/MroPurchaseOrder.java new file mode 100644 index 0000000..9b6b93b --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/purchaseorder/MroPurchaseOrder.java @@ -0,0 +1,30 @@ +package com.zioinfo.mro.purchaseorder; + +import lombok.Data; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 발주 (mro_purchase_order) — DRAFT→ORDERED→PARTIAL/RECEIVED→CLOSED, +CANCELLED. + */ +@Data +public class MroPurchaseOrder { + private Long id; + private String poNo; + private String prNo; + private String partnerCode; + private String materialCode; + private Double qty; + private String unit; + private Double unitPrice; + private Double amount; + private String currency; + private LocalDate orderDate; + private LocalDate expectedDate; + private String status; // DRAFT/ORDERED/PARTIAL/RECEIVED/CLOSED/CANCELLED + private Double receivedQty; + private String buyer; + private String createdBy; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/backend/src/main/java/com/zioinfo/mro/purchaseorder/PurchaseOrderController.java b/backend/src/main/java/com/zioinfo/mro/purchaseorder/PurchaseOrderController.java new file mode 100644 index 0000000..72ed1ce --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/purchaseorder/PurchaseOrderController.java @@ -0,0 +1,63 @@ +package com.zioinfo.mro.purchaseorder; + +import com.zioinfo.mro.common.ApiResponse; +import com.zioinfo.mro.common.AuthSupport; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** + * 발주 API — 조회 Viewer+, 생성/수정/발주/입고 Worker+, 취소 Manager+. + */ +@RestController +@RequestMapping("/api/mro/purchase-orders") +@RequiredArgsConstructor +public class PurchaseOrderController { + + private final PurchaseOrderService service; + + @GetMapping + public ApiResponse> list(@RequestParam(required = false) String status, + @RequestParam(required = false) String partnerCode, + @RequestParam(required = false) String keyword) { + return ApiResponse.ok(service.list(status, partnerCode, keyword)); + } + + @GetMapping("/{id}") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.ok(service.get(id)); + } + + @PostMapping + public ApiResponse create(@RequestBody MroPurchaseOrder po, Authentication auth) { + return ApiResponse.ok(service.create(po, AuthSupport.actor(auth))); + } + + @PutMapping("/{id}") + public ApiResponse update(@PathVariable Long id, @RequestBody MroPurchaseOrder po, + Authentication auth) { + return ApiResponse.ok(service.update(id, po, AuthSupport.actor(auth))); + } + + @PatchMapping("/{id}/order") + public ApiResponse order(@PathVariable Long id, Authentication auth) { + return ApiResponse.ok(service.order(id, AuthSupport.actor(auth))); + } + + @PatchMapping("/{id}/receive") + public ApiResponse receive(@PathVariable Long id, @RequestBody Map body, + Authentication auth) { + Object v = body == null ? null : body.get("receivedQty"); + Double receivedQty = v == null ? null : Double.valueOf(v.toString()); + return ApiResponse.ok(service.receive(id, receivedQty, AuthSupport.actor(auth))); + } + + @PatchMapping("/{id}/cancel") + public ApiResponse cancel(@PathVariable Long id, Authentication auth) { + AuthSupport.requireManager(auth); + return ApiResponse.ok(service.cancel(id, AuthSupport.actor(auth))); + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/purchaseorder/PurchaseOrderService.java b/backend/src/main/java/com/zioinfo/mro/purchaseorder/PurchaseOrderService.java new file mode 100644 index 0000000..9aa9778 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/purchaseorder/PurchaseOrderService.java @@ -0,0 +1,125 @@ +package com.zioinfo.mro.purchaseorder; + +import com.zioinfo.mro.admin.AuditService; +import com.zioinfo.mro.purchaseorder.mapper.PurchaseOrderMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.util.List; +import java.util.Set; + +/** + * 발주 서비스 — 발주확정(ORDERED)·입고(PARTIAL/RECEIVED)·취소. + * + *

수정은 DRAFT 만. 입고는 received_qty 누적, qty 충족 시 RECEIVED. 취소는 RECEIVED/CLOSED 이전만. + */ +@Service +@RequiredArgsConstructor +public class PurchaseOrderService { + + private static final Set RECEIVED_FINAL = Set.of("RECEIVED", "CLOSED", "CANCELLED"); + + private final PurchaseOrderMapper mapper; + private final AuditService audit; + + public List list(String status, String partnerCode, String keyword) { + return mapper.findAll(status, partnerCode, keyword); + } + + public MroPurchaseOrder get(Long id) { + MroPurchaseOrder po = mapper.findById(id); + if (po == null) throw new RuntimeException("ERR-PO-404: 발주 없음"); + return po; + } + + public MroPurchaseOrder create(MroPurchaseOrder po, String actor) { + if (po.getMaterialCode() == null || po.getMaterialCode().isBlank()) + throw new IllegalArgumentException("ERR-PO-400: materialCode 필수"); + if (po.getPoNo() == null || po.getPoNo().isBlank()) { + po.setPoNo("PO-" + System.currentTimeMillis()); + } + if (mapper.countByNo(po.getPoNo()) > 0) { + throw new RuntimeException("ERR-PO-409: 중복 발주번호"); + } + double qty = po.getQty() == null ? 0 : po.getQty(); + double unitPrice = po.getUnitPrice() == null ? 0 : po.getUnitPrice(); + po.setQty(qty); + po.setUnitPrice(unitPrice); + po.setAmount(qty * unitPrice); + if (po.getUnit() == null) po.setUnit("EA"); + if (po.getCurrency() == null) po.setCurrency("KRW"); + po.setStatus("DRAFT"); + po.setReceivedQty(0.0); + po.setCreatedBy(actor); + mapper.insert(po); + audit.log(actor, "PO_CREATE", po.getPoNo(), "amount=" + po.getAmount()); + return mapper.findById(po.getId()); + } + + public MroPurchaseOrder update(Long id, MroPurchaseOrder po, String actor) { + MroPurchaseOrder cur = get(id); + if (!"DRAFT".equals(cur.getStatus())) { + throw new RuntimeException("ERR-PO-409: DRAFT 상태에서만 수정 가능 (현재 " + cur.getStatus() + ")"); + } + double qty = po.getQty() == null ? 0 : po.getQty(); + double unitPrice = po.getUnitPrice() == null ? 0 : po.getUnitPrice(); + po.setId(id); + po.setPoNo(cur.getPoNo()); + po.setStatus(cur.getStatus()); + po.setQty(qty); + po.setUnitPrice(unitPrice); + po.setAmount(qty * unitPrice); + po.setReceivedQty(cur.getReceivedQty()); + po.setOrderDate(cur.getOrderDate()); + if (po.getCurrency() == null) po.setCurrency(cur.getCurrency()); + mapper.update(po); + audit.log(actor, "PO_UPDATE", cur.getPoNo(), "id=" + id); + return mapper.findById(id); + } + + /** 발주확정 — DRAFT → ORDERED (order_date=now). */ + public MroPurchaseOrder order(Long id, String actor) { + MroPurchaseOrder cur = get(id); + if (!"DRAFT".equals(cur.getStatus())) { + throw new RuntimeException("ERR-PO-409: DRAFT 상태에서만 발주 가능 (현재 " + cur.getStatus() + ")"); + } + cur.setStatus("ORDERED"); + cur.setOrderDate(LocalDate.now()); + mapper.update(cur); + audit.log(actor, "PO_ORDER", cur.getPoNo(), "ordered"); + return mapper.findById(id); + } + + /** 입고 — received_qty 누적, qty 충족 시 RECEIVED 아니면 PARTIAL. */ + public MroPurchaseOrder receive(Long id, Double receivedQty, String actor) { + MroPurchaseOrder cur = get(id); + if ("RECEIVED".equals(cur.getStatus()) || "CLOSED".equals(cur.getStatus()) + || "CANCELLED".equals(cur.getStatus())) { + throw new RuntimeException("ERR-PO-409: 입고 완료/취소된 발주는 입고 불가 (현재 " + cur.getStatus() + ")"); + } + if (receivedQty == null || receivedQty <= 0) { + throw new IllegalArgumentException("ERR-PO-400: receivedQty 는 0 초과"); + } + double already = cur.getReceivedQty() == null ? 0 : cur.getReceivedQty(); + double total = already + receivedQty; + double ordered = cur.getQty() == null ? 0 : cur.getQty(); + cur.setReceivedQty(total); + cur.setStatus(total >= ordered ? "RECEIVED" : "PARTIAL"); + mapper.update(cur); + audit.log(actor, "PO_RECEIVE", cur.getPoNo(), "receivedQty=" + total + "/" + ordered); + return mapper.findById(id); + } + + /** 취소 — RECEIVED/CLOSED 이전만 (Manager+). */ + public MroPurchaseOrder cancel(Long id, String actor) { + MroPurchaseOrder cur = get(id); + if (RECEIVED_FINAL.contains(cur.getStatus())) { + throw new RuntimeException("ERR-PO-409: 입고완료/마감/취소된 발주는 취소 불가 (현재 " + cur.getStatus() + ")"); + } + cur.setStatus("CANCELLED"); + mapper.update(cur); + audit.log(actor, "PO_CANCEL", cur.getPoNo(), "cancelled"); + return mapper.findById(id); + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/purchaseorder/mapper/PurchaseOrderMapper.java b/backend/src/main/java/com/zioinfo/mro/purchaseorder/mapper/PurchaseOrderMapper.java new file mode 100644 index 0000000..c325a8e --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/purchaseorder/mapper/PurchaseOrderMapper.java @@ -0,0 +1,18 @@ +package com.zioinfo.mro.purchaseorder.mapper; + +import com.zioinfo.mro.purchaseorder.MroPurchaseOrder; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface PurchaseOrderMapper { + List findAll(@Param("status") String status, + @Param("partnerCode") String partnerCode, + @Param("keyword") String keyword); + MroPurchaseOrder findById(@Param("id") Long id); + int countByNo(@Param("poNo") String poNo); + int insert(MroPurchaseOrder po); + int update(MroPurchaseOrder po); +} diff --git a/backend/src/main/java/com/zioinfo/mro/purchaserequest/MroPurchaseRequest.java b/backend/src/main/java/com/zioinfo/mro/purchaserequest/MroPurchaseRequest.java new file mode 100644 index 0000000..31ccc20 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/purchaserequest/MroPurchaseRequest.java @@ -0,0 +1,30 @@ +package com.zioinfo.mro.purchaserequest; + +import lombok.Data; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 구매요청 (mro_purchase_request) — DRAFT→SUBMITTED→APPROVED/REJECTED→ORDERED. + */ +@Data +public class MroPurchaseRequest { + private Long id; + private String prNo; + private String title; + private String materialCode; + private Double qty; + private String unit; + private LocalDate requiredDate; + private String requester; + private String deptCode; + private String purpose; + private String woNo; + private Double estAmount; + private String status; // DRAFT/SUBMITTED/APPROVED/REJECTED/ORDERED + private String approver; + private LocalDateTime approvedAt; + private String createdBy; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/backend/src/main/java/com/zioinfo/mro/purchaserequest/PurchaseRequestController.java b/backend/src/main/java/com/zioinfo/mro/purchaserequest/PurchaseRequestController.java new file mode 100644 index 0000000..624ac97 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/purchaserequest/PurchaseRequestController.java @@ -0,0 +1,59 @@ +package com.zioinfo.mro.purchaserequest; + +import com.zioinfo.mro.common.ApiResponse; +import com.zioinfo.mro.common.AuthSupport; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 구매요청 API — 조회 Viewer+, 생성/수정/상신 Worker+, 승인/반려 Manager+. + */ +@RestController +@RequestMapping("/api/mro/purchase-requests") +@RequiredArgsConstructor +public class PurchaseRequestController { + + private final PurchaseRequestService service; + + @GetMapping + public ApiResponse> list(@RequestParam(required = false) String status, + @RequestParam(required = false) String keyword) { + return ApiResponse.ok(service.list(status, keyword)); + } + + @GetMapping("/{id}") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.ok(service.get(id)); + } + + @PostMapping + public ApiResponse create(@RequestBody MroPurchaseRequest pr, Authentication auth) { + return ApiResponse.ok(service.create(pr, AuthSupport.actor(auth))); + } + + @PutMapping("/{id}") + public ApiResponse update(@PathVariable Long id, @RequestBody MroPurchaseRequest pr, + Authentication auth) { + return ApiResponse.ok(service.update(id, pr, AuthSupport.actor(auth))); + } + + @PatchMapping("/{id}/submit") + public ApiResponse submit(@PathVariable Long id, Authentication auth) { + return ApiResponse.ok(service.submit(id, AuthSupport.actor(auth))); + } + + @PatchMapping("/{id}/approve") + public ApiResponse approve(@PathVariable Long id, Authentication auth) { + AuthSupport.requireManager(auth); + return ApiResponse.ok(service.approve(id, AuthSupport.actor(auth))); + } + + @PatchMapping("/{id}/reject") + public ApiResponse reject(@PathVariable Long id, Authentication auth) { + AuthSupport.requireManager(auth); + return ApiResponse.ok(service.reject(id, AuthSupport.actor(auth))); + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/purchaserequest/PurchaseRequestService.java b/backend/src/main/java/com/zioinfo/mro/purchaserequest/PurchaseRequestService.java new file mode 100644 index 0000000..b462cad --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/purchaserequest/PurchaseRequestService.java @@ -0,0 +1,114 @@ +package com.zioinfo.mro.purchaserequest; + +import com.zioinfo.mro.admin.AuditService; +import com.zioinfo.mro.purchaserequest.mapper.PurchaseRequestMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; + +/** + * 구매요청 서비스 — 상태전이 DRAFT→SUBMITTED→APPROVED/REJECTED. ORDERED 는 발주 연동. + * + *

수정은 DRAFT 만. 완료(APPROVED/REJECTED/ORDERED) 재전이 불가. + */ +@Service +@RequiredArgsConstructor +public class PurchaseRequestService { + + private static final Set FINAL_STATUS = Set.of("APPROVED", "REJECTED", "ORDERED"); + + private final PurchaseRequestMapper mapper; + private final AuditService audit; + + public List list(String status, String keyword) { + return mapper.findAll(status, keyword); + } + + public MroPurchaseRequest get(Long id) { + MroPurchaseRequest pr = mapper.findById(id); + if (pr == null) throw new RuntimeException("ERR-PR-404: 구매요청 없음"); + return pr; + } + + public MroPurchaseRequest create(MroPurchaseRequest pr, String actor) { + if (pr.getTitle() == null || pr.getTitle().isBlank()) + throw new IllegalArgumentException("ERR-PR-400: title 필수"); + if (pr.getPrNo() == null || pr.getPrNo().isBlank()) { + pr.setPrNo("PR-" + System.currentTimeMillis()); + } + if (mapper.countByNo(pr.getPrNo()) > 0) { + throw new RuntimeException("ERR-PR-409: 중복 구매요청번호"); + } + if (pr.getUnit() == null) pr.setUnit("EA"); + pr.setStatus("DRAFT"); + if (pr.getRequester() == null) pr.setRequester(actor); + pr.setApprover(null); + pr.setApprovedAt(null); + pr.setCreatedBy(actor); + mapper.insert(pr); + audit.log(actor, "PR_CREATE", pr.getPrNo(), "material=" + pr.getMaterialCode()); + return mapper.findById(pr.getId()); + } + + public MroPurchaseRequest update(Long id, MroPurchaseRequest pr, String actor) { + MroPurchaseRequest cur = get(id); + if (!"DRAFT".equals(cur.getStatus())) { + throw new RuntimeException("ERR-PR-409: DRAFT 상태에서만 수정 가능 (현재 " + cur.getStatus() + ")"); + } + pr.setId(id); + pr.setPrNo(cur.getPrNo()); + pr.setStatus(cur.getStatus()); + pr.setApprover(cur.getApprover()); + pr.setApprovedAt(cur.getApprovedAt()); + mapper.update(pr); + audit.log(actor, "PR_UPDATE", cur.getPrNo(), "id=" + id); + return mapper.findById(id); + } + + /** 상신 — DRAFT → SUBMITTED. */ + public MroPurchaseRequest submit(Long id, String actor) { + MroPurchaseRequest cur = get(id); + requireStatus(cur, "DRAFT", "SUBMITTED"); + cur.setStatus("SUBMITTED"); + mapper.update(cur); + audit.log(actor, "PR_SUBMIT", cur.getPrNo(), "submitted"); + return mapper.findById(id); + } + + /** 승인 — SUBMITTED → APPROVED (approver=actor, approved_at=now, Manager+). */ + public MroPurchaseRequest approve(Long id, String actor) { + MroPurchaseRequest cur = get(id); + requireStatus(cur, "SUBMITTED", "APPROVED"); + cur.setStatus("APPROVED"); + cur.setApprover(actor); + cur.setApprovedAt(LocalDateTime.now()); + mapper.update(cur); + audit.log(actor, "PR_APPROVE", cur.getPrNo(), "approved"); + return mapper.findById(id); + } + + /** 반려 — SUBMITTED → REJECTED (Manager+). */ + public MroPurchaseRequest reject(Long id, String actor) { + MroPurchaseRequest cur = get(id); + requireStatus(cur, "SUBMITTED", "REJECTED"); + cur.setStatus("REJECTED"); + cur.setApprover(actor); + cur.setApprovedAt(LocalDateTime.now()); + mapper.update(cur); + audit.log(actor, "PR_REJECT", cur.getPrNo(), "rejected"); + return mapper.findById(id); + } + + private void requireStatus(MroPurchaseRequest pr, String expected, String to) { + if (FINAL_STATUS.contains(pr.getStatus())) { + throw new RuntimeException("ERR-PR-409: 완료된 구매요청은 재전이 불가 (현재 " + pr.getStatus() + ")"); + } + if (!expected.equals(pr.getStatus())) { + throw new RuntimeException("ERR-PR-409: " + expected + " 상태에서만 " + to + + " 전이 가능 (현재 " + pr.getStatus() + ")"); + } + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/purchaserequest/mapper/PurchaseRequestMapper.java b/backend/src/main/java/com/zioinfo/mro/purchaserequest/mapper/PurchaseRequestMapper.java new file mode 100644 index 0000000..4a99fa3 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/purchaserequest/mapper/PurchaseRequestMapper.java @@ -0,0 +1,17 @@ +package com.zioinfo.mro.purchaserequest.mapper; + +import com.zioinfo.mro.purchaserequest.MroPurchaseRequest; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface PurchaseRequestMapper { + List findAll(@Param("status") String status, + @Param("keyword") String keyword); + MroPurchaseRequest findById(@Param("id") Long id); + int countByNo(@Param("prNo") String prNo); + int insert(MroPurchaseRequest pr); + int update(MroPurchaseRequest pr); +} diff --git a/backend/src/main/java/com/zioinfo/mro/receiving/MroReceiving.java b/backend/src/main/java/com/zioinfo/mro/receiving/MroReceiving.java new file mode 100644 index 0000000..635d040 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/receiving/MroReceiving.java @@ -0,0 +1,24 @@ +package com.zioinfo.mro.receiving; + +import lombok.Data; +import java.time.LocalDateTime; + +/** + * 입고 (mro_receiving) — 순수 입고 기록. 재고 연동은 별도(inventorytxn) 담당. + */ +@Data +public class MroReceiving { + private Long id; + private String receivingNo; + private String poNo; + private String materialCode; + private String locationCode; + private Double receivedQty; + private String unit; + private String inspectResult; // PASS/FAIL/PENDING + private String receivedBy; + private LocalDateTime receivedAt; + private String remark; + private String createdBy; + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/com/zioinfo/mro/receiving/ReceivingController.java b/backend/src/main/java/com/zioinfo/mro/receiving/ReceivingController.java new file mode 100644 index 0000000..6285289 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/receiving/ReceivingController.java @@ -0,0 +1,43 @@ +package com.zioinfo.mro.receiving; + +import com.zioinfo.mro.common.ApiResponse; +import com.zioinfo.mro.common.AuthSupport; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** + * 입고 API — 조회 Viewer+, 생성/검사 Worker+. + */ +@RestController +@RequestMapping("/api/mro/receivings") +@RequiredArgsConstructor +public class ReceivingController { + + private final ReceivingService service; + + @GetMapping + public ApiResponse> list(@RequestParam(required = false) String poNo, + @RequestParam(required = false) String inspectResult) { + return ApiResponse.ok(service.list(poNo, inspectResult)); + } + + @GetMapping("/{id}") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.ok(service.get(id)); + } + + @PostMapping + public ApiResponse create(@RequestBody MroReceiving r, Authentication auth) { + return ApiResponse.ok(service.create(r, AuthSupport.actor(auth))); + } + + @PatchMapping("/{id}/inspect") + public ApiResponse inspect(@PathVariable Long id, @RequestBody Map body, + Authentication auth) { + return ApiResponse.ok(service.inspect(id, body.get("inspectResult"), AuthSupport.actor(auth))); + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/receiving/ReceivingService.java b/backend/src/main/java/com/zioinfo/mro/receiving/ReceivingService.java new file mode 100644 index 0000000..84de623 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/receiving/ReceivingService.java @@ -0,0 +1,68 @@ +package com.zioinfo.mro.receiving; + +import com.zioinfo.mro.admin.AuditService; +import com.zioinfo.mro.receiving.mapper.ReceivingMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; + +/** + * 입고 서비스 — 순수 입고 기록 + 검사결과 등록. 재고 연동 없음. + */ +@Service +@RequiredArgsConstructor +public class ReceivingService { + + private static final Set INSPECT_RESULTS = Set.of("PASS", "FAIL", "PENDING"); + + private final ReceivingMapper mapper; + private final AuditService audit; + + public List list(String poNo, String inspectResult) { + return mapper.findAll(poNo, inspectResult); + } + + public MroReceiving get(Long id) { + MroReceiving r = mapper.findById(id); + if (r == null) throw new RuntimeException("ERR-RCV-404: 입고 없음"); + return r; + } + + public MroReceiving create(MroReceiving r, String actor) { + if (r.getMaterialCode() == null || r.getMaterialCode().isBlank()) + throw new IllegalArgumentException("ERR-RCV-400: materialCode 필수"); + if (r.getReceivingNo() == null || r.getReceivingNo().isBlank()) { + r.setReceivingNo("RCV-" + System.currentTimeMillis()); + } + if (mapper.countByNo(r.getReceivingNo()) > 0) { + throw new RuntimeException("ERR-RCV-409: 중복 입고번호"); + } + if (r.getLocationCode() == null) r.setLocationCode("WH-MRO"); + if (r.getUnit() == null) r.setUnit("EA"); + if (r.getInspectResult() == null) r.setInspectResult("PENDING"); + else if (!INSPECT_RESULTS.contains(r.getInspectResult().toUpperCase())) + throw new IllegalArgumentException("ERR-RCV-400: inspectResult 은 PASS/FAIL/PENDING"); + r.setReceivedBy(actor); + r.setReceivedAt(LocalDateTime.now()); + r.setCreatedBy(actor); + mapper.insert(r); + audit.log(actor, "RECEIVING_CREATE", r.getReceivingNo(), "po=" + r.getPoNo()); + return mapper.findById(r.getId()); + } + + /** 검사결과 등록 — PASS/FAIL. */ + public MroReceiving inspect(Long id, String inspectResult, String actor) { + MroReceiving cur = get(id); + String s = inspectResult == null ? "" : inspectResult.trim().toUpperCase(); + if (!"PASS".equals(s) && !"FAIL".equals(s)) { + throw new IllegalArgumentException("ERR-RCV-400: inspectResult 은 PASS/FAIL"); + } + cur.setInspectResult(s); + mapper.update(cur); + audit.log(actor, "RECEIVING_INSPECT", cur.getReceivingNo(), "result=" + s); + return mapper.findById(id); + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/receiving/mapper/ReceivingMapper.java b/backend/src/main/java/com/zioinfo/mro/receiving/mapper/ReceivingMapper.java new file mode 100644 index 0000000..e9f8cb9 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/receiving/mapper/ReceivingMapper.java @@ -0,0 +1,17 @@ +package com.zioinfo.mro.receiving.mapper; + +import com.zioinfo.mro.receiving.MroReceiving; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface ReceivingMapper { + List findAll(@Param("poNo") String poNo, + @Param("inspectResult") String inspectResult); + MroReceiving findById(@Param("id") Long id); + int countByNo(@Param("receivingNo") String receivingNo); + int insert(MroReceiving r); + int update(MroReceiving r); +} diff --git a/backend/src/main/java/com/zioinfo/mro/reliability/ReliabilityController.java b/backend/src/main/java/com/zioinfo/mro/reliability/ReliabilityController.java new file mode 100644 index 0000000..c3ed94d --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/reliability/ReliabilityController.java @@ -0,0 +1,34 @@ +package com.zioinfo.mro.reliability; + +import com.zioinfo.mro.common.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 신뢰성 지표(MTBF/MTTR/가동률) API — 조회 전용(GET, Viewer+). 순수 매퍼 집계 + 자바 계산. + */ +@RestController +@RequestMapping("/api/mro/reliability") +@RequiredArgsConstructor +public class ReliabilityController { + + private final ReliabilityService service; + + /** + * 설비별 신뢰성 지표 목록/단건. + * @param equipmentCode 지정 시 해당 설비만 + * @param fromDate 기간 시작(yyyy-MM-dd), 미지정 시 최근 30일 가정 + * @param toDate 기간 종료(yyyy-MM-dd) + */ + @GetMapping + public ApiResponse> metrics(@RequestParam(required = false) String equipmentCode, + @RequestParam(required = false) String fromDate, + @RequestParam(required = false) String toDate) { + return ApiResponse.ok(service.metrics(equipmentCode, fromDate, toDate)); + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/reliability/ReliabilityDto.java b/backend/src/main/java/com/zioinfo/mro/reliability/ReliabilityDto.java new file mode 100644 index 0000000..a3b8316 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/reliability/ReliabilityDto.java @@ -0,0 +1,18 @@ +package com.zioinfo.mro.reliability; + +import lombok.Getter; +import lombok.Setter; + +/** + * 설비 신뢰성 지표 결과. MTBF/MTTR/가동률은 서비스에서 원자료(매퍼 집계)로 계산. + */ +@Getter +@Setter +public class ReliabilityDto { + private String equipmentCode; + private long failureCount; + private double mtbfHours; + private double mttrHours; + private double availability; + private double totalDowntimeMin; +} diff --git a/backend/src/main/java/com/zioinfo/mro/reliability/ReliabilityService.java b/backend/src/main/java/com/zioinfo/mro/reliability/ReliabilityService.java new file mode 100644 index 0000000..4eae15a --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/reliability/ReliabilityService.java @@ -0,0 +1,116 @@ +package com.zioinfo.mro.reliability; + +import com.zioinfo.mro.reliability.mapper.ReliabilityMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.format.DateTimeParseException; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * 신뢰성 지표(MTBF/MTTR/가동률) 계산 서비스. + * 매퍼는 설비별 원자료(failureCount·sumRepairMin·sumDowntimeMin)만 뽑고, 지표 계산은 여기서 수행(0 division 방어). + */ +@Service +@RequiredArgsConstructor +public class ReliabilityService { + + private static final long DEFAULT_PERIOD_DAYS = 30; + + private final ReliabilityMapper mapper; + + /** + * 설비별 신뢰성 지표 목록. 기간 미지정 시 최근 30일(30*24*60분)을 운영시간으로 가정. + */ + public List metrics(String equipmentCode, String fromDate, String toDate) { + String eq = (equipmentCode == null || equipmentCode.isBlank()) ? null : equipmentCode.trim(); + long periodMin = periodMinutes(fromDate, toDate); + + List> rows = mapper.aggregate(eq, blankToNull(fromDate), blankToNull(toDate)); + List out = new ArrayList<>(); + for (Map row : rows) { + long failureCount = asLong(row.get("failureCount")); + double sumRepairMin = asDouble(row.get("sumRepairMin")); + double sumDowntimeMin = asDouble(row.get("sumDowntimeMin")); + + ReliabilityDto dto = new ReliabilityDto(); + dto.setEquipmentCode(str(row.get("equipmentCode"))); + dto.setFailureCount(failureCount); + dto.setTotalDowntimeMin(round2(sumDowntimeMin)); + + // MTTR(h) = 총수리시간 / 고장건수 / 60 + dto.setMttrHours(failureCount > 0 ? round2(sumRepairMin / failureCount / 60.0) : 0.0); + + // MTBF(h) = (기간분 - 다운타임분) / 고장건수 / 60 + double upMin = Math.max(0.0, periodMin - sumDowntimeMin); + dto.setMtbfHours(failureCount > 0 ? round2(upMin / failureCount / 60.0) : 0.0); + + // 가동률 = (기간분 - 다운타임분) / 기간분 (0~1 clamp) + double availability = periodMin > 0 ? upMin / periodMin : 0.0; + dto.setAvailability(round4(Math.max(0.0, Math.min(1.0, availability)))); + + out.add(dto); + } + return out; + } + + /** 기간 총시간(분). from/to 모두 유효할 때만 사용, 아니면 최근 30일. */ + private long periodMinutes(String fromDate, String toDate) { + LocalDate from = parse(fromDate); + LocalDate to = parse(toDate); + if (from != null && to != null && !to.isBefore(from)) { + long days = ChronoUnit.DAYS.between(from, to) + 1; // 경계 포함 + return days * 24L * 60L; + } + return DEFAULT_PERIOD_DAYS * 24L * 60L; + } + + private static LocalDate parse(String s) { + if (s == null || s.isBlank()) return null; + try { + return LocalDate.parse(s.trim()); + } catch (DateTimeParseException e) { + return null; + } + } + + private static String blankToNull(String s) { + return (s == null || s.isBlank()) ? null : s.trim(); + } + + private static long asLong(Object v) { + if (v instanceof Number n) return n.longValue(); + if (v == null) return 0L; + try { + return Long.parseLong(v.toString()); + } catch (NumberFormatException e) { + return 0L; + } + } + + private static double asDouble(Object v) { + if (v instanceof Number n) return n.doubleValue(); + if (v == null) return 0.0; + try { + return Double.parseDouble(v.toString()); + } catch (NumberFormatException e) { + return 0.0; + } + } + + private static String str(Object v) { + return v == null ? null : v.toString(); + } + + private static double round2(double v) { + return Math.round(v * 100.0) / 100.0; + } + + private static double round4(double v) { + return Math.round(v * 10000.0) / 10000.0; + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/reliability/mapper/ReliabilityMapper.java b/backend/src/main/java/com/zioinfo/mro/reliability/mapper/ReliabilityMapper.java new file mode 100644 index 0000000..92fb4fe --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/reliability/mapper/ReliabilityMapper.java @@ -0,0 +1,23 @@ +package com.zioinfo.mro.reliability.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; +import java.util.Map; + +/** + * 신뢰성 지표 원자료 집계 매퍼 — 설비별 failureCount·sumRepairMin·sumDowntimeMin 만 GROUP BY 로 추출. + * MTBF/MTTR/availability 계산은 자바 서비스에서 수행(PostgreSQL 별칭 함수참조 함정 회피). + */ +@Mapper +public interface ReliabilityMapper { + + /** + * 정비이력(mro_maintenance_history) 기반 설비별 원자료. + * equipmentCode 지정 시 해당 설비만, from/toDate 지정 시 해당 기간만. + */ + List> aggregate(@Param("equipmentCode") String equipmentCode, + @Param("fromDate") String fromDate, + @Param("toDate") String toDate); +} diff --git a/backend/src/main/java/com/zioinfo/mro/stocktake/MroStocktake.java b/backend/src/main/java/com/zioinfo/mro/stocktake/MroStocktake.java new file mode 100644 index 0000000..2f39e1c --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/stocktake/MroStocktake.java @@ -0,0 +1,26 @@ +package com.zioinfo.mro.stocktake; + +import lombok.Data; +import java.time.LocalDateTime; + +/** + * MRO 재고실사 (mro_stocktake) — 계획→실사수량 입력→차이 확정. + * + *

status: PLANNED/COUNTING/DONE. diff_qty = counted_qty - system_qty. + */ +@Data +public class MroStocktake { + private Long id; + private String stocktakeNo; + private String locationCode; + private String materialCode; + private Double systemQty; + private Double countedQty; + private Double diffQty; + private String status; + private String countedBy; + private LocalDateTime countedAt; + private String remark; + private String createdBy; + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/com/zioinfo/mro/stocktake/StocktakeController.java b/backend/src/main/java/com/zioinfo/mro/stocktake/StocktakeController.java new file mode 100644 index 0000000..670ea7c --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/stocktake/StocktakeController.java @@ -0,0 +1,54 @@ +package com.zioinfo.mro.stocktake; + +import com.zioinfo.mro.common.ApiResponse; +import com.zioinfo.mro.common.AuthSupport; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** + * MRO 재고실사 API — 조회 Viewer+, 계획/실사입력 Worker+, 삭제 Manager+. + */ +@RestController +@RequestMapping("/api/mro/stocktakes") +@RequiredArgsConstructor +public class StocktakeController { + + private final StocktakeService service; + + @GetMapping + public ApiResponse> list(@RequestParam(required = false) String stocktakeNo, + @RequestParam(required = false) String status) { + return ApiResponse.ok(service.list(stocktakeNo, status)); + } + + @GetMapping("/{id}") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.ok(service.get(id)); + } + + @PostMapping + public ApiResponse create(@RequestBody MroStocktake st, Authentication auth) { + return ApiResponse.ok(service.create(st, AuthSupport.actor(auth))); + } + + @PatchMapping("/{id}/count") + public ApiResponse count(@PathVariable Long id, @RequestBody Map body, + Authentication auth) { + Object q = body.get("countedQty"); + Double countedQty = q == null ? null : Double.valueOf(q.toString()); + Object r = body.get("remark"); + String remark = r == null ? null : r.toString(); + return ApiResponse.ok(service.count(id, countedQty, remark, AuthSupport.actor(auth))); + } + + @DeleteMapping("/{id}") + public ApiResponse delete(@PathVariable Long id, Authentication auth) { + AuthSupport.requireManager(auth); + service.delete(id, AuthSupport.actor(auth)); + return ApiResponse.ok(null); + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/stocktake/StocktakeService.java b/backend/src/main/java/com/zioinfo/mro/stocktake/StocktakeService.java new file mode 100644 index 0000000..9e01cbc --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/stocktake/StocktakeService.java @@ -0,0 +1,69 @@ +package com.zioinfo.mro.stocktake; + +import com.zioinfo.mro.admin.AuditService; +import com.zioinfo.mro.stocktake.mapper.StocktakeMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * MRO 재고실사 서비스 — 계획 생성(Worker+)·실사수량 입력(Worker+)·삭제(Manager+). + */ +@Service +@RequiredArgsConstructor +public class StocktakeService { + + private final StocktakeMapper mapper; + private final AuditService audit; + + public List list(String stocktakeNo, String status) { + return mapper.findAll(stocktakeNo, status); + } + + public MroStocktake get(Long id) { + MroStocktake st = mapper.findById(id); + if (st == null) throw new RuntimeException("ERR-ST-404: 재고실사 없음"); + return st; + } + + /** 실사 계획 생성 — Worker+. */ + public MroStocktake create(MroStocktake st, String actor) { + if (st.getMaterialCode() == null || st.getMaterialCode().isBlank()) + throw new IllegalArgumentException("ERR-ST-400: materialCode 필수"); + if (st.getStocktakeNo() == null || st.getStocktakeNo().isBlank()) + st.setStocktakeNo("ST-" + System.currentTimeMillis()); + if (st.getLocationCode() == null || st.getLocationCode().isBlank()) st.setLocationCode("WH-MRO"); + if (st.getSystemQty() == null) st.setSystemQty(0d); + st.setStatus("PLANNED"); + st.setCreatedBy(actor); + mapper.insert(st); + audit.log(actor, "STOCKTAKE_CREATE", st.getStocktakeNo(), "material=" + st.getMaterialCode()); + return mapper.findById(st.getId()); + } + + /** 실사수량 입력 — diff 계산·DONE 확정 (Worker+). */ + public MroStocktake count(Long id, Double countedQty, String remark, String actor) { + MroStocktake cur = get(id); + if (countedQty == null) + throw new IllegalArgumentException("ERR-ST-400: countedQty 필수"); + double system = cur.getSystemQty() == null ? 0d : cur.getSystemQty(); + cur.setCountedQty(countedQty); + cur.setDiffQty(countedQty - system); + cur.setStatus("DONE"); + cur.setCountedBy(actor); + cur.setCountedAt(LocalDateTime.now()); + if (remark != null) cur.setRemark(remark); + mapper.updateCount(cur); + audit.log(actor, "STOCKTAKE_COUNT", cur.getStocktakeNo(), + "diff=" + cur.getDiffQty() + " counted=" + countedQty); + return mapper.findById(id); + } + + public void delete(Long id, String actor) { + MroStocktake cur = get(id); + mapper.delete(id); + audit.log(actor, "STOCKTAKE_DELETE", cur.getStocktakeNo(), "id=" + id); + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/stocktake/mapper/StocktakeMapper.java b/backend/src/main/java/com/zioinfo/mro/stocktake/mapper/StocktakeMapper.java new file mode 100644 index 0000000..0070e6c --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/stocktake/mapper/StocktakeMapper.java @@ -0,0 +1,17 @@ +package com.zioinfo.mro.stocktake.mapper; + +import com.zioinfo.mro.stocktake.MroStocktake; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface StocktakeMapper { + List findAll(@Param("stocktakeNo") String stocktakeNo, + @Param("status") String status); + MroStocktake findById(@Param("id") Long id); + int insert(MroStocktake st); + int updateCount(MroStocktake st); + int delete(@Param("id") Long id); +} diff --git a/backend/src/main/java/com/zioinfo/mro/workorder/MroWorkOrder.java b/backend/src/main/java/com/zioinfo/mro/workorder/MroWorkOrder.java new file mode 100644 index 0000000..6c94b43 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/workorder/MroWorkOrder.java @@ -0,0 +1,35 @@ +package com.zioinfo.mro.workorder; + +import lombok.Data; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 작업지시 (mro_work_order) — 생성→배정→진행→완료 상태전이. + * + *

woType: PM(예방)/BM(고장정비)/CM(개선정비), status: CREATED/ASSIGNED/INPROGRESS/DONE/CANCELLED. + */ +@Data +public class MroWorkOrder { + private Long id; + private String woNo; + private String woType; + private String equipmentCode; + private String pmPlanCode; + private String title; + private String description; + private String priority; // LOW/NORMAL/HIGH/URGENT + private String status; // CREATED/ASSIGNED/INPROGRESS/DONE/CANCELLED + private String assignee; + private String requestedBy; + private LocalDate plannedDate; + private LocalDateTime startedAt; + private LocalDateTime completedAt; + private Double downtimeMin; + private Double laborHours; + private Double cost; + private String resultNote; + private String createdBy; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/backend/src/main/java/com/zioinfo/mro/workorder/WorkOrderController.java b/backend/src/main/java/com/zioinfo/mro/workorder/WorkOrderController.java new file mode 100644 index 0000000..affeb2e --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/workorder/WorkOrderController.java @@ -0,0 +1,75 @@ +package com.zioinfo.mro.workorder; + +import com.zioinfo.mro.common.ApiResponse; +import com.zioinfo.mro.common.AuthSupport; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** + * 작업지시 API — 조회 Viewer+, 생성/전이/실적 Worker+, 삭제 Manager+. + */ +@RestController +@RequestMapping("/api/mro/work-orders") +@RequiredArgsConstructor +public class WorkOrderController { + + private final WorkOrderService service; + + @GetMapping + public ApiResponse> list(@RequestParam(required = false) String status, + @RequestParam(required = false) String woType, + @RequestParam(required = false) String equipmentCode, + @RequestParam(required = false) String keyword) { + return ApiResponse.ok(service.list(status, woType, equipmentCode, keyword)); + } + + @GetMapping("/{id}") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.ok(service.get(id)); + } + + @PostMapping + public ApiResponse create(@RequestBody MroWorkOrder wo, Authentication auth) { + return ApiResponse.ok(service.create(wo, AuthSupport.actor(auth))); + } + + @PutMapping("/{id}") + public ApiResponse update(@PathVariable Long id, @RequestBody MroWorkOrder wo, Authentication auth) { + return ApiResponse.ok(service.update(id, wo, AuthSupport.actor(auth))); + } + + @PatchMapping("/{id}/assign") + public ApiResponse assign(@PathVariable Long id, @RequestBody Map body, + Authentication auth) { + return ApiResponse.ok(service.assign(id, body.get("assignee"), AuthSupport.actor(auth))); + } + + @PatchMapping("/{id}/start") + public ApiResponse start(@PathVariable Long id, Authentication auth) { + return ApiResponse.ok(service.start(id, AuthSupport.actor(auth))); + } + + @PatchMapping("/{id}/complete") + public ApiResponse complete(@PathVariable Long id, @RequestBody(required = false) MroWorkOrder result, + Authentication auth) { + return ApiResponse.ok(service.complete(id, result, AuthSupport.actor(auth))); + } + + @PatchMapping("/{id}/cancel") + public ApiResponse cancel(@PathVariable Long id, @RequestBody(required = false) Map body, + Authentication auth) { + String reason = body == null ? null : body.get("reason"); + return ApiResponse.ok(service.cancel(id, reason, AuthSupport.actor(auth))); + } + + @DeleteMapping("/{id}") + public ApiResponse delete(@PathVariable Long id, Authentication auth) { + AuthSupport.requireManager(auth); + service.delete(id, AuthSupport.actor(auth)); + return ApiResponse.ok(null); + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/workorder/WorkOrderService.java b/backend/src/main/java/com/zioinfo/mro/workorder/WorkOrderService.java new file mode 100644 index 0000000..4cbff20 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/workorder/WorkOrderService.java @@ -0,0 +1,142 @@ +package com.zioinfo.mro.workorder; + +import com.zioinfo.mro.admin.AuditService; +import com.zioinfo.mro.workorder.mapper.WorkOrderMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; + +/** + * 작업지시 서비스 — 상태전이(CREATED→ASSIGNED→INPROGRESS→DONE, +CANCELLED). + * + *

전이 규칙: 배정(ASSIGNED, assignee 필수)·착수(INPROGRESS, started_at)·완료(DONE, completed_at+실적). + * 취소는 DONE 이전만 가능. 완료된 WO 는 재전이 불가. + */ +@Service +@RequiredArgsConstructor +public class WorkOrderService { + + private static final Set WO_TYPES = Set.of("PM", "BM", "CM"); + private static final Set PRIORITIES = Set.of("LOW", "NORMAL", "HIGH", "URGENT"); + + private final WorkOrderMapper mapper; + private final AuditService audit; + + public List list(String status, String woType, String equipmentCode, String keyword) { + return mapper.findAll(status, woType, equipmentCode, keyword); + } + + public MroWorkOrder get(Long id) { + MroWorkOrder wo = mapper.findById(id); + if (wo == null) throw new RuntimeException("ERR-WO-404: 작업지시 없음"); + return wo; + } + + public MroWorkOrder create(MroWorkOrder wo, String actor) { + if (wo.getTitle() == null || wo.getTitle().isBlank()) + throw new IllegalArgumentException("ERR-WO-400: title 필수"); + if (wo.getWoType() != null && !WO_TYPES.contains(wo.getWoType().toUpperCase())) + throw new IllegalArgumentException("ERR-WO-400: woType 은 PM/BM/CM"); + if (wo.getPriority() != null && !PRIORITIES.contains(wo.getPriority().toUpperCase())) + throw new IllegalArgumentException("ERR-WO-400: priority 은 LOW/NORMAL/HIGH/URGENT"); + if (wo.getWoNo() == null || wo.getWoNo().isBlank()) { + wo.setWoNo("WO-" + System.currentTimeMillis()); + } + if (mapper.countByNo(wo.getWoNo()) > 0) { + throw new RuntimeException("ERR-WO-409: 중복 작업지시번호"); + } + if (wo.getWoType() == null) wo.setWoType("BM"); + if (wo.getPriority() == null) wo.setPriority("NORMAL"); + wo.setStatus("CREATED"); + wo.setRequestedBy(actor); + wo.setCreatedBy(actor); + mapper.insert(wo); + audit.log(actor, "WORKORDER_CREATE", wo.getWoNo(), "type=" + wo.getWoType()); + return mapper.findById(wo.getId()); + } + + public MroWorkOrder update(Long id, MroWorkOrder wo, String actor) { + MroWorkOrder cur = get(id); + if ("DONE".equals(cur.getStatus()) || "CANCELLED".equals(cur.getStatus())) { + throw new RuntimeException("ERR-WO-409: 완료/취소된 작업지시는 수정 불가"); + } + wo.setId(id); + wo.setWoNo(cur.getWoNo()); + wo.setStatus(cur.getStatus()); + mapper.update(wo); + audit.log(actor, "WORKORDER_UPDATE", cur.getWoNo(), "id=" + id); + return mapper.findById(id); + } + + /** 배정 — CREATED → ASSIGNED (assignee 필수). */ + public MroWorkOrder assign(Long id, String assignee, String actor) { + MroWorkOrder cur = get(id); + requireStatus(cur, "CREATED", "ASSIGNED"); + if (assignee == null || assignee.isBlank()) + throw new IllegalArgumentException("ERR-WO-400: assignee 필수"); + cur.setStatus("ASSIGNED"); + cur.setAssignee(assignee); + mapper.update(cur); + audit.log(actor, "WORKORDER_ASSIGN", cur.getWoNo(), "assignee=" + assignee); + return mapper.findById(id); + } + + /** 착수 — ASSIGNED → INPROGRESS (started_at 기록). */ + public MroWorkOrder start(Long id, String actor) { + MroWorkOrder cur = get(id); + requireStatus(cur, "ASSIGNED", "INPROGRESS"); + cur.setStatus("INPROGRESS"); + cur.setStartedAt(LocalDateTime.now()); + mapper.update(cur); + audit.log(actor, "WORKORDER_START", cur.getWoNo(), "started"); + return mapper.findById(id); + } + + /** 완료 — INPROGRESS → DONE (실적: downtimeMin/laborHours/cost/resultNote 반영). */ + public MroWorkOrder complete(Long id, MroWorkOrder result, String actor) { + MroWorkOrder cur = get(id); + if (!"INPROGRESS".equals(cur.getStatus()) && !"ASSIGNED".equals(cur.getStatus())) { + throw new RuntimeException("ERR-WO-409: 진행/배정 상태에서만 완료 가능 (현재 " + cur.getStatus() + ")"); + } + cur.setStatus("DONE"); + cur.setCompletedAt(LocalDateTime.now()); + if (result != null) { + if (result.getDowntimeMin() != null) cur.setDowntimeMin(result.getDowntimeMin()); + if (result.getLaborHours() != null) cur.setLaborHours(result.getLaborHours()); + if (result.getCost() != null) cur.setCost(result.getCost()); + if (result.getResultNote() != null) cur.setResultNote(result.getResultNote()); + } + mapper.update(cur); + audit.log(actor, "WORKORDER_COMPLETE", cur.getWoNo(), "downtimeMin=" + cur.getDowntimeMin()); + return mapper.findById(id); + } + + /** 취소 — DONE 이전 상태만. */ + public MroWorkOrder cancel(Long id, String reason, String actor) { + MroWorkOrder cur = get(id); + if ("DONE".equals(cur.getStatus()) || "CANCELLED".equals(cur.getStatus())) { + throw new RuntimeException("ERR-WO-409: 완료/취소된 작업지시는 취소 불가"); + } + cur.setStatus("CANCELLED"); + cur.setResultNote(reason); + mapper.update(cur); + audit.log(actor, "WORKORDER_CANCEL", cur.getWoNo(), "reason=" + reason); + return mapper.findById(id); + } + + public void delete(Long id, String actor) { + MroWorkOrder cur = get(id); + mapper.delete(id); + audit.log(actor, "WORKORDER_DELETE", cur.getWoNo(), "id=" + id); + } + + private void requireStatus(MroWorkOrder wo, String expected, String to) { + if (!expected.equals(wo.getStatus())) { + throw new RuntimeException("ERR-WO-409: " + expected + " 상태에서만 " + to + + " 전이 가능 (현재 " + wo.getStatus() + ")"); + } + } +} diff --git a/backend/src/main/java/com/zioinfo/mro/workorder/mapper/WorkOrderMapper.java b/backend/src/main/java/com/zioinfo/mro/workorder/mapper/WorkOrderMapper.java new file mode 100644 index 0000000..a47bb68 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mro/workorder/mapper/WorkOrderMapper.java @@ -0,0 +1,20 @@ +package com.zioinfo.mro.workorder.mapper; + +import com.zioinfo.mro.workorder.MroWorkOrder; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface WorkOrderMapper { + List findAll(@Param("status") String status, + @Param("woType") String woType, + @Param("equipmentCode") String equipmentCode, + @Param("keyword") String keyword); + MroWorkOrder findById(@Param("id") Long id); + int countByNo(@Param("woNo") String woNo); + int insert(MroWorkOrder wo); + int update(MroWorkOrder wo); + int delete(@Param("id") Long id); +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml new file mode 100644 index 0000000..5fd2143 --- /dev/null +++ b/backend/src/main/resources/application.yml @@ -0,0 +1,68 @@ +server: + port: 8018 + +spring: + application: + name: guardia-mro + datasource: + url: ${DB_URL:jdbc:postgresql://localhost:5432/mro_db} + username: ${DB_USER:mro_user} + password: ${DB_PASS:mro_pass2026} + driver-class-name: org.postgresql.Driver + hikari: + # 운영 함정 준수: 다중 솔루션 동시 기동 시 공유 PG max_connections 보호 — 풀 3개 캡. + maximum-pool-size: ${DB_POOL_MAX:3} + minimum-idle: 1 + sql: + init: + # 멱등 스키마·시드(전부 IF NOT EXISTS + ON CONFLICT DO NOTHING). 재실행 안전. + mode: ${SQL_INIT_MODE:always} + schema-locations: classpath:db/schema.sql,classpath:db/104_seed_ai_config.sql + continue-on-error: true + servlet: + multipart: + max-file-size: 20MB + max-request-size: 20MB + +mybatis: + mapper-locations: classpath:mapper/**/*.xml + configuration: + map-underscore-to-camel-case: true + log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl + +springdoc: + api-docs: + path: /api/mro/docs + swagger-ui: + path: /api/mro/swagger + +# ── AI 로컬 학습 저장소(임베디드 DuckDB) — 피드백/추론 로그. 미가용 시 조용히 비활성(AI 무영향) ── +mro: + ai: + duckdb-path: ${MRO_DUCKDB_PATH:/opt/guardia-mro/data/mro_learning.duckdb} + +guardia: + erp-url: ${ERP_URL:http://localhost:8003} + itsm-url: ${ITSM_URL:http://localhost:9001} + mes-url: ${MES_URL:http://localhost:8013} + # 중앙 guardia-rag (하이브리드/그래프/리랭크 검색·에이전틱·구조화·스트리밍) — 온프레미스 루프백 전용. + rag: + base-url: ${RAG_URL:http://127.0.0.1:8020} + timeout-ms: ${RAG_TIMEOUT_MS:120000} + enabled: ${RAG_ENABLED:true} + solution: mro + # 보안 불변 규칙: Ollama 온프레미스만 허용. 외부 AI API 절대 금지. + # (예외: Claude 는 소유자 승인으로 api.anthropic.com 단일 경로 허용 — 키는 ANTHROPIC_API_KEY env 로만 주입) + ollama-url: ${OLLAMA_URL:http://localhost:11434} + ollama-text-model: ${OLLAMA_TEXT_MODEL:llama3.2:1b} + ollama-vision-model: ${OLLAMA_VISION_MODEL:moondream} + crypto: + secret: ${MRO_CRYPTO_SECRET:guardia-mro-aes-256-gcm-master-key-2026-zioinfo} + jwt: + secret: ${JWT_SECRET:guardia-mro-jwt-secret-2026-minimum-256bit-key-zioinfo} + expiration: 86400000 + +logging: + level: + com.zioinfo.mro: DEBUG + org.mybatis: WARN diff --git a/backend/src/main/resources/db/104_seed_ai_config.sql b/backend/src/main/resources/db/104_seed_ai_config.sql new file mode 100644 index 0000000..221e263 --- /dev/null +++ b/backend/src/main/resources/db/104_seed_ai_config.sql @@ -0,0 +1,33 @@ +-- ===================================================================== +-- GUARDiA MRO — 104. AI 플랫폼(LLM provider) 설정 시드 (멱등) +-- 대상 DB : mro_db (테이블 mro_setting) +-- 적용 : application.yml spring.sql.init.mode=always + schema-locations 에 본 파일 등재. +-- mode:always + continue-on-error 재실행 안전(ON CONFLICT DO NOTHING). +-- 설명 : 외부 Claude(Anthropic) API 를 온프레미스 Ollama 와 병행·선택형으로 추가. +-- 관리자(SUPERADMIN)가 런타임으로 provider/모델을 선택(재기동 불요, AiConfigService). +-- * ai.provider : ollama(기본) / claude / qwen3 / deepseek / glm +-- * ai.claude.model : claude-sonnet-4-6(기본) / claude-haiku-4-5 / claude-opus-4-8 +-- * ai.ollama.textModel: 빈값(=서버 프로퍼티 guardia.ollama-text-model 폴백, llama3.2:1b) +-- * ai.enabled : true(기본, off=규칙기반 degraded) +-- 보안 : Claude API 키는 DB 에 저장하지 않는다 — 서버 환경변수 ANTHROPIC_API_KEY 로만 주입. +-- 본 시드에 키/시크릿/IP/비밀번호 일절 미포함. +-- 무회귀 : 기본 provider=ollama → 설정 미변경 시 기존 Ollama 동작과 바이트 동일. +-- 멱등 : 테이블 IF NOT EXISTS + INSERT ... ON CONFLICT (key) DO NOTHING. +-- ===================================================================== + +SET client_encoding = 'UTF8'; + +CREATE TABLE IF NOT EXISTS mro_setting ( + key VARCHAR(200) PRIMARY KEY, + value TEXT, + updated_at TIMESTAMP DEFAULT NOW() +); + +INSERT INTO mro_setting (key, value) VALUES + ('ai.provider', 'ollama'), + ('ai.claude.model', 'claude-sonnet-4-6'), + ('ai.ollama.textModel', ''), + ('ai.enabled', 'true') +ON CONFLICT (key) DO NOTHING; + +-- end 104_seed_ai_config.sql diff --git a/backend/src/main/resources/db/schema.sql b/backend/src/main/resources/db/schema.sql new file mode 100644 index 0000000..9a6e16d --- /dev/null +++ b/backend/src/main/resources/db/schema.sql @@ -0,0 +1,411 @@ +-- ============================================================================ +-- GUARDiA MRO — PostgreSQL 스키마 (mro_db) +-- AI 기반 설비보전(EAM/CMMS) + MRO 자재 플랫폼 +-- 보안 불변: 거래처 PII *_enc AES-256-GCM 저장·응답 마스킹/제외, 스택트레이스 미노출 +-- 모든 테이블 IF NOT EXISTS / 시드 admin(SuperAdmin, bcrypt admin123) — 전부 멱등 +-- ============================================================================ + +SET client_encoding = 'UTF8'; + +-- ── 운영자 계정 (auth/admin) — RBAC: SUPERADMIN/MANAGER/WORKER/VIEWER ────────── +CREATE TABLE IF NOT EXISTS mro_user ( + id BIGSERIAL PRIMARY KEY, + username VARCHAR(200) UNIQUE NOT NULL, + password_hash VARCHAR(500) NOT NULL, + display_name VARCHAR(200), + email VARCHAR(300), + role VARCHAR(50) DEFAULT 'VIEWER', + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT NOW() +); +-- 기본 계정 (비밀번호: admin123 / BCrypt $2a). Phase3 인증 에이전트가 ADMIN_PASSWORD_ENC 로 재시드. 운영 시 즉시 변경. +INSERT INTO mro_user (username, password_hash, display_name, role) +VALUES ('admin', '$2a$10$Uo4GFiJ2TZy9KlGRALfDt.gcE0e7..57rXBRaEv1mJG2XqANG3OI2', '최고관리자', 'SUPERADMIN') +ON CONFLICT (username) DO NOTHING; +INSERT INTO mro_user (username, password_hash, display_name, role) +VALUES ('manager', '$2a$10$Uo4GFiJ2TZy9KlGRALfDt.gcE0e7..57rXBRaEv1mJG2XqANG3OI2', '설비보전관리자', 'MANAGER') +ON CONFLICT (username) DO NOTHING; +INSERT INTO mro_user (username, password_hash, display_name, role) +VALUES ('worker', '$2a$10$Uo4GFiJ2TZy9KlGRALfDt.gcE0e7..57rXBRaEv1mJG2XqANG3OI2', '정비작업자', 'WORKER') +ON CONFLICT (username) DO NOTHING; + +CREATE TABLE IF NOT EXISTS mro_audit_log ( + id BIGSERIAL PRIMARY KEY, + actor VARCHAR(200), + action VARCHAR(100), + target VARCHAR(300), + detail TEXT, + created_at TIMESTAMP DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_mro_audit_action ON mro_audit_log(action); +CREATE INDEX IF NOT EXISTS idx_mro_audit_actor ON mro_audit_log(actor); + +CREATE TABLE IF NOT EXISTS mro_setting ( + key VARCHAR(200) PRIMARY KEY, + value TEXT, + updated_at TIMESTAMP DEFAULT NOW() +); +INSERT INTO mro_setting (key, value) VALUES +('default_location', 'WH-MRO'), +('availability_target', '0.90'), +('itsm_fault_to_sr', 'true') +ON CONFLICT (key) DO NOTHING; + +-- ============================================================================ +-- EAM/CMMS — 설비보전 +-- ============================================================================ + +-- 설비 마스터 (위치/계층) — parent_code 로 계층, criticality A/B/C +CREATE TABLE IF NOT EXISTS mro_equipment ( + id BIGSERIAL PRIMARY KEY, + equipment_code VARCHAR(100) UNIQUE NOT NULL, + equipment_name VARCHAR(300), + category VARCHAR(100), + parent_code VARCHAR(100), + location VARCHAR(300), + dept_code VARCHAR(100), + manufacturer VARCHAR(200), + model_no VARCHAR(200), + serial_no VARCHAR(200), + install_date DATE, + criticality VARCHAR(10) DEFAULT 'B', -- A/B/C + run_status VARCHAR(30) DEFAULT 'IDLE', -- RUN/IDLE/DOWN/MAINT + attributes JSONB, + status VARCHAR(30) DEFAULT 'ACTIVE', + created_by VARCHAR(200), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_mro_equip_parent ON mro_equipment(parent_code); +CREATE INDEX IF NOT EXISTS idx_mro_equip_status ON mro_equipment(run_status); + +-- 예방보전(PM) 계획 — TBM(주기)/CBM(상태)/inspection +CREATE TABLE IF NOT EXISTS mro_pm_plan ( + id BIGSERIAL PRIMARY KEY, + plan_code VARCHAR(100) UNIQUE NOT NULL, + plan_name VARCHAR(300), + equipment_code VARCHAR(100), + pm_type VARCHAR(30) DEFAULT 'TBM', -- TBM/CBM/INSPECTION + cycle_days INTEGER DEFAULT 30, + last_done_date DATE, + next_due_date DATE, + task_desc TEXT, + standard_time_min DOUBLE PRECISION DEFAULT 0, + assignee VARCHAR(200), + status VARCHAR(30) DEFAULT 'ACTIVE', + created_by VARCHAR(200), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_mro_pm_equip ON mro_pm_plan(equipment_code); +CREATE INDEX IF NOT EXISTS idx_mro_pm_due ON mro_pm_plan(next_due_date); + +-- 작업지시(WO) — 생성→배정→진행→완료. wo_type PM/BM(고장정비)/CM(개선정비) +CREATE TABLE IF NOT EXISTS mro_work_order ( + id BIGSERIAL PRIMARY KEY, + wo_no VARCHAR(100) UNIQUE NOT NULL, + wo_type VARCHAR(30) DEFAULT 'BM', -- PM/BM/CM + equipment_code VARCHAR(100), + pm_plan_code VARCHAR(100), + title VARCHAR(300), + description TEXT, + priority VARCHAR(20) DEFAULT 'NORMAL', -- LOW/NORMAL/HIGH/URGENT + status VARCHAR(30) DEFAULT 'CREATED', -- CREATED/ASSIGNED/INPROGRESS/DONE/CANCELLED + assignee VARCHAR(200), + requested_by VARCHAR(200), + planned_date DATE, + started_at TIMESTAMP, + completed_at TIMESTAMP, + downtime_min DOUBLE PRECISION DEFAULT 0, + labor_hours DOUBLE PRECISION DEFAULT 0, + cost DOUBLE PRECISION DEFAULT 0, + result_note TEXT, + created_by VARCHAR(200), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_mro_wo_equip ON mro_work_order(equipment_code); +CREATE INDEX IF NOT EXISTS idx_mro_wo_status ON mro_work_order(status); + +-- 고장/정비이력 +CREATE TABLE IF NOT EXISTS mro_maintenance_history ( + id BIGSERIAL PRIMARY KEY, + equipment_code VARCHAR(100), + wo_no VARCHAR(100), + maint_type VARCHAR(30) DEFAULT 'REPAIR', -- FAULT/REPAIR/PM + symptom VARCHAR(500), + cause VARCHAR(500), + action TEXT, + part_used VARCHAR(500), + fault_at TIMESTAMP, + repaired_at TIMESTAMP, + repair_min DOUBLE PRECISION DEFAULT 0, + downtime_min DOUBLE PRECISION DEFAULT 0, + cost DOUBLE PRECISION DEFAULT 0, + technician VARCHAR(200), + created_by VARCHAR(200), + created_at TIMESTAMP DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_mro_maint_equip ON mro_maintenance_history(equipment_code); + +-- 계측기 교정 +CREATE TABLE IF NOT EXISTS mro_calibration ( + id BIGSERIAL PRIMARY KEY, + instrument_code VARCHAR(100) UNIQUE NOT NULL, + instrument_name VARCHAR(300), + equipment_code VARCHAR(100), + cal_cycle_days INTEGER DEFAULT 365, + last_cal_date DATE, + next_cal_date DATE, + cal_result VARCHAR(30) DEFAULT 'PASS', -- PASS/FAIL/ADJUSTED + cal_agency VARCHAR(300), + certificate_no VARCHAR(200), + status VARCHAR(30) DEFAULT 'ACTIVE', + created_by VARCHAR(200), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_mro_cal_next ON mro_calibration(next_cal_date); + +-- 다운타임 +CREATE TABLE IF NOT EXISTS mro_downtime ( + id BIGSERIAL PRIMARY KEY, + equipment_code VARCHAR(100), + wo_no VARCHAR(100), + downtime_type VARCHAR(30) DEFAULT 'FAULT', -- FAULT/PM/SETUP/MATERIAL/OTHER + start_at TIMESTAMP, + end_at TIMESTAMP, + duration_min DOUBLE PRECISION DEFAULT 0, + reason VARCHAR(500), + created_by VARCHAR(200), + created_at TIMESTAMP DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_mro_downtime_equip ON mro_downtime(equipment_code); + +-- ============================================================================ +-- MRO 자재 +-- ============================================================================ + +-- 자재/공구/부품 마스터 +CREATE TABLE IF NOT EXISTS mro_material ( + id BIGSERIAL PRIMARY KEY, + material_code VARCHAR(100) UNIQUE NOT NULL, + material_name VARCHAR(300), + material_type VARCHAR(30) DEFAULT 'SPARE', -- SPARE(예비품)/TOOL(공구)/CONSUMABLE(소모품)/PART(부품) + category VARCHAR(100), + unit VARCHAR(30) DEFAULT 'EA', + spec VARCHAR(500), + manufacturer VARCHAR(200), + barcode VARCHAR(200), + unit_price DOUBLE PRECISION DEFAULT 0, + safety_stock DOUBLE PRECISION DEFAULT 0, + reorder_point DOUBLE PRECISION DEFAULT 0, + lead_time_days INTEGER DEFAULT 7, + location_code VARCHAR(100), + partner_code VARCHAR(100), + attributes JSONB, + status VARCHAR(30) DEFAULT 'ACTIVE', + created_by VARCHAR(200), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_mro_material_type ON mro_material(material_type); + +-- 자재 BOM (설비별 소요 부품) +CREATE TABLE IF NOT EXISTS mro_material_bom ( + id BIGSERIAL PRIMARY KEY, + equipment_code VARCHAR(100) NOT NULL, + material_code VARCHAR(100) NOT NULL, + qty_per DOUBLE PRECISION DEFAULT 1, + unit VARCHAR(30) DEFAULT 'EA', + position VARCHAR(200), + remark VARCHAR(500), + status VARCHAR(30) DEFAULT 'ACTIVE', + created_by VARCHAR(200), + created_at TIMESTAMP DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_mro_mbom_equip ON mro_material_bom(equipment_code); + +-- 재고 (로케이션·안전재고) +CREATE TABLE IF NOT EXISTS mro_inventory ( + id BIGSERIAL PRIMARY KEY, + material_code VARCHAR(100) NOT NULL, + location_code VARCHAR(100) NOT NULL DEFAULT 'WH-MRO', + on_hand_qty DOUBLE PRECISION DEFAULT 0, + allocated_qty DOUBLE PRECISION DEFAULT 0, + available_qty DOUBLE PRECISION DEFAULT 0, + safety_stock DOUBLE PRECISION DEFAULT 0, + unit VARCHAR(30) DEFAULT 'EA', + last_count_date DATE, + updated_at TIMESTAMP DEFAULT NOW() +); +CREATE UNIQUE INDEX IF NOT EXISTS ux_mro_inv_mat_loc ON mro_inventory(material_code, location_code); + +-- 입출고 트랜잭션 +CREATE TABLE IF NOT EXISTS mro_inventory_txn ( + id BIGSERIAL PRIMARY KEY, + txn_no VARCHAR(100), + material_code VARCHAR(100) NOT NULL, + location_code VARCHAR(100) DEFAULT 'WH-MRO', + txn_type VARCHAR(30) NOT NULL, -- IN/OUT/ADJUST/MOVE/RETURN + qty DOUBLE PRECISION DEFAULT 0, + unit VARCHAR(30) DEFAULT 'EA', + ref_type VARCHAR(50), -- PO/WO/STOCKTAKE/MANUAL + ref_no VARCHAR(100), + wo_no VARCHAR(100), + reason VARCHAR(500), + balance_after DOUBLE PRECISION, + txn_by VARCHAR(200), + created_at TIMESTAMP DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_mro_txn_mat ON mro_inventory_txn(material_code); + +-- 재고실사 +CREATE TABLE IF NOT EXISTS mro_stocktake ( + id BIGSERIAL PRIMARY KEY, + stocktake_no VARCHAR(100) NOT NULL, + location_code VARCHAR(100) DEFAULT 'WH-MRO', + material_code VARCHAR(100) NOT NULL, + system_qty DOUBLE PRECISION DEFAULT 0, + counted_qty DOUBLE PRECISION, + diff_qty DOUBLE PRECISION, + status VARCHAR(30) DEFAULT 'PLANNED', -- PLANNED/COUNTING/DONE + counted_by VARCHAR(200), + counted_at TIMESTAMP, + remark VARCHAR(500), + created_by VARCHAR(200), + created_at TIMESTAMP DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_mro_stocktake_no ON mro_stocktake(stocktake_no); + +-- 구매요청(PR) +CREATE TABLE IF NOT EXISTS mro_purchase_request ( + id BIGSERIAL PRIMARY KEY, + pr_no VARCHAR(100) UNIQUE NOT NULL, + title VARCHAR(300), + material_code VARCHAR(100), + qty DOUBLE PRECISION DEFAULT 0, + unit VARCHAR(30) DEFAULT 'EA', + required_date DATE, + requester VARCHAR(200), + dept_code VARCHAR(100), + purpose VARCHAR(500), + wo_no VARCHAR(100), + est_amount DOUBLE PRECISION DEFAULT 0, + status VARCHAR(30) DEFAULT 'DRAFT', -- DRAFT/SUBMITTED/APPROVED/REJECTED/ORDERED + approver VARCHAR(200), + approved_at TIMESTAMP, + created_by VARCHAR(200), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_mro_pr_status ON mro_purchase_request(status); + +-- 발주(PO) +CREATE TABLE IF NOT EXISTS mro_purchase_order ( + id BIGSERIAL PRIMARY KEY, + po_no VARCHAR(100) UNIQUE NOT NULL, + pr_no VARCHAR(100), + partner_code VARCHAR(100), + material_code VARCHAR(100), + qty DOUBLE PRECISION DEFAULT 0, + unit VARCHAR(30) DEFAULT 'EA', + unit_price DOUBLE PRECISION DEFAULT 0, + amount DOUBLE PRECISION DEFAULT 0, + currency VARCHAR(10) DEFAULT 'KRW', + order_date DATE, + expected_date DATE, + status VARCHAR(30) DEFAULT 'DRAFT', -- DRAFT/ORDERED/PARTIAL/RECEIVED/CLOSED/CANCELLED + received_qty DOUBLE PRECISION DEFAULT 0, + buyer VARCHAR(200), + created_by VARCHAR(200), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_mro_po_status ON mro_purchase_order(status); + +-- 입고 +CREATE TABLE IF NOT EXISTS mro_receiving ( + id BIGSERIAL PRIMARY KEY, + receiving_no VARCHAR(100) UNIQUE NOT NULL, + po_no VARCHAR(100), + material_code VARCHAR(100), + location_code VARCHAR(100) DEFAULT 'WH-MRO', + received_qty DOUBLE PRECISION DEFAULT 0, + unit VARCHAR(30) DEFAULT 'EA', + inspect_result VARCHAR(30) DEFAULT 'PENDING', -- PASS/FAIL/PENDING + received_by VARCHAR(200), + received_at TIMESTAMP, + remark VARCHAR(500), + created_by VARCHAR(200), + created_at TIMESTAMP DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_mro_recv_po ON mro_receiving(po_no); + +-- 거래처 (PII: contact_enc/email_enc AES-256-GCM 저장, 응답 마스킹/제외) +CREATE TABLE IF NOT EXISTS mro_partner ( + id BIGSERIAL PRIMARY KEY, + partner_code VARCHAR(100) UNIQUE NOT NULL, + partner_name VARCHAR(300), + partner_type VARCHAR(30) DEFAULT 'SUPPLIER', -- SUPPLIER/VENDOR/SERVICE + biz_no VARCHAR(100), + manager_name VARCHAR(200), + contact_enc TEXT, + email_enc TEXT, + address VARCHAR(500), + payment_terms VARCHAR(200), + rating VARCHAR(20), + status VARCHAR(30) DEFAULT 'ACTIVE', + created_by VARCHAR(200), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +-- MRO 비용/예산 +CREATE TABLE IF NOT EXISTS mro_cost ( + id BIGSERIAL PRIMARY KEY, + cost_no VARCHAR(100), + cost_type VARCHAR(30) DEFAULT 'PART', -- LABOR/PART/OUTSOURCE/OTHER + equipment_code VARCHAR(100), + wo_no VARCHAR(100), + dept_code VARCHAR(100), + budget_code VARCHAR(100), + amount DOUBLE PRECISION DEFAULT 0, + currency VARCHAR(10) DEFAULT 'KRW', + cost_date DATE, + description VARCHAR(500), + budget_amount DOUBLE PRECISION DEFAULT 0, + created_by VARCHAR(200), + created_at TIMESTAMP DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_mro_cost_equip ON mro_cost(equipment_code); + +-- ============================================================================ +-- 데모 시드 (멱등) +-- ============================================================================ +INSERT INTO mro_equipment (equipment_code, equipment_name, category, location, criticality, run_status) +VALUES ('EQ-1001', '공기압축기 #1', 'UTILITY', '유틸리티동 1F', 'A', 'RUN') +ON CONFLICT (equipment_code) DO NOTHING; +INSERT INTO mro_equipment (equipment_code, equipment_name, category, parent_code, location, criticality, run_status) +VALUES ('EQ-1002', '냉각펌프 #1', 'UTILITY', 'EQ-1001', '유틸리티동 1F', 'B', 'IDLE') +ON CONFLICT (equipment_code) DO NOTHING; + +INSERT INTO mro_material (material_code, material_name, material_type, unit, unit_price, safety_stock, reorder_point) +VALUES ('MAT-2001', '베어링 6205', 'SPARE', 'EA', 12000, 10, 5) +ON CONFLICT (material_code) DO NOTHING; +INSERT INTO mro_material (material_code, material_name, material_type, unit, unit_price, safety_stock, reorder_point) +VALUES ('MAT-2002', '윤활유 ISO VG68', 'CONSUMABLE', 'L', 8000, 50, 20) +ON CONFLICT (material_code) DO NOTHING; + +INSERT INTO mro_partner (partner_code, partner_name, partner_type, status) +VALUES ('PT-3001', '한빛산업기계', 'SUPPLIER', 'ACTIVE') +ON CONFLICT (partner_code) DO NOTHING; + +INSERT INTO mro_inventory (material_code, location_code, on_hand_qty, available_qty, safety_stock, unit) +VALUES ('MAT-2001', 'WH-MRO', 8, 8, 10, 'EA') +ON CONFLICT (material_code, location_code) DO NOTHING; +INSERT INTO mro_inventory (material_code, location_code, on_hand_qty, available_qty, safety_stock, unit) +VALUES ('MAT-2002', 'WH-MRO', 60, 60, 50, 'L') +ON CONFLICT (material_code, location_code) DO NOTHING; + +-- end schema.sql diff --git a/backend/src/main/resources/mapper/AdminUserMapper.xml b/backend/src/main/resources/mapper/AdminUserMapper.xml new file mode 100644 index 0000000..3f73acb --- /dev/null +++ b/backend/src/main/resources/mapper/AdminUserMapper.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + INSERT INTO mro_user (username, password_hash, display_name, email, role, is_active) + VALUES (#{username}, #{passwordHash}, #{displayName}, #{email}, #{role}, #{active}) + + + + UPDATE mro_user SET role = #{role} WHERE id = #{id} + + + + UPDATE mro_user SET is_active = #{active} WHERE id = #{id} + + + + UPDATE mro_user SET password_hash = #{passwordHash} WHERE id = #{id} + + + + DELETE FROM mro_user WHERE id = #{id} + + + + + + + diff --git a/backend/src/main/resources/mapper/AuditLogMapper.xml b/backend/src/main/resources/mapper/AuditLogMapper.xml new file mode 100644 index 0000000..6af9963 --- /dev/null +++ b/backend/src/main/resources/mapper/AuditLogMapper.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + INSERT INTO mro_audit_log (actor, action, target, detail) + VALUES (#{actor}, #{action}, #{target}, #{detail}) + + + + + diff --git a/backend/src/main/resources/mapper/CalibrationMapper.xml b/backend/src/main/resources/mapper/CalibrationMapper.xml new file mode 100644 index 0000000..862a566 --- /dev/null +++ b/backend/src/main/resources/mapper/CalibrationMapper.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO mro_calibration (instrument_code, instrument_name, equipment_code, cal_cycle_days, last_cal_date, + next_cal_date, cal_result, cal_agency, certificate_no, status, created_by) + VALUES (#{instrumentCode}, #{instrumentName}, #{equipmentCode}, #{calCycleDays}, #{lastCalDate}, + #{nextCalDate}, #{calResult}, #{calAgency}, #{certificateNo}, #{status}, #{createdBy}) + + + + UPDATE mro_calibration SET instrument_code = #{instrumentCode}, instrument_name = #{instrumentName}, + equipment_code = #{equipmentCode}, cal_cycle_days = #{calCycleDays}, last_cal_date = #{lastCalDate}, + next_cal_date = #{nextCalDate}, cal_result = #{calResult}, cal_agency = #{calAgency}, + certificate_no = #{certificateNo}, status = #{status}, updated_at = NOW() + WHERE id = #{id} + + + DELETE FROM mro_calibration WHERE id = #{id} + + diff --git a/backend/src/main/resources/mapper/CostMapper.xml b/backend/src/main/resources/mapper/CostMapper.xml new file mode 100644 index 0000000..92ec3a6 --- /dev/null +++ b/backend/src/main/resources/mapper/CostMapper.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO mro_cost (cost_no, cost_type, equipment_code, wo_no, dept_code, budget_code, amount, currency, + cost_date, description, budget_amount, created_by) + VALUES (#{costNo}, #{costType}, #{equipmentCode}, #{woNo}, #{deptCode}, #{budgetCode}, #{amount}, #{currency}, + #{costDate}, #{description}, #{budgetAmount}, #{createdBy}) + + + + UPDATE mro_cost SET cost_type = #{costType}, equipment_code = #{equipmentCode}, wo_no = #{woNo}, + dept_code = #{deptCode}, budget_code = #{budgetCode}, amount = #{amount}, currency = #{currency}, + cost_date = #{costDate}, description = #{description}, budget_amount = #{budgetAmount} + WHERE id = #{id} + + + DELETE FROM mro_cost WHERE id = #{id} + + diff --git a/backend/src/main/resources/mapper/DashboardMapper.xml b/backend/src/main/resources/mapper/DashboardMapper.xml new file mode 100644 index 0000000..a5356a9 --- /dev/null +++ b/backend/src/main/resources/mapper/DashboardMapper.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/src/main/resources/mapper/DowntimeMapper.xml b/backend/src/main/resources/mapper/DowntimeMapper.xml new file mode 100644 index 0000000..94456d2 --- /dev/null +++ b/backend/src/main/resources/mapper/DowntimeMapper.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO mro_downtime (equipment_code, wo_no, downtime_type, start_at, end_at, duration_min, reason, created_by) + VALUES (#{equipmentCode}, #{woNo}, #{downtimeType}, #{startAt}, #{endAt}, #{durationMin}, #{reason}, #{createdBy}) + + + + UPDATE mro_downtime SET equipment_code = #{equipmentCode}, wo_no = #{woNo}, downtime_type = #{downtimeType}, + start_at = #{startAt}, end_at = #{endAt}, duration_min = #{durationMin}, reason = #{reason} + WHERE id = #{id} + + + DELETE FROM mro_downtime WHERE id = #{id} + + diff --git a/backend/src/main/resources/mapper/EquipmentMapper.xml b/backend/src/main/resources/mapper/EquipmentMapper.xml new file mode 100644 index 0000000..dc9f38b --- /dev/null +++ b/backend/src/main/resources/mapper/EquipmentMapper.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO mro_equipment (equipment_code, equipment_name, category, parent_code, location, dept_code, + manufacturer, model_no, serial_no, install_date, criticality, run_status, attributes, status, created_by) + VALUES (#{equipmentCode}, #{equipmentName}, #{category}, #{parentCode}, #{location}, #{deptCode}, + #{manufacturer}, #{modelNo}, #{serialNo}, #{installDate}, #{criticality}, #{runStatus}, + #{attributes}::jsonb, #{status}, #{createdBy}) + + + + UPDATE mro_equipment SET equipment_code = #{equipmentCode}, equipment_name = #{equipmentName}, + category = #{category}, parent_code = #{parentCode}, location = #{location}, dept_code = #{deptCode}, + manufacturer = #{manufacturer}, model_no = #{modelNo}, serial_no = #{serialNo}, install_date = #{installDate}, + criticality = #{criticality}, run_status = #{runStatus}, attributes = #{attributes}::jsonb, status = #{status}, + updated_at = NOW() + WHERE id = #{id} + + + + UPDATE mro_equipment SET run_status = #{runStatus}, updated_at = NOW() WHERE id = #{id} + + + DELETE FROM mro_equipment WHERE id = #{id} + + diff --git a/backend/src/main/resources/mapper/InventoryMapper.xml b/backend/src/main/resources/mapper/InventoryMapper.xml new file mode 100644 index 0000000..440869d --- /dev/null +++ b/backend/src/main/resources/mapper/InventoryMapper.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO mro_inventory (material_code, location_code, on_hand_qty, allocated_qty, available_qty, + safety_stock, unit, last_count_date, updated_at) + VALUES (#{materialCode}, #{locationCode}, #{onHandQty}, #{allocatedQty}, #{availableQty}, + #{safetyStock}, #{unit}, #{lastCountDate}, NOW()) + ON CONFLICT (material_code, location_code) DO UPDATE SET + on_hand_qty = EXCLUDED.on_hand_qty, + allocated_qty = EXCLUDED.allocated_qty, + available_qty = EXCLUDED.available_qty, + safety_stock = EXCLUDED.safety_stock, + unit = EXCLUDED.unit, + last_count_date = EXCLUDED.last_count_date, + updated_at = NOW() + + + + UPDATE mro_inventory SET on_hand_qty = #{onHandQty}, allocated_qty = #{allocatedQty}, + available_qty = #{availableQty}, safety_stock = #{safetyStock}, unit = #{unit}, + last_count_date = #{lastCountDate}, updated_at = NOW() + WHERE id = #{id} + + + diff --git a/backend/src/main/resources/mapper/InventoryTxnMapper.xml b/backend/src/main/resources/mapper/InventoryTxnMapper.xml new file mode 100644 index 0000000..d20266c --- /dev/null +++ b/backend/src/main/resources/mapper/InventoryTxnMapper.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO mro_inventory_txn (txn_no, material_code, location_code, txn_type, qty, unit, + ref_type, ref_no, wo_no, reason, balance_after, txn_by) + VALUES (#{txnNo}, #{materialCode}, #{locationCode}, #{txnType}, #{qty}, #{unit}, + #{refType}, #{refNo}, #{woNo}, #{reason}, #{balanceAfter}, #{txnBy}) + + + + INSERT INTO mro_inventory (material_code, location_code, on_hand_qty, available_qty, updated_at) + VALUES (#{materialCode}, #{locationCode}, #{delta}, #{delta}, NOW()) + ON CONFLICT (material_code, location_code) DO UPDATE SET + on_hand_qty = mro_inventory.on_hand_qty + #{delta}, + available_qty = mro_inventory.available_qty + #{delta}, + updated_at = NOW() + + + + + diff --git a/backend/src/main/resources/mapper/MaintenanceMapper.xml b/backend/src/main/resources/mapper/MaintenanceMapper.xml new file mode 100644 index 0000000..a091b2a --- /dev/null +++ b/backend/src/main/resources/mapper/MaintenanceMapper.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO mro_maintenance_history (equipment_code, wo_no, maint_type, symptom, cause, action, part_used, + fault_at, repaired_at, repair_min, downtime_min, cost, technician, created_by) + VALUES (#{equipmentCode}, #{woNo}, #{maintType}, #{symptom}, #{cause}, #{action}, #{partUsed}, + #{faultAt}, #{repairedAt}, #{repairMin}, #{downtimeMin}, #{cost}, #{technician}, #{createdBy}) + + + + UPDATE mro_maintenance_history SET equipment_code = #{equipmentCode}, wo_no = #{woNo}, maint_type = #{maintType}, + symptom = #{symptom}, cause = #{cause}, action = #{action}, part_used = #{partUsed}, fault_at = #{faultAt}, + repaired_at = #{repairedAt}, repair_min = #{repairMin}, downtime_min = #{downtimeMin}, cost = #{cost}, + technician = #{technician} + WHERE id = #{id} + + + DELETE FROM mro_maintenance_history WHERE id = #{id} + + diff --git a/backend/src/main/resources/mapper/MaterialBomMapper.xml b/backend/src/main/resources/mapper/MaterialBomMapper.xml new file mode 100644 index 0000000..c33c382 --- /dev/null +++ b/backend/src/main/resources/mapper/MaterialBomMapper.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO mro_material_bom (equipment_code, material_code, qty_per, unit, position, remark, status, created_by) + VALUES (#{equipmentCode}, #{materialCode}, #{qtyPer}, #{unit}, #{position}, #{remark}, #{status}, #{createdBy}) + + + + UPDATE mro_material_bom SET equipment_code = #{equipmentCode}, material_code = #{materialCode}, + qty_per = #{qtyPer}, unit = #{unit}, position = #{position}, remark = #{remark}, status = #{status} + WHERE id = #{id} + + + DELETE FROM mro_material_bom WHERE id = #{id} + + diff --git a/backend/src/main/resources/mapper/MaterialMapper.xml b/backend/src/main/resources/mapper/MaterialMapper.xml new file mode 100644 index 0000000..67de569 --- /dev/null +++ b/backend/src/main/resources/mapper/MaterialMapper.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO mro_material (material_code, material_name, material_type, category, unit, spec, manufacturer, + barcode, unit_price, safety_stock, reorder_point, lead_time_days, location_code, partner_code, + attributes, status, created_by) + VALUES (#{materialCode}, #{materialName}, #{materialType}, #{category}, #{unit}, #{spec}, #{manufacturer}, + #{barcode}, #{unitPrice}, #{safetyStock}, #{reorderPoint}, #{leadTimeDays}, #{locationCode}, #{partnerCode}, + #{attributes}::jsonb, #{status}, #{createdBy}) + + + + UPDATE mro_material SET material_code = #{materialCode}, material_name = #{materialName}, + material_type = #{materialType}, category = #{category}, unit = #{unit}, spec = #{spec}, + manufacturer = #{manufacturer}, barcode = #{barcode}, unit_price = #{unitPrice}, safety_stock = #{safetyStock}, + reorder_point = #{reorderPoint}, lead_time_days = #{leadTimeDays}, location_code = #{locationCode}, + partner_code = #{partnerCode}, attributes = #{attributes}::jsonb, status = #{status}, updated_at = NOW() + WHERE id = #{id} + + + DELETE FROM mro_material WHERE id = #{id} + + diff --git a/backend/src/main/resources/mapper/PartnerMapper.xml b/backend/src/main/resources/mapper/PartnerMapper.xml new file mode 100644 index 0000000..d48e56c --- /dev/null +++ b/backend/src/main/resources/mapper/PartnerMapper.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO mro_partner (partner_code, partner_name, partner_type, biz_no, manager_name, contact_enc, email_enc, + address, payment_terms, rating, status, created_by) + VALUES (#{partnerCode}, #{partnerName}, #{partnerType}, #{bizNo}, #{managerName}, #{contactEnc}, #{emailEnc}, + #{address}, #{paymentTerms}, #{rating}, #{status}, #{createdBy}) + + + + UPDATE mro_partner SET partner_code = #{partnerCode}, partner_name = #{partnerName}, + partner_type = #{partnerType}, biz_no = #{bizNo}, manager_name = #{managerName}, + contact_enc = #{contactEnc}, email_enc = #{emailEnc}, address = #{address}, + payment_terms = #{paymentTerms}, rating = #{rating}, status = #{status}, updated_at = NOW() + WHERE id = #{id} + + + DELETE FROM mro_partner WHERE id = #{id} + + diff --git a/backend/src/main/resources/mapper/PmPlanMapper.xml b/backend/src/main/resources/mapper/PmPlanMapper.xml new file mode 100644 index 0000000..d16e205 --- /dev/null +++ b/backend/src/main/resources/mapper/PmPlanMapper.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO mro_pm_plan (plan_code, plan_name, equipment_code, pm_type, cycle_days, last_done_date, + next_due_date, task_desc, standard_time_min, assignee, status, created_by) + VALUES (#{planCode}, #{planName}, #{equipmentCode}, #{pmType}, #{cycleDays}, #{lastDoneDate}, + #{nextDueDate}, #{taskDesc}, #{standardTimeMin}, #{assignee}, #{status}, #{createdBy}) + + + + UPDATE mro_pm_plan SET plan_code = #{planCode}, plan_name = #{planName}, equipment_code = #{equipmentCode}, + pm_type = #{pmType}, cycle_days = #{cycleDays}, last_done_date = #{lastDoneDate}, next_due_date = #{nextDueDate}, + task_desc = #{taskDesc}, standard_time_min = #{standardTimeMin}, assignee = #{assignee}, status = #{status}, + updated_at = NOW() + WHERE id = #{id} + + + DELETE FROM mro_pm_plan WHERE id = #{id} + + diff --git a/backend/src/main/resources/mapper/PurchaseOrderMapper.xml b/backend/src/main/resources/mapper/PurchaseOrderMapper.xml new file mode 100644 index 0000000..0dc9730 --- /dev/null +++ b/backend/src/main/resources/mapper/PurchaseOrderMapper.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO mro_purchase_order (po_no, pr_no, partner_code, material_code, qty, unit, unit_price, amount, + currency, order_date, expected_date, status, received_qty, buyer, created_by) + VALUES (#{poNo}, #{prNo}, #{partnerCode}, #{materialCode}, #{qty}, #{unit}, #{unitPrice}, #{amount}, + #{currency}, #{orderDate}, #{expectedDate}, #{status}, #{receivedQty}, #{buyer}, #{createdBy}) + + + + UPDATE mro_purchase_order SET pr_no = #{prNo}, partner_code = #{partnerCode}, material_code = #{materialCode}, + qty = #{qty}, unit = #{unit}, unit_price = #{unitPrice}, amount = #{amount}, currency = #{currency}, + order_date = #{orderDate}, expected_date = #{expectedDate}, status = #{status}, + received_qty = #{receivedQty}, buyer = #{buyer}, updated_at = NOW() + WHERE id = #{id} + + + diff --git a/backend/src/main/resources/mapper/PurchaseRequestMapper.xml b/backend/src/main/resources/mapper/PurchaseRequestMapper.xml new file mode 100644 index 0000000..2654768 --- /dev/null +++ b/backend/src/main/resources/mapper/PurchaseRequestMapper.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO mro_purchase_request (pr_no, title, material_code, qty, unit, required_date, requester, + dept_code, purpose, wo_no, est_amount, status, approver, approved_at, created_by) + VALUES (#{prNo}, #{title}, #{materialCode}, #{qty}, #{unit}, #{requiredDate}, #{requester}, + #{deptCode}, #{purpose}, #{woNo}, #{estAmount}, #{status}, #{approver}, #{approvedAt}, #{createdBy}) + + + + UPDATE mro_purchase_request SET title = #{title}, material_code = #{materialCode}, qty = #{qty}, + unit = #{unit}, required_date = #{requiredDate}, requester = #{requester}, dept_code = #{deptCode}, + purpose = #{purpose}, wo_no = #{woNo}, est_amount = #{estAmount}, status = #{status}, + approver = #{approver}, approved_at = #{approvedAt}, updated_at = NOW() + WHERE id = #{id} + + + diff --git a/backend/src/main/resources/mapper/ReceivingMapper.xml b/backend/src/main/resources/mapper/ReceivingMapper.xml new file mode 100644 index 0000000..7264a3e --- /dev/null +++ b/backend/src/main/resources/mapper/ReceivingMapper.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO mro_receiving (receiving_no, po_no, material_code, location_code, received_qty, unit, + inspect_result, received_by, received_at, remark, created_by) + VALUES (#{receivingNo}, #{poNo}, #{materialCode}, #{locationCode}, #{receivedQty}, #{unit}, + #{inspectResult}, #{receivedBy}, #{receivedAt}, #{remark}, #{createdBy}) + + + + UPDATE mro_receiving SET po_no = #{poNo}, material_code = #{materialCode}, location_code = #{locationCode}, + received_qty = #{receivedQty}, unit = #{unit}, inspect_result = #{inspectResult}, remark = #{remark} + WHERE id = #{id} + + + diff --git a/backend/src/main/resources/mapper/ReliabilityMapper.xml b/backend/src/main/resources/mapper/ReliabilityMapper.xml new file mode 100644 index 0000000..affe648 --- /dev/null +++ b/backend/src/main/resources/mapper/ReliabilityMapper.xml @@ -0,0 +1,36 @@ + + + + + + + + diff --git a/backend/src/main/resources/mapper/SettingMapper.xml b/backend/src/main/resources/mapper/SettingMapper.xml new file mode 100644 index 0000000..ce1c2a4 --- /dev/null +++ b/backend/src/main/resources/mapper/SettingMapper.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + INSERT INTO mro_setting (key, value, updated_at) VALUES (#{key}, #{value}, NOW()) + ON CONFLICT (key) DO UPDATE SET value = #{value}, updated_at = NOW() + + + diff --git a/backend/src/main/resources/mapper/StocktakeMapper.xml b/backend/src/main/resources/mapper/StocktakeMapper.xml new file mode 100644 index 0000000..c32cf27 --- /dev/null +++ b/backend/src/main/resources/mapper/StocktakeMapper.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO mro_stocktake (stocktake_no, location_code, material_code, system_qty, counted_qty, + diff_qty, status, counted_by, counted_at, remark, created_by) + VALUES (#{stocktakeNo}, #{locationCode}, #{materialCode}, #{systemQty}, #{countedQty}, + #{diffQty}, #{status}, #{countedBy}, #{countedAt}, #{remark}, #{createdBy}) + + + + UPDATE mro_stocktake SET counted_qty = #{countedQty}, diff_qty = #{diffQty}, status = #{status}, + counted_by = #{countedBy}, counted_at = #{countedAt}, remark = #{remark} + WHERE id = #{id} + + + DELETE FROM mro_stocktake WHERE id = #{id} + + diff --git a/backend/src/main/resources/mapper/UserMapper.xml b/backend/src/main/resources/mapper/UserMapper.xml new file mode 100644 index 0000000..925d070 --- /dev/null +++ b/backend/src/main/resources/mapper/UserMapper.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + INSERT INTO mro_user (username, password_hash, display_name, email, role, is_active) + VALUES (#{username}, #{passwordHash}, #{displayName}, #{email}, #{role}, #{active}) + + + diff --git a/backend/src/main/resources/mapper/WorkOrderMapper.xml b/backend/src/main/resources/mapper/WorkOrderMapper.xml new file mode 100644 index 0000000..8a3fd00 --- /dev/null +++ b/backend/src/main/resources/mapper/WorkOrderMapper.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO mro_work_order (wo_no, wo_type, equipment_code, pm_plan_code, title, description, priority, + status, assignee, requested_by, planned_date, created_by) + VALUES (#{woNo}, #{woType}, #{equipmentCode}, #{pmPlanCode}, #{title}, #{description}, #{priority}, + #{status}, #{assignee}, #{requestedBy}, #{plannedDate}, #{createdBy}) + + + + UPDATE mro_work_order SET wo_type = #{woType}, equipment_code = #{equipmentCode}, pm_plan_code = #{pmPlanCode}, + title = #{title}, description = #{description}, priority = #{priority}, status = #{status}, + assignee = #{assignee}, planned_date = #{plannedDate}, started_at = #{startedAt}, completed_at = #{completedAt}, + downtime_min = #{downtimeMin}, labor_hours = #{laborHours}, cost = #{cost}, result_note = #{resultNote}, + updated_at = NOW() + WHERE id = #{id} + + + DELETE FROM mro_work_order WHERE id = #{id} + + diff --git a/backend/src/main/resources/static/assets/index-BP-omnwj.js b/backend/src/main/resources/static/assets/index-BP-omnwj.js new file mode 100644 index 0000000..5a7e51d --- /dev/null +++ b/backend/src/main/resources/static/assets/index-BP-omnwj.js @@ -0,0 +1,140 @@ +var g1=e=>{throw TypeError(e)};var $m=(e,t,n)=>t.has(e)||g1("Cannot "+n);var U=(e,t,n)=>($m(e,t,"read from private field"),n?n.call(e):t.get(e)),je=(e,t,n)=>t.has(e)?g1("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(e):t.set(e,n),se=(e,t,n,r)=>($m(e,t,"write to private field"),r?r.call(e,n):t.set(e,n),n),Dt=(e,t,n)=>($m(e,t,"access private method"),n);var ff=(e,t,n,r)=>({set _(a){se(e,t,a,n)},get _(){return U(e,t,r)}});(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const a of document.querySelectorAll('link[rel="modulepreload"]'))r(a);new MutationObserver(a=>{for(const i of a)if(i.type==="childList")for(const l of i.addedNodes)l.tagName==="LINK"&&l.rel==="modulepreload"&&r(l)}).observe(document,{childList:!0,subtree:!0});function n(a){const i={};return a.integrity&&(i.integrity=a.integrity),a.referrerPolicy&&(i.referrerPolicy=a.referrerPolicy),a.crossOrigin==="use-credentials"?i.credentials="include":a.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function r(a){if(a.ep)return;a.ep=!0;const i=n(a);fetch(a.href,i)}})();var df=typeof globalThis<"u"?globalThis:typeof window<"u"?window:typeof global<"u"?global:typeof self<"u"?self:{};function _e(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var BN={exports:{}},op={};/** + * @license React + * react-jsx-runtime.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var nR=Symbol.for("react.transitional.element"),rR=Symbol.for("react.fragment");function UN(e,t,n){var r=null;if(n!==void 0&&(r=""+n),t.key!==void 0&&(r=""+t.key),"key"in t){n={};for(var a in t)a!=="key"&&(n[a]=t[a])}else n=t;return t=n.ref,{$$typeof:nR,type:e,key:r,ref:t!==void 0?t:null,props:n}}op.Fragment=rR;op.jsx=UN;op.jsxs=UN;BN.exports=op;var o=BN.exports,IN={exports:{}},de={};/** + * @license React + * react.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Fx=Symbol.for("react.transitional.element"),aR=Symbol.for("react.portal"),iR=Symbol.for("react.fragment"),lR=Symbol.for("react.strict_mode"),oR=Symbol.for("react.profiler"),sR=Symbol.for("react.consumer"),uR=Symbol.for("react.context"),cR=Symbol.for("react.forward_ref"),fR=Symbol.for("react.suspense"),dR=Symbol.for("react.memo"),HN=Symbol.for("react.lazy"),hR=Symbol.for("react.activity"),x1=Symbol.iterator;function pR(e){return e===null||typeof e!="object"?null:(e=x1&&e[x1]||e["@@iterator"],typeof e=="function"?e:null)}var qN={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},FN=Object.assign,GN={};function ss(e,t,n){this.props=e,this.context=t,this.refs=GN,this.updater=n||qN}ss.prototype.isReactComponent={};ss.prototype.setState=function(e,t){if(typeof e!="object"&&typeof e!="function"&&e!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,e,t,"setState")};ss.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,"forceUpdate")};function KN(){}KN.prototype=ss.prototype;function Gx(e,t,n){this.props=e,this.context=t,this.refs=GN,this.updater=n||qN}var Kx=Gx.prototype=new KN;Kx.constructor=Gx;FN(Kx,ss.prototype);Kx.isPureReactComponent=!0;var b1=Array.isArray;function ly(){}var Qe={H:null,A:null,T:null,S:null},VN=Object.prototype.hasOwnProperty;function Vx(e,t,n){var r=n.ref;return{$$typeof:Fx,type:e,key:t,ref:r!==void 0?r:null,props:n}}function mR(e,t){return Vx(e.type,t,e.props)}function Yx(e){return typeof e=="object"&&e!==null&&e.$$typeof===Fx}function vR(e){var t={"=":"=0",":":"=2"};return"$"+e.replace(/[=:]/g,function(n){return t[n]})}var j1=/\/+/g;function Rm(e,t){return typeof e=="object"&&e!==null&&e.key!=null?vR(""+e.key):t.toString(36)}function yR(e){switch(e.status){case"fulfilled":return e.value;case"rejected":throw e.reason;default:switch(typeof e.status=="string"?e.then(ly,ly):(e.status="pending",e.then(function(t){e.status==="pending"&&(e.status="fulfilled",e.value=t)},function(t){e.status==="pending"&&(e.status="rejected",e.reason=t)})),e.status){case"fulfilled":return e.value;case"rejected":throw e.reason}}throw e}function kl(e,t,n,r,a){var i=typeof e;(i==="undefined"||i==="boolean")&&(e=null);var l=!1;if(e===null)l=!0;else switch(i){case"bigint":case"string":case"number":l=!0;break;case"object":switch(e.$$typeof){case Fx:case aR:l=!0;break;case HN:return l=e._init,kl(l(e._payload),t,n,r,a)}}if(l)return a=a(e),l=r===""?"."+Rm(e,0):r,b1(a)?(n="",l!=null&&(n=l.replace(j1,"$&/")+"/"),kl(a,t,n,"",function(c){return c})):a!=null&&(Yx(a)&&(a=mR(a,n+(a.key==null||e&&e.key===a.key?"":(""+a.key).replace(j1,"$&/")+"/")+l)),t.push(a)),1;l=0;var s=r===""?".":r+":";if(b1(e))for(var u=0;u>>1,V=P[G];if(0>>1;Ga(te,B))vea(Re,te)?(P[G]=Re,P[ve]=B,G=ve):(P[G]=te,P[X]=B,G=X);else if(vea(Re,B))P[G]=Re,P[ve]=B,G=ve;else break e}}return L}function a(P,L){var B=P.sortIndex-L.sortIndex;return B!==0?B:P.id-L.id}if(e.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var i=performance;e.unstable_now=function(){return i.now()}}else{var l=Date,s=l.now();e.unstable_now=function(){return l.now()-s}}var u=[],c=[],f=1,d=null,h=3,m=!1,x=!1,y=!1,p=!1,v=typeof setTimeout=="function"?setTimeout:null,g=typeof clearTimeout=="function"?clearTimeout:null,b=typeof setImmediate<"u"?setImmediate:null;function j(P){for(var L=n(c);L!==null;){if(L.callback===null)r(c);else if(L.startTime<=P)r(c),L.sortIndex=L.expirationTime,t(u,L);else break;L=n(c)}}function S(P){if(y=!1,j(P),!x)if(n(u)!==null)x=!0,w||(w=!0,C());else{var L=n(c);L!==null&&R(S,L.startTime-P)}}var w=!1,N=-1,A=5,_=-1;function E(){return p?!0:!(e.unstable_now()-_P&&E());){var G=d.callback;if(typeof G=="function"){d.callback=null,h=d.priorityLevel;var V=G(d.expirationTime<=P);if(P=e.unstable_now(),typeof V=="function"){d.callback=V,j(P),L=!0;break t}d===n(u)&&r(u),j(P)}else r(u);d=n(u)}if(d!==null)L=!0;else{var K=n(c);K!==null&&R(S,K.startTime-P),L=!1}}break e}finally{d=null,h=B,m=!1}L=void 0}}finally{L?C():w=!1}}}var C;if(typeof b=="function")C=function(){b(M)};else if(typeof MessageChannel<"u"){var z=new MessageChannel,k=z.port2;z.port1.onmessage=M,C=function(){k.postMessage(null)}}else C=function(){v(M,0)};function R(P,L){N=v(function(){P(e.unstable_now())},L)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(P){P.callback=null},e.unstable_forceFrameRate=function(P){0>P||125G?(P.sortIndex=B,t(c,P),n(u)===null&&P===n(c)&&(y?(g(N),N=-1):y=!0,R(S,B-G))):(P.sortIndex=V,t(u,P),x||m||(x=!0,w||(w=!0,C()))),P},e.unstable_shouldYield=E,e.unstable_wrapCallback=function(P){var L=h;return function(){var B=h;h=L;try{return P.apply(this,arguments)}finally{h=B}}}})(QN);XN.exports=QN;var bR=XN.exports,WN={exports:{}},rn={};/** + * @license React + * react-dom.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var jR=O;function ZN(e){var t="https://react.dev/errors/"+e;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(JN)}catch(e){console.error(e)}}JN(),WN.exports=rn;var OR=WN.exports;/** + * @license React + * react-dom-client.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Ot=bR,eE=O,AR=OR;function H(e){var t="https://react.dev/errors/"+e;if(1Ul||(e.current=dy[Ul],dy[Ul]=null,Ul--)}function qe(e,t){Ul++,dy[Ul]=e.current,e.current=t}var Pr=Lr(null),Mu=Lr(null),Wa=Lr(null),bd=Lr(null);function jd(e,t){switch(qe(Wa,t),qe(Mu,e),qe(Pr,null),t.nodeType){case 9:case 11:e=(e=t.documentElement)&&(e=e.namespaceURI)?Cj(e):0;break;default:if(e=t.tagName,t=t.namespaceURI)t=Cj(t),e=NT(t,e);else switch(e){case"svg":e=1;break;case"math":e=2;break;default:e=0}}Mt(Pr),qe(Pr,e)}function wo(){Mt(Pr),Mt(Mu),Mt(Wa)}function hy(e){e.memoizedState!==null&&qe(bd,e);var t=Pr.current,n=NT(t,e.type);t!==n&&(qe(Mu,e),qe(Pr,n))}function Sd(e){Mu.current===e&&(Mt(Pr),Mt(Mu)),bd.current===e&&(Mt(bd),Hu._currentValue=Vi)}var Dm,A1;function Ei(e){if(Dm===void 0)try{throw Error()}catch(n){var t=n.stack.trim().match(/\n( *(at )?)/);Dm=t&&t[1]||"",A1=-1)":-1a||u[r]!==c[a]){var f=` +`+u[r].replace(" at new "," at ");return e.displayName&&f.includes("")&&(f=f.replace("",e.displayName)),f}while(1<=r&&0<=a);break}}}finally{km=!1,Error.prepareStackTrace=n}return(n=e?e.displayName||e.name:"")?Ei(n):""}function CR(e,t){switch(e.tag){case 26:case 27:case 5:return Ei(e.type);case 16:return Ei("Lazy");case 13:return e.child!==t&&t!==null?Ei("Suspense Fallback"):Ei("Suspense");case 19:return Ei("SuspenseList");case 0:case 15:return Lm(e.type,!1);case 11:return Lm(e.type.render,!1);case 1:return Lm(e.type,!0);case 31:return Ei("Activity");default:return""}}function N1(e){try{var t="",n=null;do t+=CR(e,n),n=e,e=e.return;while(e);return t}catch(r){return` +Error generating stack: `+r.message+` +`+r.stack}}var py=Object.prototype.hasOwnProperty,Wx=Ot.unstable_scheduleCallback,zm=Ot.unstable_cancelCallback,MR=Ot.unstable_shouldYield,PR=Ot.unstable_requestPaint,_n=Ot.unstable_now,$R=Ot.unstable_getCurrentPriorityLevel,oE=Ot.unstable_ImmediatePriority,sE=Ot.unstable_UserBlockingPriority,wd=Ot.unstable_NormalPriority,RR=Ot.unstable_LowPriority,uE=Ot.unstable_IdlePriority,DR=Ot.log,kR=Ot.unstable_setDisableYieldValue,Dc=null,Tn=null;function Fa(e){if(typeof DR=="function"&&kR(e),Tn&&typeof Tn.setStrictMode=="function")try{Tn.setStrictMode(Dc,e)}catch{}}var Cn=Math.clz32?Math.clz32:BR,LR=Math.log,zR=Math.LN2;function BR(e){return e>>>=0,e===0?32:31-(LR(e)/zR|0)|0}var mf=256,vf=262144,yf=4194304;function _i(e){var t=e&42;if(t!==0)return t;switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return e&261888;case 262144:case 524288:case 1048576:case 2097152:return e&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return e&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return e}}function cp(e,t,n){var r=e.pendingLanes;if(r===0)return 0;var a=0,i=e.suspendedLanes,l=e.pingedLanes;e=e.warmLanes;var s=r&134217727;return s!==0?(r=s&~i,r!==0?a=_i(r):(l&=s,l!==0?a=_i(l):n||(n=s&~e,n!==0&&(a=_i(n))))):(s=r&~i,s!==0?a=_i(s):l!==0?a=_i(l):n||(n=r&~e,n!==0&&(a=_i(n)))),a===0?0:t!==0&&t!==a&&!(t&i)&&(i=a&-a,n=t&-t,i>=n||i===32&&(n&4194048)!==0)?t:a}function kc(e,t){return(e.pendingLanes&~(e.suspendedLanes&~e.pingedLanes)&t)===0}function UR(e,t){switch(e){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function cE(){var e=yf;return yf<<=1,!(yf&62914560)&&(yf=4194304),e}function Bm(e){for(var t=[],n=0;31>n;n++)t.push(e);return t}function Lc(e,t){e.pendingLanes|=t,t!==268435456&&(e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0)}function IR(e,t,n,r,a,i){var l=e.pendingLanes;e.pendingLanes=n,e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0,e.expiredLanes&=n,e.entangledLanes&=n,e.errorRecoveryDisabledLanes&=n,e.shellSuspendCounter=0;var s=e.entanglements,u=e.expirationTimes,c=e.hiddenUpdates;for(n=l&~n;0"u")return null;try{return e.activeElement||e.body}catch{return e.body}}var VR=/[\n"\\]/g;function Vn(e){return e.replace(VR,function(t){return"\\"+t.charCodeAt(0).toString(16)+" "})}function yy(e,t,n,r,a,i,l,s){e.name="",l!=null&&typeof l!="function"&&typeof l!="symbol"&&typeof l!="boolean"?e.type=l:e.removeAttribute("type"),t!=null?l==="number"?(t===0&&e.value===""||e.value!=t)&&(e.value=""+Fn(t)):e.value!==""+Fn(t)&&(e.value=""+Fn(t)):l!=="submit"&&l!=="reset"||e.removeAttribute("value"),t!=null?gy(e,l,Fn(t)):n!=null?gy(e,l,Fn(n)):r!=null&&e.removeAttribute("value"),a==null&&i!=null&&(e.defaultChecked=!!i),a!=null&&(e.checked=a&&typeof a!="function"&&typeof a!="symbol"),s!=null&&typeof s!="function"&&typeof s!="symbol"&&typeof s!="boolean"?e.name=""+Fn(s):e.removeAttribute("name")}function xE(e,t,n,r,a,i,l,s){if(i!=null&&typeof i!="function"&&typeof i!="symbol"&&typeof i!="boolean"&&(e.type=i),t!=null||n!=null){if(!(i!=="submit"&&i!=="reset"||t!=null)){vy(e);return}n=n!=null?""+Fn(n):"",t=t!=null?""+Fn(t):n,s||t===e.value||(e.value=t),e.defaultValue=t}r=r??a,r=typeof r!="function"&&typeof r!="symbol"&&!!r,e.checked=s?e.checked:!!r,e.defaultChecked=!!r,l!=null&&typeof l!="function"&&typeof l!="symbol"&&typeof l!="boolean"&&(e.name=l),vy(e)}function gy(e,t,n){t==="number"&&Od(e.ownerDocument)===e||e.defaultValue===""+n||(e.defaultValue=""+n)}function ao(e,t,n,r){if(e=e.options,t){t={};for(var a=0;a"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),by=!1;if(da)try{var zs={};Object.defineProperty(zs,"passive",{get:function(){by=!0}}),window.addEventListener("test",zs,zs),window.removeEventListener("test",zs,zs)}catch{by=!1}var Ga=null,rb=null,Zf=null;function OE(){if(Zf)return Zf;var e,t=rb,n=t.length,r,a="value"in Ga?Ga.value:Ga.textContent,i=a.length;for(e=0;e=fu),L1=" ",z1=!1;function NE(e,t){switch(e){case"keyup":return jD.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function EE(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var ql=!1;function wD(e,t){switch(e){case"compositionend":return EE(t);case"keypress":return t.which!==32?null:(z1=!0,L1);case"textInput":return e=t.data,e===L1&&z1?null:e;default:return null}}function OD(e,t){if(ql)return e==="compositionend"||!ib&&NE(e,t)?(e=OE(),Zf=rb=Ga=null,ql=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=q1(n)}}function ME(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?ME(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function PE(e){e=e!=null&&e.ownerDocument!=null&&e.ownerDocument.defaultView!=null?e.ownerDocument.defaultView:window;for(var t=Od(e.document);t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=Od(e.document)}return t}function lb(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}var PD=da&&"documentMode"in document&&11>=document.documentMode,Fl=null,jy=null,hu=null,Sy=!1;function G1(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;Sy||Fl==null||Fl!==Od(r)||(r=Fl,"selectionStart"in r&&lb(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),hu&&Ru(hu,r)||(hu=r,r=Hd(jy,"onSelect"),0>=l,a-=l,Nr=1<<32-Cn(t)+a|n<A?(_=N,N=null):_=N.sibling;var E=h(v,N,b[A],j);if(E===null){N===null&&(N=_);break}e&&N&&E.alternate===null&&t(v,N),g=i(E,g,A),w===null?S=E:w.sibling=E,w=E,N=_}if(A===b.length)return n(v,N),we&&Wr(v,A),S;if(N===null){for(;AA?(_=N,N=null):_=N.sibling;var M=h(v,N,E.value,j);if(M===null){N===null&&(N=_);break}e&&N&&M.alternate===null&&t(v,N),g=i(M,g,A),w===null?S=M:w.sibling=M,w=M,N=_}if(E.done)return n(v,N),we&&Wr(v,A),S;if(N===null){for(;!E.done;A++,E=b.next())E=d(v,E.value,j),E!==null&&(g=i(E,g,A),w===null?S=E:w.sibling=E,w=E);return we&&Wr(v,A),S}for(N=r(N);!E.done;A++,E=b.next())E=m(N,v,A,E.value,j),E!==null&&(e&&E.alternate!==null&&N.delete(E.key===null?A:E.key),g=i(E,g,A),w===null?S=E:w.sibling=E,w=E);return e&&N.forEach(function(C){return t(v,C)}),we&&Wr(v,A),S}function p(v,g,b,j){if(typeof b=="object"&&b!==null&&b.type===Bl&&b.key===null&&(b=b.props.children),typeof b=="object"&&b!==null){switch(b.$$typeof){case pf:e:{for(var S=b.key;g!==null;){if(g.key===S){if(S=b.type,S===Bl){if(g.tag===7){n(v,g.sibling),j=a(g,b.props.children),j.return=v,v=j;break e}}else if(g.elementType===S||typeof S=="object"&&S!==null&&S.$$typeof===Ca&&Ti(S)===g.type){n(v,g.sibling),j=a(g,b.props),Us(j,b),j.return=v,v=j;break e}n(v,g);break}else t(v,g);g=g.sibling}b.type===Bl?(j=Yi(b.props.children,v.mode,j,b.key),j.return=v,v=j):(j=ed(b.type,b.key,b.props,null,v.mode,j),Us(j,b),j.return=v,v=j)}return l(v);case au:e:{for(S=b.key;g!==null;){if(g.key===S)if(g.tag===4&&g.stateNode.containerInfo===b.containerInfo&&g.stateNode.implementation===b.implementation){n(v,g.sibling),j=a(g,b.children||[]),j.return=v,v=j;break e}else{n(v,g);break}else t(v,g);g=g.sibling}j=Ym(b,v.mode,j),j.return=v,v=j}return l(v);case Ca:return b=Ti(b),p(v,g,b,j)}if(iu(b))return x(v,g,b,j);if(Ls(b)){if(S=Ls(b),typeof S!="function")throw Error(H(150));return b=S.call(b),y(v,g,b,j)}if(typeof b.then=="function")return p(v,g,jf(b),j);if(b.$$typeof===ta)return p(v,g,bf(v,b),j);Sf(v,b)}return typeof b=="string"&&b!==""||typeof b=="number"||typeof b=="bigint"?(b=""+b,g!==null&&g.tag===6?(n(v,g.sibling),j=a(g,b),j.return=v,v=j):(n(v,g),j=Vm(b,v.mode,j),j.return=v,v=j),l(v)):n(v,g)}return function(v,g,b,j){try{Lu=0;var S=p(v,g,b,j);return oo=null,S}catch(N){if(N===ds||N===vp)throw N;var w=An(29,N,null,v.mode);return w.lanes=j,w.return=v,w}finally{}}}var al=VE(!0),YE=VE(!1),Ma=!1;function mb(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function Ty(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,callbacks:null})}function Ja(e){return{lane:e,tag:0,payload:null,callback:null,next:null}}function ei(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,Ae&2){var a=r.pending;return a===null?t.next=t:(t.next=a.next,a.next=t),r.pending=t,t=Nd(e),BE(e,null,n),t}return mp(e,r,t,n),Nd(e)}function mu(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,(n&4194048)!==0)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,dE(e,n)}}function Qm(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var a=null,i=null;if(n=n.firstBaseUpdate,n!==null){do{var l={lane:n.lane,tag:n.tag,payload:n.payload,callback:null,next:null};i===null?a=i=l:i=i.next=l,n=n.next}while(n!==null);i===null?a=i=t:i=i.next=t}else a=i=t;n={baseState:r.baseState,firstBaseUpdate:a,lastBaseUpdate:i,shared:r.shared,callbacks:r.callbacks},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}var Cy=!1;function vu(){if(Cy){var e=lo;if(e!==null)throw e}}function yu(e,t,n,r){Cy=!1;var a=e.updateQueue;Ma=!1;var i=a.firstBaseUpdate,l=a.lastBaseUpdate,s=a.shared.pending;if(s!==null){a.shared.pending=null;var u=s,c=u.next;u.next=null,l===null?i=c:l.next=c,l=u;var f=e.alternate;f!==null&&(f=f.updateQueue,s=f.lastBaseUpdate,s!==l&&(s===null?f.firstBaseUpdate=c:s.next=c,f.lastBaseUpdate=u))}if(i!==null){var d=a.baseState;l=0,f=c=u=null,s=i;do{var h=s.lane&-536870913,m=h!==s.lane;if(m?(Se&h)===h:(r&h)===h){h!==0&&h===No&&(Cy=!0),f!==null&&(f=f.next={lane:0,tag:s.tag,payload:s.payload,callback:null,next:null});e:{var x=e,y=s;h=t;var p=n;switch(y.tag){case 1:if(x=y.payload,typeof x=="function"){d=x.call(p,d,h);break e}d=x;break e;case 3:x.flags=x.flags&-65537|128;case 0:if(x=y.payload,h=typeof x=="function"?x.call(p,d,h):x,h==null)break e;d=We({},d,h);break e;case 2:Ma=!0}}h=s.callback,h!==null&&(e.flags|=64,m&&(e.flags|=8192),m=a.callbacks,m===null?a.callbacks=[h]:m.push(h))}else m={lane:h,tag:s.tag,payload:s.payload,callback:s.callback,next:null},f===null?(c=f=m,u=d):f=f.next=m,l|=h;if(s=s.next,s===null){if(s=a.shared.pending,s===null)break;m=s,s=m.next,m.next=null,a.lastBaseUpdate=m,a.shared.pending=null}}while(!0);f===null&&(u=d),a.baseState=u,a.firstBaseUpdate=c,a.lastBaseUpdate=f,i===null&&(a.shared.lanes=0),di|=l,e.lanes=l,e.memoizedState=d}}function XE(e,t){if(typeof e!="function")throw Error(H(191,e));e.call(t)}function QE(e,t){var n=e.callbacks;if(n!==null)for(e.callbacks=null,e=0;ei?i:8;var l=oe.T,s={};oe.T=s,_b(e,!1,t,n);try{var u=a(),c=oe.S;if(c!==null&&c(s,u),u!==null&&typeof u=="object"&&typeof u.then=="function"){var f=ID(u,r);gu(e,t,f,Mn(e))}else gu(e,t,r,Mn(e))}catch(d){gu(e,t,{then:function(){},status:"rejected",reason:d},Mn())}finally{Ne.p=i,l!==null&&s.types!==null&&(l.types=s.types),oe.T=l}}function VD(){}function Dy(e,t,n,r){if(e.tag!==5)throw Error(H(476));var a=j_(e).queue;b_(e,a,t,Vi,n===null?VD:function(){return S_(e),n(r)})}function j_(e){var t=e.memoizedState;if(t!==null)return t;t={memoizedState:Vi,baseState:Vi,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:pa,lastRenderedState:Vi},next:null};var n={};return t.next={memoizedState:n,baseState:n,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:pa,lastRenderedState:n},next:null},e.memoizedState=t,e=e.alternate,e!==null&&(e.memoizedState=t),t}function S_(e){var t=j_(e);t.next===null&&(t=e.alternate.memoizedState),gu(e,t.next.queue,{},Mn())}function Eb(){return It(Hu)}function w_(){return ot().memoizedState}function O_(){return ot().memoizedState}function YD(e){for(var t=e.return;t!==null;){switch(t.tag){case 24:case 3:var n=Mn();e=Ja(n);var r=ei(t,e,n);r!==null&&(dn(r,t,n),mu(r,t,n)),t={cache:db()},e.payload=t;return}t=t.return}}function XD(e,t,n){var r=Mn();n={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null},bp(e)?N_(t,n):(n=sb(e,t,n,r),n!==null&&(dn(n,e,r),E_(n,t,r)))}function A_(e,t,n){var r=Mn();gu(e,t,n,r)}function gu(e,t,n,r){var a={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null};if(bp(e))N_(t,a);else{var i=e.alternate;if(e.lanes===0&&(i===null||i.lanes===0)&&(i=t.lastRenderedReducer,i!==null))try{var l=t.lastRenderedState,s=i(l,n);if(a.hasEagerState=!0,a.eagerState=s,Rn(s,l))return mp(e,t,a,0),Ue===null&&pp(),!1}catch{}finally{}if(n=sb(e,t,a,r),n!==null)return dn(n,e,r),E_(n,t,r),!0}return!1}function _b(e,t,n,r){if(r={lane:2,revertLane:Lb(),gesture:null,action:r,hasEagerState:!1,eagerState:null,next:null},bp(e)){if(t)throw Error(H(479))}else t=sb(e,n,r,2),t!==null&&dn(t,e,2)}function bp(e){var t=e.alternate;return e===me||t!==null&&t===me}function N_(e,t){so=Pd=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function E_(e,t,n){if(n&4194048){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,dE(e,n)}}var Bu={readContext:It,use:gp,useCallback:tt,useContext:tt,useEffect:tt,useImperativeHandle:tt,useLayoutEffect:tt,useInsertionEffect:tt,useMemo:tt,useReducer:tt,useRef:tt,useState:tt,useDebugValue:tt,useDeferredValue:tt,useTransition:tt,useSyncExternalStore:tt,useId:tt,useHostTransitionStatus:tt,useFormState:tt,useActionState:tt,useOptimistic:tt,useMemoCache:tt,useCacheRefresh:tt};Bu.useEffectEvent=tt;var __={readContext:It,use:gp,useCallback:function(e,t){return Wt().memoizedState=[e,t===void 0?null:t],e},useContext:It,useEffect:lj,useImperativeHandle:function(e,t,n){n=n!=null?n.concat([e]):null,rd(4194308,4,m_.bind(null,t,e),n)},useLayoutEffect:function(e,t){return rd(4194308,4,e,t)},useInsertionEffect:function(e,t){rd(4,2,e,t)},useMemo:function(e,t){var n=Wt();t=t===void 0?null:t;var r=e();if(il){Fa(!0);try{e()}finally{Fa(!1)}}return n.memoizedState=[r,t],r},useReducer:function(e,t,n){var r=Wt();if(n!==void 0){var a=n(t);if(il){Fa(!0);try{n(t)}finally{Fa(!1)}}}else a=t;return r.memoizedState=r.baseState=a,e={pending:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:a},r.queue=e,e=e.dispatch=XD.bind(null,me,e),[r.memoizedState,e]},useRef:function(e){var t=Wt();return e={current:e},t.memoizedState=e},useState:function(e){e=$y(e);var t=e.queue,n=A_.bind(null,me,t);return t.dispatch=n,[e.memoizedState,n]},useDebugValue:Ab,useDeferredValue:function(e,t){var n=Wt();return Nb(n,e,t)},useTransition:function(){var e=$y(!1);return e=b_.bind(null,me,e.queue,!0,!1),Wt().memoizedState=e,[!1,e]},useSyncExternalStore:function(e,t,n){var r=me,a=Wt();if(we){if(n===void 0)throw Error(H(407));n=n()}else{if(n=t(),Ue===null)throw Error(H(349));Se&127||t_(r,t,n)}a.memoizedState=n;var i={value:n,getSnapshot:t};return a.queue=i,lj(r_.bind(null,r,i,e),[e]),r.flags|=2048,_o(9,{destroy:void 0},n_.bind(null,r,i,n,t),null),n},useId:function(){var e=Wt(),t=Ue.identifierPrefix;if(we){var n=Er,r=Nr;n=(r&~(1<<32-Cn(r)-1)).toString(32)+n,t="_"+t+"R_"+n,n=$d++,0<\/script>",i=i.removeChild(i.firstChild);break;case"select":i=typeof r.is=="string"?l.createElement("select",{is:r.is}):l.createElement("select"),r.multiple?i.multiple=!0:r.size&&(i.size=r.size);break;default:i=typeof r.is=="string"?l.createElement(a,{is:r.is}):l.createElement(a)}}i[zt]=t,i[pn]=r;e:for(l=t.child;l!==null;){if(l.tag===5||l.tag===6)i.appendChild(l.stateNode);else if(l.tag!==4&&l.tag!==27&&l.child!==null){l.child.return=l,l=l.child;continue}if(l===t)break e;for(;l.sibling===null;){if(l.return===null||l.return===t)break e;l=l.return}l.sibling.return=l.return,l=l.sibling}t.stateNode=i;e:switch(Ht(i,a,r),a){case"button":case"input":case"select":case"textarea":r=!!r.autoFocus;break e;case"img":r=!0;break e;default:r=!1}r&&Fr(t)}}return Ke(t),av(t,t.type,e===null?null:e.memoizedProps,t.pendingProps,n),null;case 6:if(e&&t.stateNode!=null)e.memoizedProps!==r&&Fr(t);else{if(typeof r!="string"&&t.stateNode===null)throw Error(H(166));if(e=Wa.current,El(t)){if(e=t.stateNode,n=t.memoizedProps,r=null,a=Bt,a!==null)switch(a.tag){case 27:case 5:r=a.memoizedProps}e[zt]=t,e=!!(e.nodeValue===n||r!==null&&r.suppressHydrationWarning===!0||AT(e.nodeValue,n)),e||ci(t,!0)}else e=qd(e).createTextNode(r),e[zt]=t,t.stateNode=e}return Ke(t),null;case 31:if(n=t.memoizedState,e===null||e.memoizedState!==null){if(r=El(t),n!==null){if(e===null){if(!r)throw Error(H(318));if(e=t.memoizedState,e=e!==null?e.dehydrated:null,!e)throw Error(H(557));e[zt]=t}else nl(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;Ke(t),e=!1}else n=Xm(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=n),e=!0;if(!e)return t.flags&256?(On(t),t):(On(t),null);if(t.flags&128)throw Error(H(558))}return Ke(t),null;case 13:if(r=t.memoizedState,e===null||e.memoizedState!==null&&e.memoizedState.dehydrated!==null){if(a=El(t),r!==null&&r.dehydrated!==null){if(e===null){if(!a)throw Error(H(318));if(a=t.memoizedState,a=a!==null?a.dehydrated:null,!a)throw Error(H(317));a[zt]=t}else nl(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;Ke(t),a=!1}else a=Xm(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=a),a=!0;if(!a)return t.flags&256?(On(t),t):(On(t),null)}return On(t),t.flags&128?(t.lanes=n,t):(n=r!==null,e=e!==null&&e.memoizedState!==null,n&&(r=t.child,a=null,r.alternate!==null&&r.alternate.memoizedState!==null&&r.alternate.memoizedState.cachePool!==null&&(a=r.alternate.memoizedState.cachePool.pool),i=null,r.memoizedState!==null&&r.memoizedState.cachePool!==null&&(i=r.memoizedState.cachePool.pool),i!==a&&(r.flags|=2048)),n!==e&&n&&(t.child.flags|=8192),wf(t,t.updateQueue),Ke(t),null);case 4:return wo(),e===null&&zb(t.stateNode.containerInfo),Ke(t),null;case 10:return sa(t.type),Ke(t),null;case 19:if(Mt(it),r=t.memoizedState,r===null)return Ke(t),null;if(a=(t.flags&128)!==0,i=r.rendering,i===null)if(a)Is(r,!1);else{if(at!==0||e!==null&&e.flags&128)for(e=t.child;e!==null;){if(i=Md(e),i!==null){for(t.flags|=128,Is(r,!1),e=i.updateQueue,t.updateQueue=e,wf(t,e),t.subtreeFlags=0,e=n,n=t.child;n!==null;)UE(n,e),n=n.sibling;return qe(it,it.current&1|2),we&&Wr(t,r.treeForkCount),t.child}e=e.sibling}r.tail!==null&&_n()>Ld&&(t.flags|=128,a=!0,Is(r,!1),t.lanes=4194304)}else{if(!a)if(e=Md(i),e!==null){if(t.flags|=128,a=!0,e=e.updateQueue,t.updateQueue=e,wf(t,e),Is(r,!0),r.tail===null&&r.tailMode==="hidden"&&!i.alternate&&!we)return Ke(t),null}else 2*_n()-r.renderingStartTime>Ld&&n!==536870912&&(t.flags|=128,a=!0,Is(r,!1),t.lanes=4194304);r.isBackwards?(i.sibling=t.child,t.child=i):(e=r.last,e!==null?e.sibling=i:t.child=i,r.last=i)}return r.tail!==null?(e=r.tail,r.rendering=e,r.tail=e.sibling,r.renderingStartTime=_n(),e.sibling=null,n=it.current,qe(it,a?n&1|2:n&1),we&&Wr(t,r.treeForkCount),e):(Ke(t),null);case 22:case 23:return On(t),vb(),r=t.memoizedState!==null,e!==null?e.memoizedState!==null!==r&&(t.flags|=8192):r&&(t.flags|=8192),r?n&536870912&&!(t.flags&128)&&(Ke(t),t.subtreeFlags&6&&(t.flags|=8192)):Ke(t),n=t.updateQueue,n!==null&&wf(t,n.retryQueue),n=null,e!==null&&e.memoizedState!==null&&e.memoizedState.cachePool!==null&&(n=e.memoizedState.cachePool.pool),r=null,t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(r=t.memoizedState.cachePool.pool),r!==n&&(t.flags|=2048),e!==null&&Mt(Xi),null;case 24:return n=null,e!==null&&(n=e.memoizedState.cache),t.memoizedState.cache!==n&&(t.flags|=2048),sa(pt),Ke(t),null;case 25:return null;case 30:return null}throw Error(H(156,t.tag))}function e3(e,t){switch(fb(t),t.tag){case 1:return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return sa(pt),wo(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 26:case 27:case 5:return Sd(t),null;case 31:if(t.memoizedState!==null){if(On(t),t.alternate===null)throw Error(H(340));nl()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 13:if(On(t),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(H(340));nl()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return Mt(it),null;case 4:return wo(),null;case 10:return sa(t.type),null;case 22:case 23:return On(t),vb(),e!==null&&Mt(Xi),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 24:return sa(pt),null;case 25:return null;default:return null}}function U_(e,t){switch(fb(t),t.tag){case 3:sa(pt),wo();break;case 26:case 27:case 5:Sd(t);break;case 4:wo();break;case 31:t.memoizedState!==null&&On(t);break;case 13:On(t);break;case 19:Mt(it);break;case 10:sa(t.type);break;case 22:case 23:On(t),vb(),e!==null&&Mt(Xi);break;case 24:sa(pt)}}function Hc(e,t){try{var n=t.updateQueue,r=n!==null?n.lastEffect:null;if(r!==null){var a=r.next;n=a;do{if((n.tag&e)===e){r=void 0;var i=n.create,l=n.inst;r=i(),l.destroy=r}n=n.next}while(n!==a)}}catch(s){Pe(t,t.return,s)}}function fi(e,t,n){try{var r=t.updateQueue,a=r!==null?r.lastEffect:null;if(a!==null){var i=a.next;r=i;do{if((r.tag&e)===e){var l=r.inst,s=l.destroy;if(s!==void 0){l.destroy=void 0,a=t;var u=n,c=s;try{c()}catch(f){Pe(a,u,f)}}}r=r.next}while(r!==i)}}catch(f){Pe(t,t.return,f)}}function I_(e){var t=e.updateQueue;if(t!==null){var n=e.stateNode;try{QE(t,n)}catch(r){Pe(e,e.return,r)}}}function H_(e,t,n){n.props=ll(e.type,e.memoizedProps),n.state=e.memoizedState;try{n.componentWillUnmount()}catch(r){Pe(e,t,r)}}function xu(e,t){try{var n=e.ref;if(n!==null){switch(e.tag){case 26:case 27:case 5:var r=e.stateNode;break;case 30:r=e.stateNode;break;default:r=e.stateNode}typeof n=="function"?e.refCleanup=n(r):n.current=r}}catch(a){Pe(e,t,a)}}function _r(e,t){var n=e.ref,r=e.refCleanup;if(n!==null)if(typeof r=="function")try{r()}catch(a){Pe(e,t,a)}finally{e.refCleanup=null,e=e.alternate,e!=null&&(e.refCleanup=null)}else if(typeof n=="function")try{n(null)}catch(a){Pe(e,t,a)}else n.current=null}function q_(e){var t=e.type,n=e.memoizedProps,r=e.stateNode;try{e:switch(t){case"button":case"input":case"select":case"textarea":n.autoFocus&&r.focus();break e;case"img":n.src?r.src=n.src:n.srcSet&&(r.srcset=n.srcSet)}}catch(a){Pe(e,e.return,a)}}function iv(e,t,n){try{var r=e.stateNode;S3(r,e.type,n,t),r[pn]=t}catch(a){Pe(e,e.return,a)}}function F_(e){return e.tag===5||e.tag===3||e.tag===26||e.tag===27&&mi(e.type)||e.tag===4}function lv(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||F_(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.tag===27&&mi(e.type)||e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Uy(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?(n.nodeType===9?n.body:n.nodeName==="HTML"?n.ownerDocument.body:n).insertBefore(e,t):(t=n.nodeType===9?n.body:n.nodeName==="HTML"?n.ownerDocument.body:n,t.appendChild(e),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=na));else if(r!==4&&(r===27&&mi(e.type)&&(n=e.stateNode,t=null),e=e.child,e!==null))for(Uy(e,t,n),e=e.sibling;e!==null;)Uy(e,t,n),e=e.sibling}function kd(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(r===27&&mi(e.type)&&(n=e.stateNode),e=e.child,e!==null))for(kd(e,t,n),e=e.sibling;e!==null;)kd(e,t,n),e=e.sibling}function G_(e){var t=e.stateNode,n=e.memoizedProps;try{for(var r=e.type,a=t.attributes;a.length;)t.removeAttributeNode(a[0]);Ht(t,r,n),t[zt]=e,t[pn]=n}catch(i){Pe(e,e.return,i)}}var Jr=!1,ht=!1,ov=!1,xj=typeof WeakSet=="function"?WeakSet:Set,_t=null;function t3(e,t){if(e=e.containerInfo,Vy=Vd,e=PE(e),lb(e)){if("selectionStart"in e)var n={start:e.selectionStart,end:e.selectionEnd};else e:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var a=r.anchorOffset,i=r.focusNode;r=r.focusOffset;try{n.nodeType,i.nodeType}catch{n=null;break e}var l=0,s=-1,u=-1,c=0,f=0,d=e,h=null;t:for(;;){for(var m;d!==n||a!==0&&d.nodeType!==3||(s=l+a),d!==i||r!==0&&d.nodeType!==3||(u=l+r),d.nodeType===3&&(l+=d.nodeValue.length),(m=d.firstChild)!==null;)h=d,d=m;for(;;){if(d===e)break t;if(h===n&&++c===a&&(s=l),h===i&&++f===r&&(u=l),(m=d.nextSibling)!==null)break;d=h,h=d.parentNode}d=m}n=s===-1||u===-1?null:{start:s,end:u}}else n=null}n=n||{start:0,end:0}}else n=null;for(Yy={focusedElem:e,selectionRange:n},Vd=!1,_t=t;_t!==null;)if(t=_t,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,_t=e;else for(;_t!==null;){switch(t=_t,i=t.alternate,e=t.flags,t.tag){case 0:if(e&4&&(e=t.updateQueue,e=e!==null?e.events:null,e!==null))for(n=0;n title"))),Ht(i,r,n),i[zt]=e,Tt(i),r=i;break e;case"link":var l=Bj("link","href",a).get(r+(n.href||""));if(l){for(var s=0;sp&&(l=p,p=y,y=l);var v=F1(s,y),g=F1(s,p);if(v&&g&&(m.rangeCount!==1||m.anchorNode!==v.node||m.anchorOffset!==v.offset||m.focusNode!==g.node||m.focusOffset!==g.offset)){var b=d.createRange();b.setStart(v.node,v.offset),m.removeAllRanges(),y>p?(m.addRange(b),m.extend(g.node,g.offset)):(b.setEnd(g.node,g.offset),m.addRange(b))}}}}for(d=[],m=s;m=m.parentNode;)m.nodeType===1&&d.push({element:m,left:m.scrollLeft,top:m.scrollTop});for(typeof s.focus=="function"&&s.focus(),s=0;sn?32:n,oe.T=null,n=qy,qy=null;var i=ni,l=ua;if(St=0,Co=ni=null,ua=0,Ae&6)throw Error(H(331));var s=Ae;if(Ae|=4,nT(i.current),J_(i,i.current,l,n),Ae=s,qc(0,!1),Tn&&typeof Tn.onPostCommitFiberRoot=="function")try{Tn.onPostCommitFiberRoot(Dc,i)}catch{}return!0}finally{Ne.p=a,oe.T=r,yT(e,t)}}function wj(e,t,n){t=Yn(n,t),t=Ly(e.stateNode,t,2),e=ei(e,t,2),e!==null&&(Lc(e,2),zr(e))}function Pe(e,t,n){if(e.tag===3)wj(e,e,n);else for(;t!==null;){if(t.tag===3){wj(t,e,n);break}else if(t.tag===1){var r=t.stateNode;if(typeof t.type.getDerivedStateFromError=="function"||typeof r.componentDidCatch=="function"&&(ti===null||!ti.has(r))){e=Yn(n,e),n=$_(2),r=ei(t,n,2),r!==null&&(R_(n,r,t,e),Lc(r,2),zr(r));break}}t=t.return}}function uv(e,t,n){var r=e.pingCache;if(r===null){r=e.pingCache=new a3;var a=new Set;r.set(t,a)}else a=r.get(t),a===void 0&&(a=new Set,r.set(t,a));a.has(n)||(Rb=!0,a.add(n),e=u3.bind(null,e,t,n),t.then(e,e))}function u3(e,t,n){var r=e.pingCache;r!==null&&r.delete(t),e.pingedLanes|=e.suspendedLanes&n,e.warmLanes&=~n,Ue===e&&(Se&n)===n&&(at===4||at===3&&(Se&62914560)===Se&&300>_n()-jp?!(Ae&2)&&Mo(e,0):Db|=n,To===Se&&(To=0)),zr(e)}function xT(e,t){t===0&&(t=cE()),e=xl(e,t),e!==null&&(Lc(e,t),zr(e))}function c3(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),xT(e,n)}function f3(e,t){var n=0;switch(e.tag){case 31:case 13:var r=e.stateNode,a=e.memoizedState;a!==null&&(n=a.retryLane);break;case 19:r=e.stateNode;break;case 22:r=e.stateNode._retryCache;break;default:throw Error(H(314))}r!==null&&r.delete(t),xT(e,n)}function d3(e,t){return Wx(e,t)}var Ud=null,zl=null,Gy=!1,Id=!1,cv=!1,Ya=0;function zr(e){e!==zl&&e.next===null&&(zl===null?Ud=zl=e:zl=zl.next=e),Id=!0,Gy||(Gy=!0,p3())}function qc(e,t){if(!cv&&Id){cv=!0;do for(var n=!1,r=Ud;r!==null;){if(e!==0){var a=r.pendingLanes;if(a===0)var i=0;else{var l=r.suspendedLanes,s=r.pingedLanes;i=(1<<31-Cn(42|e)+1)-1,i&=a&~(l&~s),i=i&201326741?i&201326741|1:i?i|2:0}i!==0&&(n=!0,Oj(r,i))}else i=Se,i=cp(r,r===Ue?i:0,r.cancelPendingCommit!==null||r.timeoutHandle!==-1),!(i&3)||kc(r,i)||(n=!0,Oj(r,i));r=r.next}while(n);cv=!1}}function h3(){bT()}function bT(){Id=Gy=!1;var e=0;Ya!==0&&O3()&&(e=Ya);for(var t=_n(),n=null,r=Ud;r!==null;){var a=r.next,i=jT(r,t);i===0?(r.next=null,n===null?Ud=a:n.next=a,a===null&&(zl=n)):(n=r,(e!==0||i&3)&&(Id=!0)),r=a}St!==0&&St!==5||qc(e),Ya!==0&&(Ya=0)}function jT(e,t){for(var n=e.suspendedLanes,r=e.pingedLanes,a=e.expirationTimes,i=e.pendingLanes&-62914561;0s)break;var f=u.transferSize,d=u.initiatorType;f&&Tj(d)&&(u=u.responseEnd,l+=f*(u"u"?null:document;function CT(e,t,n){var r=ps;if(r&&typeof t=="string"&&t){var a=Vn(t);a='link[rel="'+e+'"][href="'+a+'"]',typeof n=="string"&&(a+='[crossorigin="'+n+'"]'),kj.has(a)||(kj.add(a),e={rel:e,crossOrigin:n,href:t},r.querySelector(a)===null&&(t=r.createElement("link"),Ht(t,"link",e),Tt(t),r.head.appendChild(t)))}}function $3(e){ja.D(e),CT("dns-prefetch",e,null)}function R3(e,t){ja.C(e,t),CT("preconnect",e,t)}function D3(e,t,n){ja.L(e,t,n);var r=ps;if(r&&e&&t){var a='link[rel="preload"][as="'+Vn(t)+'"]';t==="image"&&n&&n.imageSrcSet?(a+='[imagesrcset="'+Vn(n.imageSrcSet)+'"]',typeof n.imageSizes=="string"&&(a+='[imagesizes="'+Vn(n.imageSizes)+'"]')):a+='[href="'+Vn(e)+'"]';var i=a;switch(t){case"style":i=Po(e);break;case"script":i=ms(e)}er.has(i)||(e=We({rel:"preload",href:t==="image"&&n&&n.imageSrcSet?void 0:e,as:t},n),er.set(i,e),r.querySelector(a)!==null||t==="style"&&r.querySelector(Fc(i))||t==="script"&&r.querySelector(Gc(i))||(t=r.createElement("link"),Ht(t,"link",e),Tt(t),r.head.appendChild(t)))}}function k3(e,t){ja.m(e,t);var n=ps;if(n&&e){var r=t&&typeof t.as=="string"?t.as:"script",a='link[rel="modulepreload"][as="'+Vn(r)+'"][href="'+Vn(e)+'"]',i=a;switch(r){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":i=ms(e)}if(!er.has(i)&&(e=We({rel:"modulepreload",href:e},t),er.set(i,e),n.querySelector(a)===null)){switch(r){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":if(n.querySelector(Gc(i)))return}r=n.createElement("link"),Ht(r,"link",e),Tt(r),n.head.appendChild(r)}}}function L3(e,t,n){ja.S(e,t,n);var r=ps;if(r&&e){var a=ro(r).hoistableStyles,i=Po(e);t=t||"default";var l=a.get(i);if(!l){var s={loading:0,preload:null};if(l=r.querySelector(Fc(i)))s.loading=5;else{e=We({rel:"stylesheet",href:e,"data-precedence":t},n),(n=er.get(i))&&Bb(e,n);var u=l=r.createElement("link");Tt(u),Ht(u,"link",e),u._p=new Promise(function(c,f){u.onload=c,u.onerror=f}),u.addEventListener("load",function(){s.loading|=1}),u.addEventListener("error",function(){s.loading|=2}),s.loading|=4,od(l,t,r)}l={type:"stylesheet",instance:l,count:1,state:s},a.set(i,l)}}}function z3(e,t){ja.X(e,t);var n=ps;if(n&&e){var r=ro(n).hoistableScripts,a=ms(e),i=r.get(a);i||(i=n.querySelector(Gc(a)),i||(e=We({src:e,async:!0},t),(t=er.get(a))&&Ub(e,t),i=n.createElement("script"),Tt(i),Ht(i,"link",e),n.head.appendChild(i)),i={type:"script",instance:i,count:1,state:null},r.set(a,i))}}function B3(e,t){ja.M(e,t);var n=ps;if(n&&e){var r=ro(n).hoistableScripts,a=ms(e),i=r.get(a);i||(i=n.querySelector(Gc(a)),i||(e=We({src:e,async:!0,type:"module"},t),(t=er.get(a))&&Ub(e,t),i=n.createElement("script"),Tt(i),Ht(i,"link",e),n.head.appendChild(i)),i={type:"script",instance:i,count:1,state:null},r.set(a,i))}}function Lj(e,t,n,r){var a=(a=Wa.current)?Fd(a):null;if(!a)throw Error(H(446));switch(e){case"meta":case"title":return null;case"style":return typeof n.precedence=="string"&&typeof n.href=="string"?(t=Po(n.href),n=ro(a).hoistableStyles,r=n.get(t),r||(r={type:"style",instance:null,count:0,state:null},n.set(t,r)),r):{type:"void",instance:null,count:0,state:null};case"link":if(n.rel==="stylesheet"&&typeof n.href=="string"&&typeof n.precedence=="string"){e=Po(n.href);var i=ro(a).hoistableStyles,l=i.get(e);if(l||(a=a.ownerDocument||a,l={type:"stylesheet",instance:null,count:0,state:{loading:0,preload:null}},i.set(e,l),(i=a.querySelector(Fc(e)))&&!i._p&&(l.instance=i,l.state.loading=5),er.has(e)||(n={rel:"preload",as:"style",href:n.href,crossOrigin:n.crossOrigin,integrity:n.integrity,media:n.media,hrefLang:n.hrefLang,referrerPolicy:n.referrerPolicy},er.set(e,n),i||U3(a,e,n,l.state))),t&&r===null)throw Error(H(528,""));return l}if(t&&r!==null)throw Error(H(529,""));return null;case"script":return t=n.async,n=n.src,typeof n=="string"&&t&&typeof t!="function"&&typeof t!="symbol"?(t=ms(n),n=ro(a).hoistableScripts,r=n.get(t),r||(r={type:"script",instance:null,count:0,state:null},n.set(t,r)),r):{type:"void",instance:null,count:0,state:null};default:throw Error(H(444,e))}}function Po(e){return'href="'+Vn(e)+'"'}function Fc(e){return'link[rel="stylesheet"]['+e+"]"}function MT(e){return We({},e,{"data-precedence":e.precedence,precedence:null})}function U3(e,t,n,r){e.querySelector('link[rel="preload"][as="style"]['+t+"]")?r.loading=1:(t=e.createElement("link"),r.preload=t,t.addEventListener("load",function(){return r.loading|=1}),t.addEventListener("error",function(){return r.loading|=2}),Ht(t,"link",n),Tt(t),e.head.appendChild(t))}function ms(e){return'[src="'+Vn(e)+'"]'}function Gc(e){return"script[async]"+e}function zj(e,t,n){if(t.count++,t.instance===null)switch(t.type){case"style":var r=e.querySelector('style[data-href~="'+Vn(n.href)+'"]');if(r)return t.instance=r,Tt(r),r;var a=We({},n,{"data-href":n.href,"data-precedence":n.precedence,href:null,precedence:null});return r=(e.ownerDocument||e).createElement("style"),Tt(r),Ht(r,"style",a),od(r,n.precedence,e),t.instance=r;case"stylesheet":a=Po(n.href);var i=e.querySelector(Fc(a));if(i)return t.state.loading|=4,t.instance=i,Tt(i),i;r=MT(n),(a=er.get(a))&&Bb(r,a),i=(e.ownerDocument||e).createElement("link"),Tt(i);var l=i;return l._p=new Promise(function(s,u){l.onload=s,l.onerror=u}),Ht(i,"link",r),t.state.loading|=4,od(i,n.precedence,e),t.instance=i;case"script":return i=ms(n.src),(a=e.querySelector(Gc(i)))?(t.instance=a,Tt(a),a):(r=n,(a=er.get(i))&&(r=We({},n),Ub(r,a)),e=e.ownerDocument||e,a=e.createElement("script"),Tt(a),Ht(a,"link",r),e.head.appendChild(a),t.instance=a);case"void":return null;default:throw Error(H(443,t.type))}else t.type==="stylesheet"&&!(t.state.loading&4)&&(r=t.instance,t.state.loading|=4,od(r,n.precedence,e));return t.instance}function od(e,t,n){for(var r=n.querySelectorAll('link[rel="stylesheet"][data-precedence],style[data-precedence]'),a=r.length?r[r.length-1]:null,i=a,l=0;l title"):null)}function I3(e,t,n){if(n===1||t.itemProp!=null)return!1;switch(e){case"meta":case"title":return!0;case"style":if(typeof t.precedence!="string"||typeof t.href!="string"||t.href==="")break;return!0;case"link":if(typeof t.rel!="string"||typeof t.href!="string"||t.href===""||t.onLoad||t.onError)break;switch(t.rel){case"stylesheet":return e=t.disabled,typeof t.precedence=="string"&&e==null;default:return!0}case"script":if(t.async&&typeof t.async!="function"&&typeof t.async!="symbol"&&!t.onLoad&&!t.onError&&t.src&&typeof t.src=="string")return!0}return!1}function PT(e){return!(e.type==="stylesheet"&&!(e.state.loading&3))}function H3(e,t,n,r){if(n.type==="stylesheet"&&(typeof r.media!="string"||matchMedia(r.media).matches!==!1)&&!(n.state.loading&4)){if(n.instance===null){var a=Po(r.href),i=t.querySelector(Fc(a));if(i){t=i._p,t!==null&&typeof t=="object"&&typeof t.then=="function"&&(e.count++,e=Gd.bind(e),t.then(e,e)),n.state.loading|=4,n.instance=i,Tt(i);return}i=t.ownerDocument||t,r=MT(r),(a=er.get(a))&&Bb(r,a),i=i.createElement("link"),Tt(i);var l=i;l._p=new Promise(function(s,u){l.onload=s,l.onerror=u}),Ht(i,"link",r),n.instance=i}e.stylesheets===null&&(e.stylesheets=new Map),e.stylesheets.set(n,t),(t=n.state.preload)&&!(n.state.loading&3)&&(e.count++,n=Gd.bind(e),t.addEventListener("load",n),t.addEventListener("error",n))}}var vv=0;function q3(e,t){return e.stylesheets&&e.count===0&&ud(e,e.stylesheets),0vv?50:800)+t);return e.unsuspend=n,function(){e.unsuspend=null,clearTimeout(r),clearTimeout(a)}}:null}function Gd(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)ud(this,this.stylesheets);else if(this.unsuspend){var e=this.unsuspend;this.unsuspend=null,e()}}}var Kd=null;function ud(e,t){e.stylesheets=null,e.unsuspend!==null&&(e.count++,Kd=new Map,t.forEach(F3,e),Kd=null,Gd.call(e))}function F3(e,t){if(!(t.state.loading&4)){var n=Kd.get(e);if(n)var r=n.get(null);else{n=new Map,Kd.set(e,n);for(var a=e.querySelectorAll("link[data-precedence],style[data-precedence]"),i=0;i"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(UT)}catch(e){console.error(e)}}UT(),YN.exports=sp;var Z3=YN.exports;const J3=_e(Z3);/** + * react-router v7.18.1 + * + * Copyright (c) Remix Software Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE.md file in the root directory of this source tree. + * + * @license MIT + */var Gb=/^(?:[a-z][a-z0-9+.-]*:|[\\/]{2})/i,IT=/^[\\/]{2}/;function e4(e,t){return t+e.replace(/\\/g,"/")}var Vj="popstate";function Yj(e){return typeof e=="object"&&e!=null&&"pathname"in e&&"search"in e&&"hash"in e&&"state"in e&&"key"in e}function t4(e={}){function t(r,a){var c;let i=(c=a.state)==null?void 0:c.masked,{pathname:l,search:s,hash:u}=i||r.location;return ng("",{pathname:l,search:s,hash:u},a.state&&a.state.usr||null,a.state&&a.state.key||"default",i?{pathname:r.location.pathname,search:r.location.search,hash:r.location.hash}:void 0)}function n(r,a){return typeof a=="string"?a:Gu(a)}return r4(t,n,null,e)}function et(e,t){if(e===!1||e===null||typeof e>"u")throw new Error(t)}function mr(e,t){if(!e){typeof console<"u"&&console.warn(t);try{throw new Error(t)}catch{}}}function n4(){return Math.random().toString(36).substring(2,10)}function Xj(e,t){return{usr:e.state,key:e.key,idx:t,masked:e.mask?{pathname:e.pathname,search:e.search,hash:e.hash}:void 0}}function ng(e,t,n=null,r,a){return{pathname:typeof e=="string"?e:e.pathname,search:"",hash:"",...typeof t=="string"?vs(t):t,state:n,key:t&&t.key||r||n4(),mask:a}}function Gu({pathname:e="/",search:t="",hash:n=""}){return t&&t!=="?"&&(e+=t.charAt(0)==="?"?t:"?"+t),n&&n!=="#"&&(e+=n.charAt(0)==="#"?n:"#"+n),e}function vs(e){let t={};if(e){let n=e.indexOf("#");n>=0&&(t.hash=e.substring(n),e=e.substring(0,n));let r=e.indexOf("?");r>=0&&(t.search=e.substring(r),e=e.substring(0,r)),e&&(t.pathname=e)}return t}function r4(e,t,n,r={}){let{window:a=document.defaultView,v5Compat:i=!1}=r,l=a.history,s="POP",u=null,c=f();c==null&&(c=0,l.replaceState({...l.state,idx:c},""));function f(){return(l.state||{idx:null}).idx}function d(){s="POP";let p=f(),v=p==null?null:p-c;c=p,u&&u({action:s,location:y.location,delta:v})}function h(p,v){s="PUSH";let g=Yj(p)?p:ng(y.location,p,v);c=f()+1;let b=Xj(g,c),j=y.createHref(g.mask||g);try{l.pushState(b,"",j)}catch(S){if(S instanceof DOMException&&S.name==="DataCloneError")throw S;a.location.assign(j)}i&&u&&u({action:s,location:y.location,delta:1})}function m(p,v){s="REPLACE";let g=Yj(p)?p:ng(y.location,p,v);c=f();let b=Xj(g,c),j=y.createHref(g.mask||g);l.replaceState(b,"",j),i&&u&&u({action:s,location:y.location,delta:0})}function x(p){return a4(a,p)}let y={get action(){return s},get location(){return e(a,l)},listen(p){if(u)throw new Error("A history only accepts one active listener");return a.addEventListener(Vj,d),u=p,()=>{a.removeEventListener(Vj,d),u=null}},createHref(p){return t(a,p)},createURL:x,encodeLocation(p){let v=x(p);return{pathname:v.pathname,search:v.search,hash:v.hash}},push:h,replace:m,go(p){return l.go(p)}};return y}function a4(e,t,n=!1){let r="http://localhost";e&&(r=e.location.origin!=="null"?e.location.origin:e.location.href),et(r,"No window.location.(origin|href) available to create URL");let a=typeof t=="string"?t:Gu(t);return a=a.replace(/ $/,"%20"),!n&&IT.test(a)&&(a=r+a),new URL(a,r)}function HT(e,t,n="/"){return i4(e,t,n,!1)}function i4(e,t,n,r,a){let i=typeof t=="string"?vs(t):t,l=ya(i.pathname||"/",n);if(l==null)return null;let s=l4(e),u=null,c=y4(l);for(let f=0;u==null&&f{let f={relativePath:c===void 0?l.path||"":c,caseSensitive:l.caseSensitive===!0,childrenIndex:s,route:l};if(f.relativePath.startsWith("/")){if(!f.relativePath.startsWith(r)&&u)return;et(f.relativePath.startsWith(r),`Absolute route path "${f.relativePath}" nested under path "${r}" is not valid. An absolute child route path must start with the combined path of all its parent routes.`),f.relativePath=f.relativePath.slice(r.length)}let d=hr([r,f.relativePath]),h=n.concat(f);l.children&&l.children.length>0&&(et(l.index!==!0,`Index routes must not have child routes. Please remove all child routes from route path "${d}".`),qT(l.children,t,h,d,u)),!(l.path==null&&!l.index)&&t.push({path:d,score:p4(d,l.index),routesMeta:h.map((m,x)=>{let[y,p]=KT(m.relativePath,m.caseSensitive,x===h.length-1);return{...m,matcher:y,compiledParams:p}})})};return e.forEach((l,s)=>{var u;if(l.path===""||!((u=l.path)!=null&&u.includes("?")))i(l,s);else for(let c of FT(l.path))i(l,s,!0,c)}),t}function FT(e){let t=e.split("/");if(t.length===0)return[];let[n,...r]=t,a=n.endsWith("?"),i=n.replace(/\?$/,"");if(r.length===0)return a?[i,""]:[i];let l=FT(r.join("/")),s=[];return s.push(...l.map(u=>u===""?i:[i,u].join("/"))),a&&s.push(...l),s.map(u=>e.startsWith("/")&&u===""?"/":u)}function o4(e){e.sort((t,n)=>t.score!==n.score?n.score-t.score:m4(t.routesMeta.map(r=>r.childrenIndex),n.routesMeta.map(r=>r.childrenIndex)))}var s4=/^:[\w-]+$/,u4=3,c4=2,f4=1,d4=10,h4=-2,Qj=e=>e==="*";function p4(e,t){let n=e.split("/"),r=n.length;return n.some(Qj)&&(r+=h4),t&&(r+=c4),n.filter(a=>!Qj(a)).reduce((a,i)=>a+(s4.test(i)?u4:i===""?f4:d4),r)}function m4(e,t){return e.length===t.length&&e.slice(0,-1).every((r,a)=>r===t[a])?e[e.length-1]-t[t.length-1]:0}function v4(e,t,n=!1){let{routesMeta:r}=e,a={},i="/",l=[];for(let s=0;s{if(f==="*"){let x=s[h]||"";l=i.slice(0,i.length-x.length).replace(/(.)\/+$/,"$1")}const m=s[h];return d&&!m?c[f]=void 0:c[f]=(m||"").replace(/%2F/g,"/"),c},{}),pathname:i,pathnameBase:l,pattern:e}}function KT(e,t=!1,n=!0){mr(e==="*"||!e.endsWith("*")||e.endsWith("/*"),`Route path "${e}" will be treated as if it were "${e.replace(/\*$/,"/*")}" because the \`*\` character must always follow a \`/\` in the pattern. To get rid of this warning, please change the route path to "${e.replace(/\*$/,"/*")}".`);let r=[],a="^"+e.replace(/\/*\*?$/,"").replace(/^\/*/,"/").replace(/[\\.*+^${}|()[\]]/g,"\\$&").replace(/\/:([\w-]+)(\?)?/g,(l,s,u,c,f)=>{if(r.push({paramName:s,isOptional:u!=null}),u){let d=f.charAt(c+l.length);return d&&d!=="/"?"/([^\\/]*)":"(?:/([^\\/]*))?"}return"/([^\\/]+)"}).replace(/\/([\w-]+)\?(\/|$)/g,"(/$1)?$2");return e.endsWith("*")?(r.push({paramName:"*"}),a+=e==="*"||e==="/*"?"(.*)$":"(?:\\/(.+)|\\/*)$"):n?a+="\\/*$":e!==""&&e!=="/"&&(a+="(?:(?=\\/|$))"),[new RegExp(a,t?void 0:"i"),r]}function y4(e){try{return e.split("/").map(t=>decodeURIComponent(t).replace(/\//g,"%2F")).join("/")}catch(t){return mr(!1,`The URL path "${e}" could not be decoded because it is a malformed URL segment. This is probably due to a bad percent encoding (${t}).`),e}}function ya(e,t){if(t==="/")return e;if(!e.toLowerCase().startsWith(t.toLowerCase()))return null;let n=t.endsWith("/")?t.length-1:t.length,r=e.charAt(n);return r&&r!=="/"?null:e.slice(n)||"/"}function g4(e,t="/"){let{pathname:n,search:r="",hash:a=""}=typeof e=="string"?vs(e):e,i;return n?(n=VT(n),n.startsWith("/")?i=Wj(n.substring(1),"/"):i=Wj(n,t)):i=t,{pathname:i,search:j4(r),hash:S4(a)}}function Wj(e,t){let n=Qd(t).split("/");return e.split("/").forEach(a=>{a===".."?n.length>1&&n.pop():a!=="."&&n.push(a)}),n.length>1?n.join("/"):"/"}function yv(e,t,n,r){return`Cannot include a '${e}' character in a manually specified \`to.${t}\` field [${JSON.stringify(r)}]. Please separate it out to the \`to.${n}\` field. Alternatively you may provide the full path as a string in and the router will parse it for you.`}function x4(e){return e.filter((t,n)=>n===0||t.route.path&&t.route.path.length>0)}function Kb(e){let t=x4(e);return t.map((n,r)=>r===t.length-1?n.pathname:n.pathnameBase)}function Np(e,t,n,r=!1){let a;typeof e=="string"?a=vs(e):(a={...e},et(!a.pathname||!a.pathname.includes("?"),yv("?","pathname","search",a)),et(!a.pathname||!a.pathname.includes("#"),yv("#","pathname","hash",a)),et(!a.search||!a.search.includes("#"),yv("#","search","hash",a)));let i=e===""||a.pathname==="",l=i?"/":a.pathname,s;if(l==null)s=n;else{let d=t.length-1;if(!r&&l.startsWith("..")){let h=l.split("/");for(;h[0]==="..";)h.shift(),d-=1;a.pathname=h.join("/")}s=d>=0?t[d]:"/"}let u=g4(a,s),c=l&&l!=="/"&&l.endsWith("/"),f=(i||l===".")&&n.endsWith("/");return!u.pathname.endsWith("/")&&(c||f)&&(u.pathname+="/"),u}var VT=e=>e.replace(/[\\/]{2,}/g,"/"),hr=e=>VT(e.join("/")),Qd=e=>e.replace(/\/+$/,""),b4=e=>Qd(e).replace(/^\/*/,"/"),j4=e=>!e||e==="?"?"":e.startsWith("?")?e:"?"+e,S4=e=>!e||e==="#"?"":e.startsWith("#")?e:"#"+e,w4=class{constructor(e,t,n,r=!1){this.status=e,this.statusText=t||"",this.internal=r,n instanceof Error?(this.data=n.toString(),this.error=n):this.data=n}};function O4(e){return e!=null&&typeof e.status=="number"&&typeof e.statusText=="string"&&typeof e.internal=="boolean"&&"data"in e}function A4(e){let t=e.map(n=>n.route.path).filter(Boolean);return hr(t)||"/"}var YT=typeof window<"u"&&typeof window.document<"u"&&typeof window.document.createElement<"u";function XT(e,t){let n=e;if(typeof n!="string"||!Gb.test(n))return{absoluteURL:void 0,isExternal:!1,to:n};let r=n,a=!1;if(YT)try{let i=new URL(window.location.href),l=IT.test(n)?new URL(e4(n,i.protocol)):new URL(n),s=ya(l.pathname,t);l.origin===i.origin&&s!=null?n=s+l.search+l.hash:a=!0}catch{mr(!1,` contains an invalid URL which will probably break when clicked - please update to a valid URL path.`)}return{absoluteURL:r,isExternal:a,to:n}}Object.getOwnPropertyNames(Object.prototype).sort().join("\0");var QT=["POST","PUT","PATCH","DELETE"];new Set(QT);var N4=["GET",...QT];new Set(N4);var E4=["about:","blob:","chrome:","chrome-untrusted:","content:","data:","devtools:","file:","filesystem:","javascript:"];function _4(e){try{return E4.includes(new URL(e).protocol)}catch{return!1}}var ys=O.createContext(null);ys.displayName="DataRouter";var Ep=O.createContext(null);Ep.displayName="DataRouterState";var WT=O.createContext(!1);function T4(){return O.useContext(WT)}var ZT=O.createContext({isTransitioning:!1});ZT.displayName="ViewTransition";var C4=O.createContext(new Map);C4.displayName="Fetchers";var M4=O.createContext(null);M4.displayName="Await";var kn=O.createContext(null);kn.displayName="Navigation";var Kc=O.createContext(null);Kc.displayName="Location";var gr=O.createContext({outlet:null,matches:[],isDataRoute:!1});gr.displayName="Route";var Vb=O.createContext(null);Vb.displayName="RouteError";var JT="REACT_ROUTER_ERROR",P4="REDIRECT",$4="ROUTE_ERROR_RESPONSE";function R4(e){if(e.startsWith(`${JT}:${P4}:{`))try{let t=JSON.parse(e.slice(28));if(typeof t=="object"&&t&&typeof t.status=="number"&&typeof t.statusText=="string"&&typeof t.location=="string"&&typeof t.reloadDocument=="boolean"&&typeof t.replace=="boolean")return t}catch{}}function D4(e){if(e.startsWith(`${JT}:${$4}:{`))try{let t=JSON.parse(e.slice(40));if(typeof t=="object"&&t&&typeof t.status=="number"&&typeof t.statusText=="string")return new w4(t.status,t.statusText,t.data)}catch{}}function k4(e,{relative:t}={}){et(gs(),"useHref() may be used only in the context of a component.");let{basename:n,navigator:r}=O.useContext(kn),{hash:a,pathname:i,search:l}=Vc(e,{relative:t}),s=i;return n!=="/"&&(s=i==="/"?n:hr([n,i])),r.createHref({pathname:s,search:l,hash:a})}function gs(){return O.useContext(Kc)!=null}function Br(){return et(gs(),"useLocation() may be used only in the context of a component."),O.useContext(Kc).location}var eC="You should call navigate() in a React.useEffect(), not when your component is first rendered.";function tC(e){O.useContext(kn).static||O.useLayoutEffect(e)}function _p(){let{isDataRoute:e}=O.useContext(gr);return e?W4():L4()}function L4(){et(gs(),"useNavigate() may be used only in the context of a component.");let e=O.useContext(ys),{basename:t,navigator:n}=O.useContext(kn),{matches:r}=O.useContext(gr),{pathname:a}=Br(),i=JSON.stringify(Kb(r)),l=O.useRef(!1);return tC(()=>{l.current=!0}),O.useCallback((u,c={})=>{if(mr(l.current,eC),!l.current)return;if(typeof u=="number"){n.go(u);return}let f=Np(u,JSON.parse(i),a,c.relative==="path");e==null&&t!=="/"&&(f.pathname=f.pathname==="/"?t:hr([t,f.pathname])),(c.replace?n.replace:n.push)(f,c.state,c)},[t,n,i,a,e])}var z4=O.createContext(null);function B4(e){let t=O.useContext(gr).outlet;return O.useMemo(()=>t&&O.createElement(z4.Provider,{value:e},t),[t,e])}function Vc(e,{relative:t}={}){let{matches:n}=O.useContext(gr),{pathname:r}=Br(),a=JSON.stringify(Kb(n));return O.useMemo(()=>Np(e,JSON.parse(a),r,t==="path"),[e,a,r,t])}function U4(e,t){return nC(e,t)}function nC(e,t,n){var p;et(gs(),"useRoutes() may be used only in the context of a component.");let{navigator:r}=O.useContext(kn),{matches:a}=O.useContext(gr),i=a[a.length-1],l=i?i.params:{},s=i?i.pathname:"/",u=i?i.pathnameBase:"/",c=i&&i.route;{let v=c&&c.path||"";aC(s,!c||v.endsWith("*")||v.endsWith("*?"),`You rendered descendant (or called \`useRoutes()\`) at "${s}" (under ) but the parent route path has no trailing "*". This means if you navigate deeper, the parent won't match anymore and therefore the child routes will never render. + +Please change the parent to .`)}let f=Br(),d;if(t){let v=typeof t=="string"?vs(t):t;et(u==="/"||((p=v.pathname)==null?void 0:p.startsWith(u)),`When overriding the location using \`\` or \`useRoutes(routes, location)\`, the location pathname must begin with the portion of the URL pathname that was matched by all parent routes. The current pathname base is "${u}" but pathname "${v.pathname}" was given in the \`location\` prop.`),d=v}else d=f;let h=d.pathname||"/",m=h;if(u!=="/"){let v=u.replace(/^\//,"").split("/");m="/"+h.replace(/^\//,"").split("/").slice(v.length).join("/")}let x=n&&n.state.matches.length?n.state.matches.map(v=>Object.assign(v,{route:n.manifest[v.route.id]||v.route})):HT(e,{pathname:m});mr(c||x!=null,`No routes matched location "${d.pathname}${d.search}${d.hash}" `),mr(x==null||x[x.length-1].route.element!==void 0||x[x.length-1].route.Component!==void 0||x[x.length-1].route.lazy!==void 0,`Matched leaf route at location "${d.pathname}${d.search}${d.hash}" does not have an element or Component. This means it will render an with a null value by default resulting in an "empty" page.`);let y=G4(x&&x.map(v=>Object.assign({},v,{params:Object.assign({},l,v.params),pathname:hr([u,r.encodeLocation?r.encodeLocation(v.pathname.replace(/%/g,"%25").replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:v.pathname]),pathnameBase:v.pathnameBase==="/"?u:hr([u,r.encodeLocation?r.encodeLocation(v.pathnameBase.replace(/%/g,"%25").replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:v.pathnameBase])})),a,n);return t&&y?O.createElement(Kc.Provider,{value:{location:{pathname:"/",search:"",hash:"",state:null,key:"default",mask:void 0,...d},navigationType:"POP"}},y):y}function I4(){let e=Q4(),t=O4(e)?`${e.status} ${e.statusText}`:e instanceof Error?e.message:JSON.stringify(e),n=e instanceof Error?e.stack:null,r="rgba(200,200,200, 0.5)",a={padding:"0.5rem",backgroundColor:r},i={padding:"2px 4px",backgroundColor:r},l=null;return console.error("Error handled by React Router default ErrorBoundary:",e),l=O.createElement(O.Fragment,null,O.createElement("p",null,"💿 Hey developer 👋"),O.createElement("p",null,"You can provide a way better UX than this when your app throws errors by providing your own ",O.createElement("code",{style:i},"ErrorBoundary")," or"," ",O.createElement("code",{style:i},"errorElement")," prop on your route.")),O.createElement(O.Fragment,null,O.createElement("h2",null,"Unexpected Application Error!"),O.createElement("h3",{style:{fontStyle:"italic"}},t),n?O.createElement("pre",{style:a},n):null,l)}var H4=O.createElement(I4,null),rC=class extends O.Component{constructor(e){super(e),this.state={location:e.location,revalidation:e.revalidation,error:e.error}}static getDerivedStateFromError(e){return{error:e}}static getDerivedStateFromProps(e,t){return t.location!==e.location||t.revalidation!=="idle"&&e.revalidation==="idle"?{error:e.error,location:e.location,revalidation:e.revalidation}:{error:e.error!==void 0?e.error:t.error,location:t.location,revalidation:e.revalidation||t.revalidation}}componentDidCatch(e,t){this.props.onError?this.props.onError(e,t):console.error("React Router caught the following error during render",e)}render(){let e=this.state.error;if(this.context&&typeof e=="object"&&e&&"digest"in e&&typeof e.digest=="string"){const n=D4(e.digest);n&&(e=n)}let t=e!==void 0?O.createElement(gr.Provider,{value:this.props.routeContext},O.createElement(Vb.Provider,{value:e,children:this.props.component})):this.props.children;return this.context?O.createElement(q4,{error:e},t):t}};rC.contextType=WT;var gv=new WeakMap;function q4({children:e,error:t}){let{basename:n}=O.useContext(kn);if(typeof t=="object"&&t&&"digest"in t&&typeof t.digest=="string"){let r=R4(t.digest);if(r){let a=gv.get(t);if(a)throw a;let i=XT(r.location,n),l=i.absoluteURL||i.to;if(_4(l))throw new Error("Invalid redirect location");if(YT&&!gv.get(t))if(i.isExternal||r.reloadDocument)window.location.href=l;else{const s=Promise.resolve().then(()=>window.__reactRouterDataRouter.navigate(i.to,{replace:r.replace}));throw gv.set(t,s),s}return O.createElement("meta",{httpEquiv:"refresh",content:`0;url=${l}`})}}return e}function F4({routeContext:e,match:t,children:n}){let r=O.useContext(ys);return r&&r.static&&r.staticContext&&(t.route.errorElement||t.route.ErrorBoundary)&&(r.staticContext._deepestRenderedBoundaryId=t.route.id),O.createElement(gr.Provider,{value:e},n)}function G4(e,t=[],n){let r=n==null?void 0:n.state;if(e==null){if(!r)return null;if(r.errors)e=r.matches;else if(t.length===0&&!r.initialized&&r.matches.length>0)e=r.matches;else return null}let a=e,i=r==null?void 0:r.errors;if(i!=null){let f=a.findIndex(d=>d.route.id&&(i==null?void 0:i[d.route.id])!==void 0);et(f>=0,`Could not find a matching route for errors on route IDs: ${Object.keys(i).join(",")}`),a=a.slice(0,Math.min(a.length,f+1))}let l=!1,s=-1;if(n&&r){l=r.renderFallback;for(let f=0;f=0?a=a.slice(0,s+1):a=[a[0]];break}}}}let u=n==null?void 0:n.onError,c=r&&u?(f,d)=>{var h,m;u(f,{location:r.location,params:((m=(h=r.matches)==null?void 0:h[0])==null?void 0:m.params)??{},pattern:A4(r.matches),errorInfo:d})}:void 0;return a.reduceRight((f,d,h)=>{let m,x=!1,y=null,p=null;r&&(m=i&&d.route.id?i[d.route.id]:void 0,y=d.route.errorElement||H4,l&&(s<0&&h===0?(aC("route-fallback",!1,"No `HydrateFallback` element provided to render during initial hydration"),x=!0,p=null):s===h&&(x=!0,p=d.route.hydrateFallbackElement||null)));let v=t.concat(a.slice(0,h+1)),g=()=>{let b;return m?b=y:x?b=p:d.route.Component?b=O.createElement(d.route.Component,null):d.route.element?b=d.route.element:b=f,O.createElement(F4,{match:d,routeContext:{outlet:f,matches:v,isDataRoute:r!=null},children:b})};return r&&(d.route.ErrorBoundary||d.route.errorElement||h===0)?O.createElement(rC,{location:r.location,revalidation:r.revalidation,component:y,error:m,children:g(),routeContext:{outlet:null,matches:v,isDataRoute:!0},onError:c}):g()},null)}function Yb(e){return`${e} must be used within a data router. See https://reactrouter.com/en/main/routers/picking-a-router.`}function K4(e){let t=O.useContext(ys);return et(t,Yb(e)),t}function V4(e){let t=O.useContext(Ep);return et(t,Yb(e)),t}function Y4(e){let t=O.useContext(gr);return et(t,Yb(e)),t}function Xb(e){let t=Y4(e),n=t.matches[t.matches.length-1];return et(n.route.id,`${e} can only be used on routes that contain a unique "id"`),n.route.id}function X4(){return Xb("useRouteId")}function Q4(){var r;let e=O.useContext(Vb),t=V4("useRouteError"),n=Xb("useRouteError");return e!==void 0?e:(r=t.errors)==null?void 0:r[n]}function W4(){let{router:e}=K4("useNavigate"),t=Xb("useNavigate"),n=O.useRef(!1);return tC(()=>{n.current=!0}),O.useCallback(async(a,i={})=>{mr(n.current,eC),n.current&&(typeof a=="number"?await e.navigate(a):await e.navigate(a,{fromRouteId:t,...i}))},[e,t])}var Zj={};function aC(e,t,n){!t&&!Zj[e]&&(Zj[e]=!0,mr(!1,n))}O.memo(Z4);function Z4({routes:e,manifest:t,future:n,state:r,isStatic:a,onError:i}){return nC(e,void 0,{manifest:t,state:r,isStatic:a,onError:i})}function iC({to:e,replace:t,state:n,relative:r}){et(gs()," may be used only in the context of a component.");let{static:a}=O.useContext(kn);mr(!a," must not be used on the initial render in a . This is a no-op, but you should modify your code so the is only ever rendered in response to some user interaction or state change.");let{matches:i}=O.useContext(gr),{pathname:l}=Br(),s=_p(),u=Np(e,Kb(i),l,r==="path"),c=JSON.stringify(u);return O.useEffect(()=>{s(JSON.parse(c),{replace:t,state:n,relative:r})},[s,c,r,t,n]),null}function J4(e){return B4(e.context)}function Ce(e){et(!1,"A is only ever to be used as the child of element, never rendered directly. Please wrap your in a .")}function e5({basename:e="/",children:t=null,location:n,navigationType:r="POP",navigator:a,static:i=!1,useTransitions:l}){et(!gs(),"You cannot render a inside another . You should never have more than one in your app.");let s=e.replace(/^\/*/,"/"),u=O.useMemo(()=>({basename:s,navigator:a,static:i,useTransitions:l,future:{}}),[s,a,i,l]);typeof n=="string"&&(n=vs(n));let{pathname:c="/",search:f="",hash:d="",state:h=null,key:m="default",mask:x}=n,y=O.useMemo(()=>{let p=ya(c,s);return p==null?null:{location:{pathname:p,search:f,hash:d,state:h,key:m,mask:x},navigationType:r}},[s,c,f,d,h,m,r,x]);return mr(y!=null,` is not able to match the URL "${c}${f}${d}" because it does not start with the basename, so the won't render anything.`),y==null?null:O.createElement(kn.Provider,{value:u},O.createElement(Kc.Provider,{children:t,value:y}))}function t5({children:e,location:t}){return U4(rg(e),t)}function rg(e,t=[]){let n=[];return O.Children.forEach(e,(r,a)=>{if(!O.isValidElement(r))return;let i=[...t,a];if(r.type===O.Fragment){n.push.apply(n,rg(r.props.children,i));return}et(r.type===Ce,`[${typeof r.type=="string"?r.type:r.type.name}] is not a component. All component children of must be a or `),et(!r.props.index||!r.props.children,"An index route cannot have child routes.");let l={id:r.props.id||i.join("-"),caseSensitive:r.props.caseSensitive,element:r.props.element,Component:r.props.Component,index:r.props.index,path:r.props.path,middleware:r.props.middleware,loader:r.props.loader,action:r.props.action,hydrateFallbackElement:r.props.hydrateFallbackElement,HydrateFallback:r.props.HydrateFallback,errorElement:r.props.errorElement,ErrorBoundary:r.props.ErrorBoundary,hasErrorBoundary:r.props.hasErrorBoundary===!0||r.props.ErrorBoundary!=null||r.props.errorElement!=null,shouldRevalidate:r.props.shouldRevalidate,handle:r.props.handle,lazy:r.props.lazy};r.props.children&&(l.children=rg(r.props.children,i)),n.push(l)}),n}var fd="get",dd="application/x-www-form-urlencoded";function Tp(e){return typeof HTMLElement<"u"&&e instanceof HTMLElement}function n5(e){return Tp(e)&&e.tagName.toLowerCase()==="button"}function r5(e){return Tp(e)&&e.tagName.toLowerCase()==="form"}function a5(e){return Tp(e)&&e.tagName.toLowerCase()==="input"}function i5(e){return!!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)}function l5(e,t){return e.button===0&&(!t||t==="_self")&&!i5(e)}var Tf=null;function o5(){if(Tf===null)try{new FormData(document.createElement("form"),0),Tf=!1}catch{Tf=!0}return Tf}var s5=new Set(["application/x-www-form-urlencoded","multipart/form-data","text/plain"]);function xv(e){return e!=null&&!s5.has(e)?(mr(!1,`"${e}" is not a valid \`encType\` for \`

\`/\`\` and will default to "${dd}"`),null):e}function u5(e,t){let n,r,a,i,l;if(r5(e)){let s=e.getAttribute("action");r=s?ya(s,t):null,n=e.getAttribute("method")||fd,a=xv(e.getAttribute("enctype"))||dd,i=new FormData(e)}else if(n5(e)||a5(e)&&(e.type==="submit"||e.type==="image")){let s=e.form;if(s==null)throw new Error('Cannot submit a + + + ) +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..104e4ef --- /dev/null +++ b/frontend/src/components/Layout.tsx @@ -0,0 +1,35 @@ +import { useEffect } from 'react' +import { Outlet, Navigate } from 'react-router-dom' +import Sidebar from './Sidebar' +import Header from './Header' +import { getMe } from '../api/client' + +export default function Layout() { + const token = localStorage.getItem('mro_token') + + // 앱 진입 시 현재 사용자 역할 조회 → RBAC 메뉴/가드 사용 + useEffect(() => { + if (!token) return + getMe() + .then(r => { + const me = r.data?.data || {} + if (me.role) localStorage.setItem('mro_role', me.role) + if (me.username) localStorage.setItem('mro_user', me.username) + }) + .catch(() => {}) + }, [token]) + + if (!token) return + + return ( +
+ +
+
+
+ +
+
+
+ ) +} diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..d7cbf49 --- /dev/null +++ b/frontend/src/components/ProtectedRoute.tsx @@ -0,0 +1,27 @@ +import { useEffect, useState, type ReactNode } from 'react' +import { ShieldAlert } from './icons' +import { hasRole, currentRole } from './rbac' + +/** 최소 역할(min) 미만이면 접근 차단 안내 표시 */ +export default function ProtectedRoute({ min, children }: { min: string; children: ReactNode }) { + const [role, setRole] = useState(currentRole()) + + useEffect(() => { + const t = window.setInterval(() => { + const r = currentRole() + setRole(prev => (prev === r ? prev : r)) + }, 500) + return () => window.clearInterval(t) + }, []) + + if (!hasRole(min, role)) { + return ( +
+ +

접근 권한이 없습니다

+

이 화면은 {min} 이상 권한이 필요합니다. (현재: {role})

+
+ ) + } + return <>{children} +} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx new file mode 100644 index 0000000..dce8e88 --- /dev/null +++ b/frontend/src/components/Sidebar.tsx @@ -0,0 +1,118 @@ +import { useEffect, useState } from 'react' +import { NavLink } from 'react-router-dom' +import { + LayoutDashboard, Cog, CalendarDays, ClipboardList, History, Ruler, Timer, Gauge, + Package, ListTree, Boxes, ArrowUpDown, ClipboardCheck, FileText, ShoppingCart, PackagePlus, + Building2, Coins, Sparkles, Cpu, ShieldCheck, ScrollText, Settings, Smartphone, +} from './icons' +import { currentRole, hasRole } from './rbac' +import type { ComponentType } from 'react' +import type { IconProps } from './icons' + +interface Link { to: string; label: string; icon: ComponentType; min?: string } + +const mainLinks: Link[] = [ + { to: '/dashboard', label: '대시보드', icon: LayoutDashboard }, +] +const eamLinks: Link[] = [ + { to: '/equipment', label: '설비마스터/계층', icon: Cog }, + { to: '/pm-plans', label: 'PM 계획 (캘린더)', icon: CalendarDays }, + { to: '/work-orders', label: '작업지시 (칸반)', icon: ClipboardList }, + { to: '/maintenance', label: '고장/정비이력', icon: History }, + { to: '/calibrations', label: '계측/교정', icon: Ruler }, + { to: '/downtimes', label: '다운타임', icon: Timer }, + { to: '/reliability', label: '신뢰성 (MTBF/MTTR)', icon: Gauge }, +] +const mroLinks: Link[] = [ + { to: '/materials', label: '자재/공구', icon: Package }, + { to: '/material-boms', label: '설비 BOM', icon: ListTree }, + { to: '/inventory', label: '재고 현황', icon: Boxes }, + { to: '/inventory-txns', label: '입출고 수불', icon: ArrowUpDown }, + { to: '/stocktakes', label: '재고실사', icon: ClipboardCheck }, + { to: '/purchase-requests', label: '구매요청', icon: FileText }, + { to: '/purchase-orders', label: '발주', icon: ShoppingCart }, + { to: '/receivings', label: '입고검수', icon: PackagePlus }, + { to: '/partners', label: '거래처', icon: Building2, min: 'MANAGER' }, + { to: '/costs', label: 'MRO 비용', icon: Coins }, +] +const commonLinks: Link[] = [ + { to: '/ai-tools', label: 'AI 도구', icon: Sparkles }, +] +const adminLinks: Link[] = [ + { to: '/admin/users', label: '사용자/권한', icon: ShieldCheck, min: 'SUPERADMIN' }, + { to: '/admin/ai-platform', label: 'AI 플랫폼 설정', icon: Cpu, min: 'SUPERADMIN' }, + { to: '/admin/audit', label: '감사 로그', icon: ScrollText, min: 'MANAGER' }, + { to: '/admin/settings', label: '시스템 설정', icon: Settings, min: 'MANAGER' }, +] +const mobileLinks: Link[] = [ + { to: '/admin/mobile-app', label: '모바일 앱 설치', icon: Smartphone }, +] + +const linkClass = ({ isActive }: { isActive: boolean }) => + `flex items-center gap-3 px-5 py-1.5 text-sm transition-colors ${ + isActive ? 'bg-card text-brand border-r-2 border-brand' : 'text-slate-300 hover:bg-card/60' + }` + +function Section({ title, links, role }: { title?: string; links: Link[]; role: string }) { + const visible = links.filter(l => hasRole(l.min || 'VIEWER', role)) + if (!visible.length) return null + return ( + <> + {title && ( +
+ {title} +
+ )} + {visible.map(({ to, label, icon: Icon }) => ( + + {label} + + ))} + + ) +} + +export default function Sidebar() { + const [role, setRole] = useState(currentRole) + + useEffect(() => { + const sync = () => { + const r = currentRole() + setRole(prev => (prev === r ? prev : r)) + } + const t = window.setInterval(sync, 800) + window.addEventListener('storage', sync) + return () => { window.clearInterval(t); window.removeEventListener('storage', sync) } + }, []) + + const visibleAdmin = adminLinks.filter(l => hasRole(l.min || 'VIEWER', role)) + + return ( + + ) +} diff --git a/frontend/src/components/StatusBadge.tsx b/frontend/src/components/StatusBadge.tsx new file mode 100644 index 0000000..23edf4f --- /dev/null +++ b/frontend/src/components/StatusBadge.tsx @@ -0,0 +1,64 @@ +const COLORS: Record = { + // 작업지시 (WO) + CREATED: 'bg-slate-500/15 text-slate-400 border-slate-500/30', + ASSIGNED: 'bg-sky-500/15 text-sky-400 border-sky-500/30', + INPROGRESS: 'bg-amber-500/15 text-amber-400 border-amber-500/30', + DONE: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30', + CANCELLED: 'bg-rose-500/15 text-rose-400 border-rose-500/30', + // 설비 가동상태 + RUN: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30', + IDLE: 'bg-amber-500/15 text-amber-400 border-amber-500/30', + DOWN: 'bg-rose-500/15 text-rose-400 border-rose-500/30', + MAINT: 'bg-orange-500/15 text-orange-400 border-orange-500/30', + // WO 유형 + PM: 'bg-sky-500/15 text-sky-400 border-sky-500/30', + BM: 'bg-rose-500/15 text-rose-400 border-rose-500/30', + CM: 'bg-violet-500/15 text-violet-400 border-violet-500/30', + // 정비이력 유형 + FAULT: 'bg-rose-500/15 text-rose-400 border-rose-500/30', + REPAIR: 'bg-amber-500/15 text-amber-400 border-amber-500/30', + // 교정 결과 + PASS: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30', + FAIL: 'bg-rose-500/15 text-rose-400 border-rose-500/30', + ADJUSTED: 'bg-sky-500/15 text-sky-400 border-sky-500/30', + PENDING: 'bg-amber-500/15 text-amber-400 border-amber-500/30', + // 실사 + PLANNED: 'bg-slate-500/15 text-slate-400 border-slate-500/30', + COUNTING: 'bg-amber-500/15 text-amber-400 border-amber-500/30', + // 구매요청/발주 + DRAFT: 'bg-slate-500/15 text-slate-400 border-slate-500/30', + SUBMITTED: 'bg-sky-500/15 text-sky-400 border-sky-500/30', + APPROVED: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30', + REJECTED: 'bg-rose-500/15 text-rose-400 border-rose-500/30', + ORDERED: 'bg-cyan-500/15 text-cyan-400 border-cyan-500/30', + PARTIAL: 'bg-amber-500/15 text-amber-400 border-amber-500/30', + RECEIVED: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30', + CLOSED: 'bg-slate-600/15 text-slate-500 border-slate-600/30', + // 재고 수불 유형 + IN: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30', + OUT: 'bg-rose-500/15 text-rose-400 border-rose-500/30', + ADJUST: 'bg-amber-500/15 text-amber-400 border-amber-500/30', + MOVE: 'bg-sky-500/15 text-sky-400 border-sky-500/30', + RETURN: 'bg-violet-500/15 text-violet-400 border-violet-500/30', + // criticality / 우선순위 + A: 'bg-rose-500/15 text-rose-400 border-rose-500/30', + B: 'bg-amber-500/15 text-amber-400 border-amber-500/30', + C: 'bg-slate-500/15 text-slate-400 border-slate-500/30', + URGENT: 'bg-rose-500/15 text-rose-400 border-rose-500/30', + HIGH: 'bg-orange-500/15 text-orange-400 border-orange-500/30', + NORMAL: 'bg-sky-500/15 text-sky-400 border-sky-500/30', + LOW: 'bg-slate-500/15 text-slate-400 border-slate-500/30', + // 일반 + ACTIVE: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30', + INACTIVE: 'bg-slate-500/15 text-slate-400 border-slate-500/30', +} + +export default function StatusBadge({ status }: { status?: string }) { + const key = (status || '').toUpperCase() + const cls = COLORS[key] || COLORS.CREATED + return ( + + {status || '-'} + + ) +} diff --git a/frontend/src/components/icons.tsx b/frontend/src/components/icons.tsx new file mode 100644 index 0000000..d84fbe0 --- /dev/null +++ b/frontend/src/components/icons.tsx @@ -0,0 +1,229 @@ +// GUARDiA MRO 선(line/stroke) 아이콘 세트 — 외부 아이콘 라이브러리 금지 규칙에 따라 직접 제작. +// 모든 아이콘: fill:none · stroke:currentColor · strokeWidth 1.8 · 24x24 viewBox. +// 사용법: +import type { SVGProps } from 'react' + +export interface IconProps extends Omit, 'width' | 'height'> { + size?: number +} + +function base({ size = 16, ...rest }: IconProps): SVGProps { + return { + width: size, + height: size, + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + strokeWidth: 1.8, + strokeLinecap: 'round', + strokeLinejoin: 'round', + ...rest, + } +} + +/* ── 레이아웃/대시보드 ─────────────────────────────────────────── */ +export const LayoutDashboard = (p: IconProps) => ( + +) +export const Gauge = (p: IconProps) => ( + +) +export const Activity = (p: IconProps) => ( + +) +export const TrendingUp = (p: IconProps) => ( + +) +export const BarChart3 = (p: IconProps) => ( + +) +export const LineChart = (p: IconProps) => ( + +) +export const PieChart = (p: IconProps) => ( + +) + +/* ── 설비/보전 ─────────────────────────────────────────────────── */ +export const Wrench = (p: IconProps) => ( + +) +export const Cog = (p: IconProps) => ( + +) +export const Factory = (p: IconProps) => ( + +) +export const Layers = (p: IconProps) => ( + +) +export const ClipboardList = (p: IconProps) => ( + +) +export const ClipboardCheck = (p: IconProps) => ( + +) +export const History = (p: IconProps) => ( + +) +export const Ruler = (p: IconProps) => ( + +) +export const CalendarDays = (p: IconProps) => ( + +) +export const CalendarRange = (p: IconProps) => ( + +) +export const Timer = (p: IconProps) => ( + +) + +/* ── 자재/재고 ─────────────────────────────────────────────────── */ +export const Package = (p: IconProps) => ( + +) +export const Boxes = (p: IconProps) => ( + +) +export const ListTree = (p: IconProps) => ( + +) +export const ArrowLeftRight = (p: IconProps) => ( + +) +export const ArrowUpDown = (p: IconProps) => ( + +) +export const Warehouse = (p: IconProps) => ( + +) + +/* ── 구매/거래처/비용 ──────────────────────────────────────────── */ +export const FileText = (p: IconProps) => ( + +) +export const ShoppingCart = (p: IconProps) => ( + +) +export const PackagePlus = (p: IconProps) => ( + +) +export const Truck = (p: IconProps) => ( + +) +export const Building2 = (p: IconProps) => ( + +) +export const Coins = (p: IconProps) => ( + +) + +/* ── 공통 액션 ─────────────────────────────────────────────────── */ +export const Plus = (p: IconProps) => ( + +) +export const Trash2 = (p: IconProps) => ( + +) +export const X = (p: IconProps) => ( + +) +export const ChevronRight = (p: IconProps) => ( + +) +export const ChevronLeft = (p: IconProps) => ( + +) +export const ArrowRight = (p: IconProps) => ( + +) +export const Columns3 = (p: IconProps) => ( + +) +export const Search = (p: IconProps) => ( + +) +export const Save = (p: IconProps) => ( + +) +export const RefreshCw = (p: IconProps) => ( + +) +export const CheckCircle2 = (p: IconProps) => ( + +) +export const Check = (p: IconProps) => ( + +) +export const AlertTriangle = (p: IconProps) => ( + +) + +/* ── 공통/AI/관리자 ────────────────────────────────────────────── */ +export const Cpu = (p: IconProps) => ( + +) +export const Sparkles = (p: IconProps) => ( + +) +export const ShieldCheck = (p: IconProps) => ( + +) +export const ShieldAlert = (p: IconProps) => ( + +) +export const ScrollText = (p: IconProps) => ( + +) +export const Settings = (p: IconProps) => ( + +) +export const KeyRound = (p: IconProps) => ( + +) +export const Power = (p: IconProps) => ( + +) +export const PlugZap = (p: IconProps) => ( + +) +export const LogOut = (p: IconProps) => ( + +) +export const UserCircle = (p: IconProps) => ( + +) +export const QrCode = (p: IconProps) => ( + +) +export const Lock = (p: IconProps) => ( + +) +export const Eye = (p: IconProps) => ( + +) +export const EyeOff = (p: IconProps) => ( + +) +export const ThumbsUp = (p: IconProps) => ( + +) +export const ThumbsDown = (p: IconProps) => ( + +) +export const Smartphone = (p: IconProps) => ( + +) +export const Download = (p: IconProps) => ( + +) +export const Copy = (p: IconProps) => ( + +) +export const ExternalLink = (p: IconProps) => ( + +) +export const Users2 = (p: IconProps) => ( + +) diff --git a/frontend/src/components/rbac.ts b/frontend/src/components/rbac.ts new file mode 100644 index 0000000..a32194c --- /dev/null +++ b/frontend/src/components/rbac.ts @@ -0,0 +1,22 @@ +// RBAC 역할 위계: SUPERADMIN ⊃ MANAGER ⊃ WORKER ⊃ VIEWER +export const ROLE_RANK: Record = { + SUPERADMIN: 4, + MANAGER: 3, + WORKER: 2, + VIEWER: 1, +} + +export const MRO_ROLES = ['SUPERADMIN', 'MANAGER', 'WORKER', 'VIEWER'] + +export function currentRole(): string { + return (localStorage.getItem('mro_role') || 'VIEWER').toUpperCase() +} + +/** role 이 min 이상 권한인가 */ +export function hasRole(min: string, role = currentRole()): boolean { + return (ROLE_RANK[role] || 0) >= (ROLE_RANK[min.toUpperCase()] || 99) +} + +export const canWrite = (role = currentRole()) => hasRole('WORKER', role) // 작업/실적/입출고 입력 +export const canManage = (role = currentRole()) => hasRole('MANAGER', role) // 기준정보/승인/거래처 +export const isSuperAdmin = (role = currentRole()) => hasRole('SUPERADMIN', role) // 사용자/AI 플랫폼 diff --git a/frontend/src/components/ui.tsx b/frontend/src/components/ui.tsx new file mode 100644 index 0000000..5b81b06 --- /dev/null +++ b/frontend/src/components/ui.tsx @@ -0,0 +1,117 @@ +import type { ReactNode } from 'react' +import { X } from './icons' + +export function PageHeader({ title, subtitle, actions }: + { title: string; subtitle?: string; actions?: ReactNode }) { + return ( +
+
+

{title}

+ {subtitle &&

{subtitle}

} +
+ {actions &&
{actions}
} +
+ ) +} + +export function Field({ label, children, hint }: + { label: string; children: ReactNode; hint?: string }) { + return ( +
+ + {children} + {hint &&

{hint}

} +
+ ) +} + +export function Modal({ title, onClose, children, wide }: + { title: string; onClose: () => void; children: ReactNode; wide?: boolean }) { + return ( +
+
e.stopPropagation()} + > +
+

{title}

+ +
+ {children} +
+
+ ) +} + +export function Btn({ children, onClick, variant = 'primary', size = 'md', disabled, title, type }: { + children: ReactNode; onClick?: () => void; variant?: 'primary' | 'ghost' | 'danger' + size?: 'sm' | 'md'; disabled?: boolean; title?: string; type?: 'button' | 'submit' +}) { + const base = 'inline-flex items-center gap-1.5 rounded-lg font-semibold transition-colors disabled:opacity-50' + const sz = size === 'sm' ? 'px-2.5 py-1 text-xs' : 'px-3 py-2 text-sm' + const vr = variant === 'primary' ? 'bg-brand text-ink hover:bg-brand/90' + : variant === 'danger' ? 'bg-rose-500/90 text-white hover:bg-rose-500' + : 'bg-card border border-edge text-slate-300 hover:border-brand' + return ( + + ) +} + +export function Empty({ msg = '데이터가 없습니다.', colSpan }: { msg?: string; colSpan?: number }) { + if (colSpan) return {msg} + return
{msg}
+} + +export function Card({ title, icon, children, className = '' }: + { title?: string; icon?: ReactNode; children: ReactNode; className?: string }) { + return ( +
+ {title && ( +

{icon}{title}

+ )} + {children} +
+ ) +} + +export function StatCard({ label, value, icon, color = 'text-brand', unit }: + { label: string; value: number | string; icon?: ReactNode; color?: string; unit?: string }) { + return ( +
+
{icon} {label}
+
+ {typeof value === 'number' ? value.toLocaleString() : value} + {unit && {unit}} +
+
+ ) +} + +/** JSON 입력 토글 — 문자열/객체 모두 허용 */ +export function safeJson(v: any, fallback: any = []) { + if (v == null) return fallback + if (typeof v === 'object') return v + try { return JSON.parse(v) } catch { return fallback } +} +export function jsonStr(v: any) { + if (v == null) return '' + if (typeof v === 'string') return v + try { return JSON.stringify(v, null, 2) } catch { return '' } +} + +/** PII 마스킹 — 전화/이메일 부분 노출 */ +export function maskValue(v?: string) { + if (!v) return '-' + if (v.includes('@')) { + const [id, dom] = v.split('@') + return `${id.slice(0, 2)}***@${dom}` + } + if (v.length > 4) return v.slice(0, 3) + '****' + v.slice(-2) + return '***' +} + +/** 숫자 안전 변환 */ +export const num = (v: any) => Number(v ?? 0) || 0 +export const fmt = (v: any) => num(v).toLocaleString() diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..5e7679f --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,32 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + color-scheme: dark; +} + +body { + margin: 0; + font-family: 'Pretendard', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + background: #0b0f17; + color: #e6edf6; +} + +* { + box-sizing: border-box; +} + +/* 공통 입력 클래스 */ +.inp { + width: 100%; + padding: 8px 12px; + border-radius: 8px; + background: #0b1220; + border: 1px solid #26304a; + font-size: 13px; + color: #e2e8f0; + outline: none; +} +.inp:focus { border-color: #00a0c8; } +textarea.inp { resize: vertical; min-height: 80px; font-family: inherit; } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..964aeb4 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' +import './index.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/frontend/src/pages/AiPlatformSettings.tsx b/frontend/src/pages/AiPlatformSettings.tsx new file mode 100644 index 0000000..98ddfdb --- /dev/null +++ b/frontend/src/pages/AiPlatformSettings.tsx @@ -0,0 +1,226 @@ +// AI 플랫폼(LLM Provider) 설정(ADMIN) — 제공자·모델 선택 + 연결 테스트. +// API 키 값은 화면에 절대 표시/입력하지 않는다(claudeKeySet 으로 설정 여부 배지만). +// 저장은 provider/claudeModel 만 전송(ollama 계열 모델·전역 활성은 읽기 전용 표시). +// guardia-mes AiPlatformSettings.tsx 미러 (Claude/Qwen3/DeepSeek/GLM/Ollama). +import { useEffect, useState } from 'react' +import { Cpu, KeyRound, PlugZap, Save, AlertTriangle } from '../components/icons' +import { getAiConfig, updateAiConfig, testAiConfig } from '../api/adminAiConfig' +import type { AiProvider, ClaudeModel, AiTestResult } from '../api/adminAiConfig' + +const PROVIDERS: { id: AiProvider; label: string; hint: string; ram?: boolean }[] = [ + { id: 'ollama', label: 'Ollama (기본)', hint: '온프레미스 · llama3.2:1b' }, + { id: 'qwen3', label: 'Qwen3', hint: '온프레미스 · qwen3:1.7b' }, + { id: 'deepseek', label: 'DeepSeek', hint: '온프레미스 · deepseek-r1:1.5b' }, + { id: 'glm', label: 'GLM', hint: '온프레미스 · glm4:9b', ram: true }, + { id: 'claude', label: 'Claude (외부)', hint: 'api.anthropic.com · 키 필요' }, +] + +const CLAUDE_MODELS: ClaudeModel[] = ['claude-sonnet-4-6', 'claude-haiku-4-5', 'claude-opus-4-8'] +const DEFAULT_CLAUDE_MODEL: ClaudeModel = 'claude-sonnet-4-6' +const normModel = (v: string): ClaudeModel => + (CLAUDE_MODELS as string[]).includes(v) ? (v as ClaudeModel) : DEFAULT_CLAUDE_MODEL + +function friendlyError(e: any): string { + const msg: string = e?.response?.data?.message || '' + if (e?.response?.status === 403) return '권한이 없습니다 (AI 설정은 관리자 전용).' + if (msg.includes('ERR-AI-400')) return msg.replace(/^ERR-AI-400:\s*/, '') + return 'AI 설정을 처리하지 못했습니다.' +} + +export default function AiPlatformSettings() { + const [provider, setProvider] = useState('ollama') + const [claudeModel, setClaudeModel] = useState(DEFAULT_CLAUDE_MODEL) + const [claudeKeySet, setClaudeKeySet] = useState(false) + const [ollamaTextModel, setOllamaTextModel] = useState('') + const [aiEnabled, setAiEnabled] = useState(true) + + const [loading, setLoading] = useState(false) + const [saving, setSaving] = useState(false) + const [saved, setSaved] = useState(false) + const [err, setErr] = useState('') + + const [testing, setTesting] = useState(false) + const [testResult, setTestResult] = useState(null) + + const load = () => { + setLoading(true) + getAiConfig() + .then(dto => { + setProvider(dto.provider) + setClaudeModel(normModel(dto.claudeModel ?? DEFAULT_CLAUDE_MODEL)) + setClaudeKeySet(!!dto.claudeKeySet) + setOllamaTextModel(dto.ollamaTextModel ?? '') + setAiEnabled(!!dto.aiEnabled) + setErr('') + }) + .catch(e => setErr(friendlyError(e))) + .finally(() => setLoading(false)) + } + + useEffect(() => { load() }, []) + + const markDirty = () => { setSaved(false); setTestResult(null) } + + const submit = async () => { + setSaving(true); setSaved(false) + try { + const dto = await updateAiConfig({ provider, claudeModel }) + setProvider(dto.provider) + setClaudeModel(normModel(dto.claudeModel ?? DEFAULT_CLAUDE_MODEL)) + setClaudeKeySet(!!dto.claudeKeySet) + setOllamaTextModel(dto.ollamaTextModel ?? '') + setAiEnabled(!!dto.aiEnabled) + setSaved(true); setErr('') + } catch (e) { + setErr(friendlyError(e)) + } finally { setSaving(false) } + } + + const runTest = async () => { + setTesting(true); setTestResult(null) + try { + setTestResult(await testAiConfig()) + } catch (e) { + setTestResult({ ok: false, degraded: false, message: friendlyError(e) }) + } finally { setTesting(false) } + } + + const isClaude = provider === 'claude' + const isGlm = provider === 'glm' + const showKeyFallback = isClaude && !claudeKeySet + const showDisabledFallback = !isClaude && !aiEnabled + + const statusClass = testResult + ? testResult.degraded ? 'text-amber-300' : testResult.ok ? 'text-emerald-300' : 'text-rose-300' + : '' + + return ( +
+
+
+

+ AI 플랫폼 설정 +

+

+ 고장 원인분석·예지보전·자연어 조회 등 텍스트 생성에 사용할 LLM 제공자와 모델을 선택합니다. +

+
+ +
+ + {err && ( +
{err}
+ )} + + {(showKeyFallback || showDisabledFallback) && ( +
+ {showKeyFallback + ? 'Claude API 키가 설정되지 않아 Ollama(온프레미스)로 폴백 동작합니다. 서버 환경변수 ANTHROPIC_API_KEY 설정 후 사용하세요.' + : 'AI 기능이 비활성 상태입니다. 모든 AI 결과는 규칙 기반(degraded)으로 동작합니다.'} +
+ )} + + {isGlm && ( +
+ + + GLM(glm4:9b)은 약 5.5GB 로드가 필요해 현재 서버 RAM에서는 콜드로드가 실패할 수 있습니다. + 선택은 허용되지만 실패 시 자동으로 폴백(degraded)됩니다. RAM 증설 후 사용을 권장합니다. + +
+ )} + +
+
+

+ 제공자 & 모델 +

+ +
+ {PROVIDERS.map(p => ( + + ))} +
+ + {!isClaude && ( +
+ +
+ {ollamaTextModel || 'llama3.2:1b'} +
+

온프레미스 모델 — provider 별 고정. 외부 호출 없음.

+
+ )} + + {isClaude && ( + <> +
+ + +
+
+ + + {claudeKeySet ? '설정됨' : '미설정'} + +

+ 키는 서버 환경변수(ANTHROPIC_API_KEY)로만 주입되며 화면·응답·로그에 노출되지 않습니다. +

+
+ + )} + +
+ + {saved && 저장됨} +
+
+ +
+

+ 연결 테스트 +

+

+ 저장된 설정 기준으로 선택한 제공자에 짧은 ping 을 보내 연결을 확인합니다(요약 결과만). +

+
+ + {testResult && ( + {testResult.message} + )} +
+

+ 먼저 변경 사항을 저장한 뒤 테스트하세요. Claude 실패 시 Ollama 로 자동 폴백합니다. +

+
+
+
+ ) +} diff --git a/frontend/src/pages/AiTools.tsx b/frontend/src/pages/AiTools.tsx new file mode 100644 index 0000000..b1da535 --- /dev/null +++ b/frontend/src/pages/AiTools.tsx @@ -0,0 +1,196 @@ +import { useEffect, useState } from 'react' +import { + Sparkles, AlertTriangle, TrendingUp, Wrench, Search, Boxes, Gauge, ThumbsUp, ThumbsDown, +} from '../components/icons' +import { Card, Btn, Field } from '../components/ui' +import { + getAiStatus, aiFaultRootCause, aiPredictiveMaintenance, aiDemandForecast, + aiParseQuery, aiSafetyStock, aiReliabilityAnomaly, postAiFeedback, +} from '../api/client' + +/** 👍/👎 로컬 학습 피드백 (POST /api/ai/feedback). 결과가 있을 때만 노출. */ +function FeedbackBar({ feature, question, answer }: { feature: string; question?: string; answer?: any }) { + const [done, setDone] = useState('') + if (answer == null) return null + const ans = typeof answer === 'string' ? answer : JSON.stringify(answer) + const send = async (verdict: 'up' | 'down') => { + try { await postAiFeedback({ feature, question, answer: ans, verdict }); setDone(verdict === 'up' ? '👍 반영됨' : '👎 반영됨') } + catch { setDone('전송 실패') } + } + return ( +
+ 이 결과가 도움이 됐나요? + + + {done && {done}} +
+ ) +} + +function Output({ data }: { data: any }) { + if (data == null) return null + const text = typeof data === 'string' ? data : JSON.stringify(data, null, 2) + return
{text}
+} + +export default function AiTools() { + const [ollama, setOllama] = useState(null) + useEffect(() => { getAiStatus().then((d: any) => setOllama(!!(d?.ollamaAvailable ?? d?.available))).catch(() => setOllama(false)) }, []) + + return ( +
+
+
+

AI 도구

+

고장 원인분석 · 예지보전 · 수요예측 · 자연어조회 · 안전재고 · 신뢰성 이상감지 (Ollama/Claude 온프레미스 + 규칙 폴백)

+
+ + + AI {ollama === null ? '확인 중' : ollama ? '온라인' : '오프라인 (폴백)'} + +
+ +
+ + +
+
+ ) +} + +/** 고장 원인 분석 — POST /ai/fault-root-cause {equipmentCode, symptom, history[]}. */ +function FaultTool() { + const [eq, setEq] = useState(''); const [symptom, setSymptom] = useState('') + const [out, setOut] = useState(null); const [busy, setBusy] = useState(false) + const run = async () => { + setBusy(true) + try { setOut(await aiFaultRootCause({ equipmentCode: eq, symptom, history: [] })) } finally { setBusy(false) } + } + return ( + }> + setEq(e.target.value)} placeholder="예: EQ-PUMP-01" /> +