From a8e0d6ecf1d49ebdddf5d3400676a36cdf146806 Mon Sep 17 00:00:00 2001 From: GUARDiA Date: Sun, 14 Jun 2026 12:35:18 +0900 Subject: [PATCH] feat(mes): GUARDiA MES v1.0 WMS/MES/QMS --- Jenkinsfile | 59 + backend/.gitignore | 5 + backend/pom.xml | 82 + .../java/com/zioinfo/mes/MesApplication.java | 26 + .../zioinfo/mes/admin/AdminController.java | 96 + .../zioinfo/mes/admin/AdminUserService.java | 118 + .../com/zioinfo/mes/admin/AuditService.java | 53 + .../com/zioinfo/mes/admin/SettingService.java | 35 + .../com/zioinfo/mes/admin/dto/AuditLog.java | 16 + .../com/zioinfo/mes/admin/dto/MesSetting.java | 13 + .../com/zioinfo/mes/admin/dto/UserDto.java | 28 + .../mes/admin/mapper/AdminUserMapper.java | 31 + .../mes/admin/mapper/AuditLogMapper.java | 17 + .../mes/admin/mapper/SettingMapper.java | 17 + .../java/com/zioinfo/mes/ai/AiController.java | 78 + .../java/com/zioinfo/mes/ai/AiService.java | 258 ++ .../java/com/zioinfo/mes/ai/OllamaClient.java | 94 + .../mes/analytics/AnalyticsController.java | 64 + .../mes/analytics/mapper/AnalyticsMapper.java | 24 + .../com/zioinfo/mes/auth/AuthController.java | 29 + .../com/zioinfo/mes/auth/AuthService.java | 40 + .../java/com/zioinfo/mes/auth/JwtFilter.java | 40 + .../java/com/zioinfo/mes/auth/JwtUtil.java | 59 + .../java/com/zioinfo/mes/auth/MesUser.java | 20 + .../zioinfo/mes/auth/mapper/UserMapper.java | 13 + .../mes/barcode/BarcodeController.java | 92 + .../com/zioinfo/mes/bom/BomController.java | 67 + .../java/com/zioinfo/mes/bom/BomService.java | 120 + .../main/java/com/zioinfo/mes/bom/MesBom.java | 24 + .../com/zioinfo/mes/bom/mapper/BomMapper.java | 18 + .../com/zioinfo/mes/capa/CapaController.java | 74 + .../java/com/zioinfo/mes/capa/MesCapa.java | 28 + .../zioinfo/mes/capa/mapper/CapaMapper.java | 17 + .../certificate/CertificateController.java | 55 + .../mes/certificate/MesCertificate.java | 26 + .../certificate/mapper/CertificateMapper.java | 17 + .../com/zioinfo/mes/common/ApiResponse.java | 20 + .../com/zioinfo/mes/common/AuthSupport.java | 36 + .../com/zioinfo/mes/common/CryptoUtil.java | 93 + .../mes/common/GlobalExceptionHandler.java | 56 + .../com/zioinfo/mes/config/CorsConfig.java | 25 + .../com/zioinfo/mes/config/MyBatisConfig.java | 36 + .../zioinfo/mes/config/SecurityConfig.java | 83 + .../mes/config/SpaForwardController.java | 26 + .../mes/dashboard/DashboardController.java | 42 + .../mes/dashboard/mapper/DashboardMapper.java | 17 + .../mes/equipment/EquipmentController.java | 105 + .../zioinfo/mes/equipment/MesEquipment.java | 24 + .../mes/equipment/mapper/EquipmentMapper.java | 19 + .../mes/inspection/InspectionController.java | 63 + .../mes/inspection/InspectionService.java | 100 + .../zioinfo/mes/inspection/MesInspection.java | 29 + .../inspection/mapper/InspectionMapper.java | 22 + .../com/zioinfo/mes/integration/BiClient.java | 30 + .../zioinfo/mes/integration/ErpClient.java | 38 + .../mes/integration/GuardiaHttpClient.java | 68 + .../integration/IntegrationController.java | 48 + .../zioinfo/mes/integration/ItsmClient.java | 45 + .../integration/ItsmSecuritySanitizer.java | 62 + .../zioinfo/mes/integration/OcrClient.java | 30 + .../mes/inventory/InventoryController.java | 56 + .../mes/inventory/InventoryService.java | 128 + .../zioinfo/mes/inventory/MesInventory.java | 22 + .../mes/inventory/MesInventoryTxn.java | 26 + .../mes/inventory/mapper/InventoryMapper.java | 39 + .../com/zioinfo/mes/item/ItemController.java | 51 + .../com/zioinfo/mes/item/ItemService.java | 70 + .../java/com/zioinfo/mes/item/MesItem.java | 27 + .../zioinfo/mes/item/mapper/ItemMapper.java | 20 + .../com/zioinfo/mes/job/JobController.java | 88 + .../java/com/zioinfo/mes/job/JobService.java | 140 + .../main/java/com/zioinfo/mes/job/MesJob.java | 36 + .../com/zioinfo/mes/job/mapper/JobMapper.java | 30 + .../mes/lotserial/LotSerialController.java | 66 + .../zioinfo/mes/lotserial/MesLotSerial.java | 27 + .../mes/lotserial/mapper/LotSerialMapper.java | 20 + .../zioinfo/mes/member/MemberController.java | 48 + .../main/java/com/zioinfo/mes/ncr/MesNcr.java | 29 + .../com/zioinfo/mes/ncr/NcrController.java | 66 + .../java/com/zioinfo/mes/ncr/NcrService.java | 98 + .../com/zioinfo/mes/ncr/mapper/NcrMapper.java | 23 + .../com/zioinfo/mes/oee/MesOeeRecord.java | 29 + .../com/zioinfo/mes/oee/OeeController.java | 48 + .../java/com/zioinfo/mes/oee/OeeService.java | 72 + .../com/zioinfo/mes/oee/mapper/OeeMapper.java | 17 + .../com/zioinfo/mes/partner/MesPartner.java | 28 + .../mes/partner/PartnerController.java | 51 + .../zioinfo/mes/partner/PartnerService.java | 93 + .../mes/partner/mapper/PartnerMapper.java | 19 + .../java/com/zioinfo/mes/plan/MesPlan.java | 21 + .../com/zioinfo/mes/plan/PlanController.java | 92 + .../java/com/zioinfo/mes/plan/PlanMapper.java | 16 + .../mes/process/MesProcessProgress.java | 25 + .../mes/process/ProcessController.java | 61 + .../zioinfo/mes/process/ProcessMapper.java | 16 + .../zioinfo/mes/production/MesProduction.java | 25 + .../mes/production/ProductionController.java | 49 + .../mes/production/ProductionService.java | 112 + .../production/mapper/ProductionMapper.java | 23 + .../mes/progress/ProgressController.java | 66 + .../mes/progress/mapper/ProgressMapper.java | 40 + .../zioinfo/mes/receiving/MesReceiving.java | 27 + .../mes/receiving/ReceivingController.java | 54 + .../mes/receiving/ReceivingMapper.java | 15 + .../mes/receiving/ReceivingService.java | 81 + .../com/zioinfo/mes/routing/MesRouting.java | 23 + .../mes/routing/RoutingController.java | 63 + .../mes/routing/mapper/RoutingMapper.java | 17 + .../com/zioinfo/mes/shipping/MesShipping.java | 38 + .../mes/shipping/ShippingController.java | 83 + .../zioinfo/mes/shipping/ShippingService.java | 151 + .../mes/shipping/mapper/ShippingMapper.java | 29 + .../com/zioinfo/mes/spc/MesSpcSample.java | 22 + .../com/zioinfo/mes/spc/SpcController.java | 52 + .../java/com/zioinfo/mes/spc/SpcService.java | 153 + .../com/zioinfo/mes/spc/mapper/SpcMapper.java | 14 + .../java/com/zioinfo/mes/spec/MesSpec.java | 25 + .../com/zioinfo/mes/spec/SpecController.java | 73 + .../zioinfo/mes/spec/mapper/SpecMapper.java | 19 + .../zioinfo/mes/stocktake/MesStocktake.java | 26 + .../mes/stocktake/StocktakeController.java | 44 + .../mes/stocktake/StocktakeService.java | 70 + .../mes/stocktake/mapper/StocktakeMapper.java | 16 + .../mes/tracking/TrackingController.java | 37 + .../zioinfo/mes/tracking/TrackingMapper.java | 22 + .../zioinfo/mes/tracking/TrackingService.java | 72 + .../zioinfo/mes/warehouse/MesLocation.java | 18 + .../zioinfo/mes/warehouse/MesWarehouse.java | 17 + .../mes/warehouse/WarehouseController.java | 94 + .../mes/warehouse/mapper/WarehouseMapper.java | 23 + .../zioinfo/mes/workorder/MesWorkOrder.java | 30 + .../mes/workorder/WorkOrderController.java | 67 + .../mes/workorder/WorkOrderService.java | 117 + .../mes/workorder/mapper/WorkOrderMapper.java | 26 + backend/src/main/resources/application.yml | 55 + backend/src/main/resources/db/schema.sql | 568 +++ .../main/resources/mapper/AdminUserMapper.xml | 56 + .../main/resources/mapper/AnalyticsMapper.xml | 64 + .../main/resources/mapper/AuditLogMapper.xml | 31 + .../src/main/resources/mapper/BomMapper.xml | 49 + .../src/main/resources/mapper/CapaMapper.xml | 53 + .../resources/mapper/CertificateMapper.xml | 43 + .../main/resources/mapper/DashboardMapper.xml | 32 + .../main/resources/mapper/EquipmentMapper.xml | 60 + .../resources/mapper/InspectionMapper.xml | 66 + .../main/resources/mapper/InventoryMapper.xml | 110 + .../src/main/resources/mapper/ItemMapper.xml | 60 + .../src/main/resources/mapper/JobMapper.xml | 95 + .../main/resources/mapper/LotSerialMapper.xml | 59 + .../src/main/resources/mapper/NcrMapper.xml | 60 + .../src/main/resources/mapper/OeeMapper.xml | 43 + .../main/resources/mapper/PartnerMapper.xml | 58 + .../src/main/resources/mapper/PlanMapper.xml | 48 + .../main/resources/mapper/ProcessMapper.xml | 51 + .../resources/mapper/ProductionMapper.xml | 62 + .../main/resources/mapper/ProgressMapper.xml | 91 + .../main/resources/mapper/ReceivingMapper.xml | 48 + .../main/resources/mapper/RoutingMapper.xml | 43 + .../main/resources/mapper/SettingMapper.xml | 25 + .../main/resources/mapper/ShippingMapper.xml | 98 + .../src/main/resources/mapper/SpcMapper.xml | 36 + .../src/main/resources/mapper/SpecMapper.xml | 56 + .../main/resources/mapper/StocktakeMapper.xml | 42 + .../main/resources/mapper/TrackingMapper.xml | 31 + .../src/main/resources/mapper/UserMapper.xml | 28 + .../main/resources/mapper/WarehouseMapper.xml | 60 + .../main/resources/mapper/WorkOrderMapper.xml | 78 + .../resources/static/assets/index-BLHiicbF.js | 480 +++ .../static/assets/index-BwkEm3xg.css | 1 + backend/src/main/resources/static/index.html | 13 + backend/src/main/resources/static/login.png | Bin 0 -> 32760 bytes .../java/com/zioinfo/mes/CryptoUtilTest.java | 47 + .../java/com/zioinfo/mes/OeeComputeTest.java | 45 + .../ItsmSecuritySanitizerTest.java | 47 + frontend/index.html | 12 + frontend/package-lock.json | 3416 +++++++++++++++++ frontend/package.json | 30 + frontend/postcss.config.js | 6 + frontend/public/login.png | Bin 0 -> 32760 bytes frontend/src/App.tsx | 101 + frontend/src/api/client.ts | 315 ++ frontend/src/components/Chart.tsx | 102 + frontend/src/components/Header.tsx | 43 + frontend/src/components/Layout.tsx | 35 + frontend/src/components/ProtectedRoute.tsx | 28 + frontend/src/components/Sidebar.tsx | 125 + frontend/src/components/StatusBadge.tsx | 64 + 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/AiTools.tsx | 128 + frontend/src/pages/Analytics.tsx | 79 + frontend/src/pages/AuditLog.tsx | 54 + frontend/src/pages/Bom.tsx | 149 + frontend/src/pages/Capa.tsx | 101 + frontend/src/pages/Certificate.tsx | 111 + frontend/src/pages/Dashboard.tsx | 132 + frontend/src/pages/Inspection.tsx | 153 + frontend/src/pages/Inventory.tsx | 141 + frontend/src/pages/Items.tsx | 110 + frontend/src/pages/Jobs.tsx | 212 + frontend/src/pages/Login.tsx | 57 + frontend/src/pages/LotSerial.tsx | 114 + frontend/src/pages/LotTracking.tsx | 72 + frontend/src/pages/Ncr.tsx | 118 + frontend/src/pages/Oee.tsx | 134 + frontend/src/pages/Partners.tsx | 109 + frontend/src/pages/ProcessBoard.tsx | 72 + frontend/src/pages/Production.tsx | 125 + frontend/src/pages/ProductionPlan.tsx | 137 + frontend/src/pages/Progress.tsx | 157 + frontend/src/pages/Receiving.tsx | 120 + frontend/src/pages/Routing.tsx | 106 + frontend/src/pages/Shipping.tsx | 214 ++ frontend/src/pages/Spc.tsx | 161 + frontend/src/pages/Spec.tsx | 139 + frontend/src/pages/StockTake.tsx | 104 + frontend/src/pages/SystemSettings.tsx | 72 + frontend/src/pages/UserManagement.tsx | 94 + frontend/src/pages/Warehouses.tsx | 124 + frontend/src/pages/WorkOrders.tsx | 159 + frontend/tailwind.config.js | 19 + frontend/tsconfig.json | 20 + frontend/vite.config.ts | 19 + 225 files changed, 17867 insertions(+) create mode 100644 Jenkinsfile create mode 100644 backend/.gitignore create mode 100644 backend/pom.xml create mode 100644 backend/src/main/java/com/zioinfo/mes/MesApplication.java create mode 100644 backend/src/main/java/com/zioinfo/mes/admin/AdminController.java create mode 100644 backend/src/main/java/com/zioinfo/mes/admin/AdminUserService.java create mode 100644 backend/src/main/java/com/zioinfo/mes/admin/AuditService.java create mode 100644 backend/src/main/java/com/zioinfo/mes/admin/SettingService.java create mode 100644 backend/src/main/java/com/zioinfo/mes/admin/dto/AuditLog.java create mode 100644 backend/src/main/java/com/zioinfo/mes/admin/dto/MesSetting.java create mode 100644 backend/src/main/java/com/zioinfo/mes/admin/dto/UserDto.java create mode 100644 backend/src/main/java/com/zioinfo/mes/admin/mapper/AdminUserMapper.java create mode 100644 backend/src/main/java/com/zioinfo/mes/admin/mapper/AuditLogMapper.java create mode 100644 backend/src/main/java/com/zioinfo/mes/admin/mapper/SettingMapper.java create mode 100644 backend/src/main/java/com/zioinfo/mes/ai/AiController.java create mode 100644 backend/src/main/java/com/zioinfo/mes/ai/AiService.java create mode 100644 backend/src/main/java/com/zioinfo/mes/ai/OllamaClient.java create mode 100644 backend/src/main/java/com/zioinfo/mes/analytics/AnalyticsController.java create mode 100644 backend/src/main/java/com/zioinfo/mes/analytics/mapper/AnalyticsMapper.java create mode 100644 backend/src/main/java/com/zioinfo/mes/auth/AuthController.java create mode 100644 backend/src/main/java/com/zioinfo/mes/auth/AuthService.java create mode 100644 backend/src/main/java/com/zioinfo/mes/auth/JwtFilter.java create mode 100644 backend/src/main/java/com/zioinfo/mes/auth/JwtUtil.java create mode 100644 backend/src/main/java/com/zioinfo/mes/auth/MesUser.java create mode 100644 backend/src/main/java/com/zioinfo/mes/auth/mapper/UserMapper.java create mode 100644 backend/src/main/java/com/zioinfo/mes/barcode/BarcodeController.java create mode 100644 backend/src/main/java/com/zioinfo/mes/bom/BomController.java create mode 100644 backend/src/main/java/com/zioinfo/mes/bom/BomService.java create mode 100644 backend/src/main/java/com/zioinfo/mes/bom/MesBom.java create mode 100644 backend/src/main/java/com/zioinfo/mes/bom/mapper/BomMapper.java create mode 100644 backend/src/main/java/com/zioinfo/mes/capa/CapaController.java create mode 100644 backend/src/main/java/com/zioinfo/mes/capa/MesCapa.java create mode 100644 backend/src/main/java/com/zioinfo/mes/capa/mapper/CapaMapper.java create mode 100644 backend/src/main/java/com/zioinfo/mes/certificate/CertificateController.java create mode 100644 backend/src/main/java/com/zioinfo/mes/certificate/MesCertificate.java create mode 100644 backend/src/main/java/com/zioinfo/mes/certificate/mapper/CertificateMapper.java create mode 100644 backend/src/main/java/com/zioinfo/mes/common/ApiResponse.java create mode 100644 backend/src/main/java/com/zioinfo/mes/common/AuthSupport.java create mode 100644 backend/src/main/java/com/zioinfo/mes/common/CryptoUtil.java create mode 100644 backend/src/main/java/com/zioinfo/mes/common/GlobalExceptionHandler.java create mode 100644 backend/src/main/java/com/zioinfo/mes/config/CorsConfig.java create mode 100644 backend/src/main/java/com/zioinfo/mes/config/MyBatisConfig.java create mode 100644 backend/src/main/java/com/zioinfo/mes/config/SecurityConfig.java create mode 100644 backend/src/main/java/com/zioinfo/mes/config/SpaForwardController.java create mode 100644 backend/src/main/java/com/zioinfo/mes/dashboard/DashboardController.java create mode 100644 backend/src/main/java/com/zioinfo/mes/dashboard/mapper/DashboardMapper.java create mode 100644 backend/src/main/java/com/zioinfo/mes/equipment/EquipmentController.java create mode 100644 backend/src/main/java/com/zioinfo/mes/equipment/MesEquipment.java create mode 100644 backend/src/main/java/com/zioinfo/mes/equipment/mapper/EquipmentMapper.java create mode 100644 backend/src/main/java/com/zioinfo/mes/inspection/InspectionController.java create mode 100644 backend/src/main/java/com/zioinfo/mes/inspection/InspectionService.java create mode 100644 backend/src/main/java/com/zioinfo/mes/inspection/MesInspection.java create mode 100644 backend/src/main/java/com/zioinfo/mes/inspection/mapper/InspectionMapper.java create mode 100644 backend/src/main/java/com/zioinfo/mes/integration/BiClient.java create mode 100644 backend/src/main/java/com/zioinfo/mes/integration/ErpClient.java create mode 100644 backend/src/main/java/com/zioinfo/mes/integration/GuardiaHttpClient.java create mode 100644 backend/src/main/java/com/zioinfo/mes/integration/IntegrationController.java create mode 100644 backend/src/main/java/com/zioinfo/mes/integration/ItsmClient.java create mode 100644 backend/src/main/java/com/zioinfo/mes/integration/ItsmSecuritySanitizer.java create mode 100644 backend/src/main/java/com/zioinfo/mes/integration/OcrClient.java create mode 100644 backend/src/main/java/com/zioinfo/mes/inventory/InventoryController.java create mode 100644 backend/src/main/java/com/zioinfo/mes/inventory/InventoryService.java create mode 100644 backend/src/main/java/com/zioinfo/mes/inventory/MesInventory.java create mode 100644 backend/src/main/java/com/zioinfo/mes/inventory/MesInventoryTxn.java create mode 100644 backend/src/main/java/com/zioinfo/mes/inventory/mapper/InventoryMapper.java create mode 100644 backend/src/main/java/com/zioinfo/mes/item/ItemController.java create mode 100644 backend/src/main/java/com/zioinfo/mes/item/ItemService.java create mode 100644 backend/src/main/java/com/zioinfo/mes/item/MesItem.java create mode 100644 backend/src/main/java/com/zioinfo/mes/item/mapper/ItemMapper.java create mode 100644 backend/src/main/java/com/zioinfo/mes/job/JobController.java create mode 100644 backend/src/main/java/com/zioinfo/mes/job/JobService.java create mode 100644 backend/src/main/java/com/zioinfo/mes/job/MesJob.java create mode 100644 backend/src/main/java/com/zioinfo/mes/job/mapper/JobMapper.java create mode 100644 backend/src/main/java/com/zioinfo/mes/lotserial/LotSerialController.java create mode 100644 backend/src/main/java/com/zioinfo/mes/lotserial/MesLotSerial.java create mode 100644 backend/src/main/java/com/zioinfo/mes/lotserial/mapper/LotSerialMapper.java create mode 100644 backend/src/main/java/com/zioinfo/mes/member/MemberController.java create mode 100644 backend/src/main/java/com/zioinfo/mes/ncr/MesNcr.java create mode 100644 backend/src/main/java/com/zioinfo/mes/ncr/NcrController.java create mode 100644 backend/src/main/java/com/zioinfo/mes/ncr/NcrService.java create mode 100644 backend/src/main/java/com/zioinfo/mes/ncr/mapper/NcrMapper.java create mode 100644 backend/src/main/java/com/zioinfo/mes/oee/MesOeeRecord.java create mode 100644 backend/src/main/java/com/zioinfo/mes/oee/OeeController.java create mode 100644 backend/src/main/java/com/zioinfo/mes/oee/OeeService.java create mode 100644 backend/src/main/java/com/zioinfo/mes/oee/mapper/OeeMapper.java create mode 100644 backend/src/main/java/com/zioinfo/mes/partner/MesPartner.java create mode 100644 backend/src/main/java/com/zioinfo/mes/partner/PartnerController.java create mode 100644 backend/src/main/java/com/zioinfo/mes/partner/PartnerService.java create mode 100644 backend/src/main/java/com/zioinfo/mes/partner/mapper/PartnerMapper.java create mode 100644 backend/src/main/java/com/zioinfo/mes/plan/MesPlan.java create mode 100644 backend/src/main/java/com/zioinfo/mes/plan/PlanController.java create mode 100644 backend/src/main/java/com/zioinfo/mes/plan/PlanMapper.java create mode 100644 backend/src/main/java/com/zioinfo/mes/process/MesProcessProgress.java create mode 100644 backend/src/main/java/com/zioinfo/mes/process/ProcessController.java create mode 100644 backend/src/main/java/com/zioinfo/mes/process/ProcessMapper.java create mode 100644 backend/src/main/java/com/zioinfo/mes/production/MesProduction.java create mode 100644 backend/src/main/java/com/zioinfo/mes/production/ProductionController.java create mode 100644 backend/src/main/java/com/zioinfo/mes/production/ProductionService.java create mode 100644 backend/src/main/java/com/zioinfo/mes/production/mapper/ProductionMapper.java create mode 100644 backend/src/main/java/com/zioinfo/mes/progress/ProgressController.java create mode 100644 backend/src/main/java/com/zioinfo/mes/progress/mapper/ProgressMapper.java create mode 100644 backend/src/main/java/com/zioinfo/mes/receiving/MesReceiving.java create mode 100644 backend/src/main/java/com/zioinfo/mes/receiving/ReceivingController.java create mode 100644 backend/src/main/java/com/zioinfo/mes/receiving/ReceivingMapper.java create mode 100644 backend/src/main/java/com/zioinfo/mes/receiving/ReceivingService.java create mode 100644 backend/src/main/java/com/zioinfo/mes/routing/MesRouting.java create mode 100644 backend/src/main/java/com/zioinfo/mes/routing/RoutingController.java create mode 100644 backend/src/main/java/com/zioinfo/mes/routing/mapper/RoutingMapper.java create mode 100644 backend/src/main/java/com/zioinfo/mes/shipping/MesShipping.java create mode 100644 backend/src/main/java/com/zioinfo/mes/shipping/ShippingController.java create mode 100644 backend/src/main/java/com/zioinfo/mes/shipping/ShippingService.java create mode 100644 backend/src/main/java/com/zioinfo/mes/shipping/mapper/ShippingMapper.java create mode 100644 backend/src/main/java/com/zioinfo/mes/spc/MesSpcSample.java create mode 100644 backend/src/main/java/com/zioinfo/mes/spc/SpcController.java create mode 100644 backend/src/main/java/com/zioinfo/mes/spc/SpcService.java create mode 100644 backend/src/main/java/com/zioinfo/mes/spc/mapper/SpcMapper.java create mode 100644 backend/src/main/java/com/zioinfo/mes/spec/MesSpec.java create mode 100644 backend/src/main/java/com/zioinfo/mes/spec/SpecController.java create mode 100644 backend/src/main/java/com/zioinfo/mes/spec/mapper/SpecMapper.java create mode 100644 backend/src/main/java/com/zioinfo/mes/stocktake/MesStocktake.java create mode 100644 backend/src/main/java/com/zioinfo/mes/stocktake/StocktakeController.java create mode 100644 backend/src/main/java/com/zioinfo/mes/stocktake/StocktakeService.java create mode 100644 backend/src/main/java/com/zioinfo/mes/stocktake/mapper/StocktakeMapper.java create mode 100644 backend/src/main/java/com/zioinfo/mes/tracking/TrackingController.java create mode 100644 backend/src/main/java/com/zioinfo/mes/tracking/TrackingMapper.java create mode 100644 backend/src/main/java/com/zioinfo/mes/tracking/TrackingService.java create mode 100644 backend/src/main/java/com/zioinfo/mes/warehouse/MesLocation.java create mode 100644 backend/src/main/java/com/zioinfo/mes/warehouse/MesWarehouse.java create mode 100644 backend/src/main/java/com/zioinfo/mes/warehouse/WarehouseController.java create mode 100644 backend/src/main/java/com/zioinfo/mes/warehouse/mapper/WarehouseMapper.java create mode 100644 backend/src/main/java/com/zioinfo/mes/workorder/MesWorkOrder.java create mode 100644 backend/src/main/java/com/zioinfo/mes/workorder/WorkOrderController.java create mode 100644 backend/src/main/java/com/zioinfo/mes/workorder/WorkOrderService.java create mode 100644 backend/src/main/java/com/zioinfo/mes/workorder/mapper/WorkOrderMapper.java create mode 100644 backend/src/main/resources/application.yml 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/AnalyticsMapper.xml create mode 100644 backend/src/main/resources/mapper/AuditLogMapper.xml create mode 100644 backend/src/main/resources/mapper/BomMapper.xml create mode 100644 backend/src/main/resources/mapper/CapaMapper.xml create mode 100644 backend/src/main/resources/mapper/CertificateMapper.xml create mode 100644 backend/src/main/resources/mapper/DashboardMapper.xml create mode 100644 backend/src/main/resources/mapper/EquipmentMapper.xml create mode 100644 backend/src/main/resources/mapper/InspectionMapper.xml create mode 100644 backend/src/main/resources/mapper/InventoryMapper.xml create mode 100644 backend/src/main/resources/mapper/ItemMapper.xml create mode 100644 backend/src/main/resources/mapper/JobMapper.xml create mode 100644 backend/src/main/resources/mapper/LotSerialMapper.xml create mode 100644 backend/src/main/resources/mapper/NcrMapper.xml create mode 100644 backend/src/main/resources/mapper/OeeMapper.xml create mode 100644 backend/src/main/resources/mapper/PartnerMapper.xml create mode 100644 backend/src/main/resources/mapper/PlanMapper.xml create mode 100644 backend/src/main/resources/mapper/ProcessMapper.xml create mode 100644 backend/src/main/resources/mapper/ProductionMapper.xml create mode 100644 backend/src/main/resources/mapper/ProgressMapper.xml create mode 100644 backend/src/main/resources/mapper/ReceivingMapper.xml create mode 100644 backend/src/main/resources/mapper/RoutingMapper.xml create mode 100644 backend/src/main/resources/mapper/SettingMapper.xml create mode 100644 backend/src/main/resources/mapper/ShippingMapper.xml create mode 100644 backend/src/main/resources/mapper/SpcMapper.xml create mode 100644 backend/src/main/resources/mapper/SpecMapper.xml create mode 100644 backend/src/main/resources/mapper/StocktakeMapper.xml create mode 100644 backend/src/main/resources/mapper/TrackingMapper.xml create mode 100644 backend/src/main/resources/mapper/UserMapper.xml create mode 100644 backend/src/main/resources/mapper/WarehouseMapper.xml create mode 100644 backend/src/main/resources/mapper/WorkOrderMapper.xml create mode 100644 backend/src/main/resources/static/assets/index-BLHiicbF.js create mode 100644 backend/src/main/resources/static/assets/index-BwkEm3xg.css create mode 100644 backend/src/main/resources/static/index.html create mode 100644 backend/src/main/resources/static/login.png create mode 100644 backend/src/test/java/com/zioinfo/mes/CryptoUtilTest.java create mode 100644 backend/src/test/java/com/zioinfo/mes/OeeComputeTest.java create mode 100644 backend/src/test/java/com/zioinfo/mes/integration/ItsmSecuritySanitizerTest.java 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/public/login.png create mode 100644 frontend/src/App.tsx 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/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/AiTools.tsx create mode 100644 frontend/src/pages/Analytics.tsx create mode 100644 frontend/src/pages/AuditLog.tsx create mode 100644 frontend/src/pages/Bom.tsx create mode 100644 frontend/src/pages/Capa.tsx create mode 100644 frontend/src/pages/Certificate.tsx create mode 100644 frontend/src/pages/Dashboard.tsx create mode 100644 frontend/src/pages/Inspection.tsx create mode 100644 frontend/src/pages/Inventory.tsx create mode 100644 frontend/src/pages/Items.tsx create mode 100644 frontend/src/pages/Jobs.tsx create mode 100644 frontend/src/pages/Login.tsx create mode 100644 frontend/src/pages/LotSerial.tsx create mode 100644 frontend/src/pages/LotTracking.tsx create mode 100644 frontend/src/pages/Ncr.tsx create mode 100644 frontend/src/pages/Oee.tsx create mode 100644 frontend/src/pages/Partners.tsx create mode 100644 frontend/src/pages/ProcessBoard.tsx create mode 100644 frontend/src/pages/Production.tsx create mode 100644 frontend/src/pages/ProductionPlan.tsx create mode 100644 frontend/src/pages/Progress.tsx create mode 100644 frontend/src/pages/Receiving.tsx create mode 100644 frontend/src/pages/Routing.tsx create mode 100644 frontend/src/pages/Shipping.tsx create mode 100644 frontend/src/pages/Spc.tsx create mode 100644 frontend/src/pages/Spec.tsx create mode 100644 frontend/src/pages/StockTake.tsx create mode 100644 frontend/src/pages/SystemSettings.tsx create mode 100644 frontend/src/pages/UserManagement.tsx create mode 100644 frontend/src/pages/Warehouses.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/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..629cb7f --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,59 @@ +// GUARDiA MES — CI/CD Pipeline (Jenkins, 보조) +// 주 배포는 Gitea webhook → deploy_server.py(9999). Jenkins는 검증/롤백 백업 경로. +// 저장소 구조: backend/, frontend/ 가 루트에 위치 (단일 jar — frontend가 backend static으로 번들됨) +pipeline { + agent any + environment { + MES_HOME = '/opt/guardia-mes' + JAVA_HOME = '/usr/lib/jvm/java-21-openjdk-amd64' // 서버 JDK21 (Java17 타겟 호환 빌드) + PATH = "${JAVA_HOME}/bin:${env.PATH}" + } + stages { + stage('Checkout') { steps { checkout scm } } + + // frontend 먼저 빌드 → vite outDir(../backend/src/main/resources/static)에 산출 → jar에 번들 + stage('Frontend Build') { + steps { + dir('frontend') { + sh 'npm ci --silent 2>/dev/null || npm install --silent' + sh 'npm run build' + } + } + } + + stage('Backend Build & Test') { + steps { + dir('backend') { + sh 'mvn clean package -DskipTests -q' + } + } + } + + stage('Deploy') { + steps { + sh ''' + sudo systemctl stop guardia-mes 2>/dev/null || true + sudo mkdir -p ${MES_HOME} + sudo cp backend/target/guardia-mes-*.jar ${MES_HOME}/app.jar + sudo systemctl start guardia-mes + ''' + } + } + + stage('Health Check') { + steps { + retry(5) { + sleep 8 + sh 'curl -sf http://localhost:8013/actuator/health | grep -q "UP"' + } + } + } + } + post { + success { echo 'GUARDiA MES 배포 성공 (포트 8013)' } + failure { + sh 'sudo systemctl stop guardia-mes 2>/dev/null || true' + echo 'GUARDiA MES 배포 실패 — 서비스 중지(롤백)' + } + } +} diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..cdb0d48 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,5 @@ +target/ +*.class +.idea/ +*.iml +.DS_Store diff --git a/backend/pom.xml b/backend/pom.xml new file mode 100644 index 0000000..54b4888 --- /dev/null +++ b/backend/pom.xml @@ -0,0 +1,82 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.5.11 + + + com.zioinfo + guardia-mes + 1.0.0 + GUARDiA MES + AI 기반 제조실행시스템 (WMS+MES+QMS 통합) — Ollama 온프레미스 AI + ERP/ITSM/OCR/BI 연계 + + + 17 + 0.12.6 + 2.6.0 + 3.0.3 + 42.7.7 + + + + + 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} + + + 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-mes-${project.version} + + + org.springframework.boot + spring-boot-maven-plugin + + + org.projectlomboklombok + + + + + + diff --git a/backend/src/main/java/com/zioinfo/mes/MesApplication.java b/backend/src/main/java/com/zioinfo/mes/MesApplication.java new file mode 100644 index 0000000..57a5948 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/MesApplication.java @@ -0,0 +1,26 @@ +package com.zioinfo.mes; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; + +/** + * GUARDiA MES — AI 기반 제조실행시스템 (WMS + MES + QMS 통합). + * + *

MES(작업지시→생산실적→LOT추적·OEE), WMS(입고·출고·재고·실사·LOT/시리얼), + * QMS(IQC/IPQC/OQC 검사·NCR·CAPA·SPC·성적서), 기준정보(품목·BOM·라우팅·설비·거래처·창고)를 + * 단일 플랫폼에서 관리한다. + * + *

보안 불변 규칙: 외부 AI API 절대 금지(Ollama localhost:11434만 + Java 폴백), + * 거래처/인사 PII는 AES-256-GCM 암호화·마스킹, ITSM/연계 응답은 자격증명 새니타이즈, + * 스택트레이스 미노출(에러 코드만). + */ +@SpringBootApplication +@EnableScheduling +@EnableAsync +public class MesApplication { + public static void main(String[] args) { + SpringApplication.run(MesApplication.class, args); + } +} diff --git a/backend/src/main/java/com/zioinfo/mes/admin/AdminController.java b/backend/src/main/java/com/zioinfo/mes/admin/AdminController.java new file mode 100644 index 0000000..2f7649f --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/admin/AdminController.java @@ -0,0 +1,96 @@ +package com.zioinfo.mes.admin; + +import com.zioinfo.mes.admin.dto.AuditLog; +import com.zioinfo.mes.admin.dto.MesSetting; +import com.zioinfo.mes.admin.dto.UserDto; +import com.zioinfo.mes.common.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * GUARDiA MES 관리자 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/mes/admin/AdminUserService.java b/backend/src/main/java/com/zioinfo/mes/admin/AdminUserService.java new file mode 100644 index 0000000..76b7c31 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/admin/AdminUserService.java @@ -0,0 +1,118 @@ +package com.zioinfo.mes.admin; + +import com.zioinfo.mes.admin.dto.UserDto; +import com.zioinfo.mes.admin.mapper.AdminUserMapper; +import com.zioinfo.mes.auth.MesUser; +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"); + } + MesUser user = new MesUser(); + 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) { + MesUser 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) { + MesUser 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 필수"); + } + MesUser 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) { + MesUser 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 MesUser require(Long id) { + MesUser 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/mes/admin/AuditService.java b/backend/src/main/java/com/zioinfo/mes/admin/AuditService.java new file mode 100644 index 0000000..87d2b2b --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/admin/AuditService.java @@ -0,0 +1,53 @@ +package com.zioinfo.mes.admin; + +import com.zioinfo.mes.admin.dto.AuditLog; +import com.zioinfo.mes.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; + +/** + * 감사 로그 서비스 — 작업지시 전이·실적·검사 판정·관리자 작업 등 주요 변경을 mes_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/mes/admin/SettingService.java b/backend/src/main/java/com/zioinfo/mes/admin/SettingService.java new file mode 100644 index 0000000..8c310a8 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/admin/SettingService.java @@ -0,0 +1,35 @@ +package com.zioinfo.mes.admin; + +import com.zioinfo.mes.admin.dto.MesSetting; +import com.zioinfo.mes.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 MesSetting 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) { + MesSetting s = mapper.findByKey(key); + return (s == null || s.getValue() == null) ? defaultValue : s.getValue(); + } +} diff --git a/backend/src/main/java/com/zioinfo/mes/admin/dto/AuditLog.java b/backend/src/main/java/com/zioinfo/mes/admin/dto/AuditLog.java new file mode 100644 index 0000000..5760a5a --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/admin/dto/AuditLog.java @@ -0,0 +1,16 @@ +package com.zioinfo.mes.admin.dto; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** 감사 로그 (mes_audit_log 테이블 매핑). */ +@Data +public class AuditLog { + private Long id; + private String actor; // 작업 수행자 (JWT subject = username) + private String action; // WORKORDER_RELEASE, PRODUCTION_REPORT, INSPECTION_JUDGE 등 + private String target; // 대상 식별자 + private String detail; // 부가 설명 + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/com/zioinfo/mes/admin/dto/MesSetting.java b/backend/src/main/java/com/zioinfo/mes/admin/dto/MesSetting.java new file mode 100644 index 0000000..588952e --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/admin/dto/MesSetting.java @@ -0,0 +1,13 @@ +package com.zioinfo.mes.admin.dto; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** 시스템 설정 (mes_setting 테이블 매핑). */ +@Data +public class MesSetting { + private String key; + private String value; + private LocalDateTime updatedAt; +} diff --git a/backend/src/main/java/com/zioinfo/mes/admin/dto/UserDto.java b/backend/src/main/java/com/zioinfo/mes/admin/dto/UserDto.java new file mode 100644 index 0000000..2d720b6 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/admin/dto/UserDto.java @@ -0,0 +1,28 @@ +package com.zioinfo.mes.admin.dto; + +import com.zioinfo.mes.auth.MesUser; +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 role; + private boolean active; + private LocalDateTime createdAt; + + public static UserDto from(MesUser u) { + UserDto d = new UserDto(); + d.id = u.getId(); + d.username = u.getUsername(); + d.displayName = u.getDisplayName(); + d.role = u.getRole(); + d.active = u.isActive(); + d.createdAt = u.getCreatedAt(); + return d; + } +} diff --git a/backend/src/main/java/com/zioinfo/mes/admin/mapper/AdminUserMapper.java b/backend/src/main/java/com/zioinfo/mes/admin/mapper/AdminUserMapper.java new file mode 100644 index 0000000..42b74ca --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/admin/mapper/AdminUserMapper.java @@ -0,0 +1,31 @@ +package com.zioinfo.mes.admin.mapper; + +import com.zioinfo.mes.auth.MesUser; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** 관리자 사용자 관리 매퍼 (mes_user). @Mapper 필수. */ +@Mapper +public interface AdminUserMapper { + + List findAll(); + + MesUser findById(@Param("id") Long id); + + int insert(MesUser 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/mes/admin/mapper/AuditLogMapper.java b/backend/src/main/java/com/zioinfo/mes/admin/mapper/AuditLogMapper.java new file mode 100644 index 0000000..f86e835 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/admin/mapper/AuditLogMapper.java @@ -0,0 +1,17 @@ +package com.zioinfo.mes.admin.mapper; + +import com.zioinfo.mes.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/mes/admin/mapper/SettingMapper.java b/backend/src/main/java/com/zioinfo/mes/admin/mapper/SettingMapper.java new file mode 100644 index 0000000..84d39ad --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/admin/mapper/SettingMapper.java @@ -0,0 +1,17 @@ +package com.zioinfo.mes.admin.mapper; + +import com.zioinfo.mes.admin.dto.MesSetting; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface SettingMapper { + + List findAll(); + + MesSetting findByKey(@Param("key") String key); + + int upsert(@Param("key") String key, @Param("value") String value); +} diff --git a/backend/src/main/java/com/zioinfo/mes/ai/AiController.java b/backend/src/main/java/com/zioinfo/mes/ai/AiController.java new file mode 100644 index 0000000..7d2fd13 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/ai/AiController.java @@ -0,0 +1,78 @@ +package com.zioinfo.mes.ai; + +import com.zioinfo.mes.common.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** + * GUARDiA MES AI 도구 API — 8개 제조 AI(전부 Ollama + Java 폴백). + * + *

RBAC: 조회/분석 Viewer+ (GET), 분석 트리거 Worker+ (POST). + */ +@RestController +@RequestMapping("/api/mes/ai") +@RequiredArgsConstructor +public class AiController { + + private final AiService ai; + + @GetMapping("/status") + public ApiResponse> status() { + return ApiResponse.ok(Map.of("ollamaAvailable", ai.ollamaAvailable())); + } + + @PostMapping("/defect-root-cause") + public ApiResponse> defectRootCause(@RequestBody DefectRequest req) { + return ApiResponse.ok(ai.defectRootCause(req.defectCode(), req.context())); + } + + @PostMapping("/forecast") + public ApiResponse> forecast(@RequestBody ForecastRequest req) { + return ApiResponse.ok(ai.forecast(req.series(), req.horizon())); + } + + @PostMapping("/predictive-maintenance") + public ApiResponse> pdm(@RequestBody PdmRequest req) { + return ApiResponse.ok(ai.predictiveMaintenance( + req.equipmentCode(), req.availability(), req.downtimeCount(), req.mtbfHours())); + } + + @PostMapping("/spc-anomaly") + public ApiResponse> spc(@RequestBody SpcRequest req) { + return ApiResponse.ok(ai.spcAnomaly(req.values(), req.ucl(), req.lcl(), req.cl())); + } + + @PostMapping("/parse-query") + public ApiResponse> parseQuery(@RequestBody QueryRequest req) { + return ApiResponse.ok(ai.parseQuery(req.query())); + } + + @PostMapping("/inspection-judge") + public ApiResponse> inspectionJudge(@RequestBody JudgeRequest req) { + return ApiResponse.ok(ai.inspectionJudge(req.measured(), req.lsl(), req.usl(), req.target())); + } + + @PostMapping("/safety-stock") + public ApiResponse> safetyStock(@RequestBody SafetyStockRequest req) { + return ApiResponse.ok(ai.safetyStock( + req.avgDailyDemand(), req.demandStdDev(), req.leadTimeDays(), req.serviceZ())); + } + + @PostMapping("/schedule-optimize") + public ApiResponse>> scheduleOptimize(@RequestBody ScheduleRequest req) { + return ApiResponse.ok(ai.scheduleOptimize(req.orders())); + } + + // ── request DTO ── + record DefectRequest(String defectCode, List> context) {} + record ForecastRequest(List series, int horizon) {} + record PdmRequest(String equipmentCode, double availability, int downtimeCount, double mtbfHours) {} + record SpcRequest(List values, double ucl, double lcl, double cl) {} + record QueryRequest(String query) {} + record JudgeRequest(double measured, Double lsl, Double usl, Double target) {} + record SafetyStockRequest(double avgDailyDemand, double demandStdDev, double leadTimeDays, double serviceZ) {} + record ScheduleRequest(List> orders) {} +} diff --git a/backend/src/main/java/com/zioinfo/mes/ai/AiService.java b/backend/src/main/java/com/zioinfo/mes/ai/AiService.java new file mode 100644 index 0000000..d38c564 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/ai/AiService.java @@ -0,0 +1,258 @@ +package com.zioinfo.mes.ai; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * GUARDiA MES AI 서비스 — 8개 제조 AI 기능, 전부 Ollama 온프레미스 + Java 폴백. + * + *

보안 불변 규칙: 외부 AI API 금지. Ollama 미가용 시 모든 메서드가 규칙기반/통계 폴백으로 동작한다. + *

    + *
  1. 불량 원인 분석 — 공정/설비/자재 상관 → 추정 원인
  2. + *
  3. 수요·생산량 예측 — 실적/계획 추세(이동평균 폴백)
  4. + *
  5. 설비 예지보전 — 가동/비가동 패턴 이상 → 정비 권고
  6. + *
  7. SPC 이상 감지 — 관리한계 이탈·연속런·추세
  8. + *
  9. 작업지시/재고 자연어 조회 — Text→필터
  10. + *
  11. 검사 판정 보조 — 측정값 vs 스펙 자동 판정
  12. + *
  13. 재고 최적화·안전재고 추천
  14. + *
  15. 생산 일정 최적화 제안
  16. + *
+ */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AiService { + + private final OllamaClient ollama; + + public boolean ollamaAvailable() { + return ollama.available(); + } + + // 1. 불량 원인 분석 (공정/설비/자재 상관) + public Map defectRootCause(String defectCode, List> context) { + Map result = new LinkedHashMap<>(); + String ctx = context == null ? "" : truncate(context.toString(), 1200); + String prompt = String.format( + "당신은 제조 품질 엔지니어입니다. 불량코드 '%s' 와 관련 공정/설비/자재 데이터: %s. " + + "가장 가능성 높은 추정 원인 1가지와 권고 조치를 'CAUSE: ...\\nACTION: ...' 형식 한국어로 출력.", + defectCode, ctx); + String out = ollama.generate(prompt); + if (out != null && !out.isBlank()) { + result.put("cause", firstNonBlank(extractLine(out, "CAUSE:"), out)); + result.put("action", extractLine(out, "ACTION:")); + result.put("source", "ollama"); + return result; + } + // Java 폴백: 불량코드 분류 규칙 + String code = defectCode == null ? "" : defectCode.toUpperCase(); + String cause; String action; + if (code.contains("DIM") || code.contains("치수")) { + cause = "설비 공구 마모 또는 셋업 오차(치수 불량)"; action = "공구 교체 점검 + 초도품 재검사"; + } else if (code.contains("SCR") || code.contains("스크래치") || code.contains("외관")) { + cause = "이송/포장 공정 표면 손상(외관 불량)"; action = "이송 지그 보호재 점검"; + } else if (code.contains("MAT") || code.contains("자재")) { + cause = "수입자재 품질 편차(IQC 연계)"; action = "해당 LOT 입고검사 이력 추적 + 공급사 통보"; + } else { + cause = "공정 변동(상관 데이터 부족 — 추가 수집 필요)"; action = "SPC 관리도 확인 + 4M 변경점 점검"; + } + result.put("cause", cause); + result.put("action", action); + result.put("source", "fallback"); + return result; + } + + // 2. 수요·생산량 예측 (이동평균/선형추세 폴백) + public Map forecast(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; + } + + // 3. 설비 예지보전 (가동/비가동 이상 → 정비 권고) + public Map predictiveMaintenance(String equipmentCode, double availability, + int downtimeCount, double mtbfHours) { + Map result = new LinkedHashMap<>(); + double risk; + // 규칙: 가동률 낮고 비가동 빈번하며 MTBF 짧을수록 위험 + 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 -> "정상 — 정기 점검 유지."; + }; + result.put("equipmentCode", equipmentCode); + result.put("risk", round2(risk)); + result.put("level", level); + result.put("recommendation", recommend); + result.put("source", "fallback"); + return result; + } + + // 4. SPC 이상 감지 (관리한계 이탈·연속런·추세 — Western Electric 일부 규칙) + public Map spcAnomaly(List values, double ucl, double lcl, double cl) { + Map result = new LinkedHashMap<>(); + List violations = new ArrayList<>(); + if (values != null && !values.isEmpty()) { + // 규칙1: 관리한계 이탈 + for (int i = 0; i < values.size(); i++) { + double v = values.get(i); + if (v > ucl || v < lcl) violations.add("RULE1: idx" + i + " 관리한계 이탈(" + round2(v) + ")"); + } + // 규칙2: 연속 9점이 중심선 한쪽 + int run = 0; boolean above = false; + for (int i = 0; i < values.size(); i++) { + boolean a = values.get(i) > cl; + if (i == 0 || a == above) { run++; } else { run = 1; } + above = a; + if (run >= 9) { violations.add("RULE2: 연속 9점 중심선 " + (above ? "위" : "아래")); run = 0; } + } + // 규칙3: 연속 6점 증가/감소 추세 + int inc = 1, dec = 1; + for (int i = 1; i < values.size(); i++) { + if (values.get(i) > values.get(i - 1)) { inc++; dec = 1; } else if (values.get(i) < values.get(i - 1)) { dec++; inc = 1; } else { inc = 1; dec = 1; } + if (inc >= 6) { violations.add("RULE3: 연속 6점 증가 추세"); inc = 1; } + if (dec >= 6) { violations.add("RULE3: 연속 6점 감소 추세"); dec = 1; } + } + } + result.put("anomaly", !violations.isEmpty()); + result.put("violations", violations); + result.put("source", "fallback"); + return result; + } + + // 5. 작업지시/재고 자연어 조회 → 필터 추출 + public Map parseQuery(String naturalQuery) { + Map filter = new LinkedHashMap<>(); + if (naturalQuery == null || naturalQuery.isBlank()) return filter; + String prompt = "다음 질의에서 검색 필터를 'STATUS:..\\nITEM:..\\nDATE:..' 형식으로만 추출:\\n" + naturalQuery; + String out = ollama.generate(prompt); + if (out != null && !out.isBlank()) { + putIf(filter, "status", extractLine(out, "STATUS:")); + putIf(filter, "item", extractLine(out, "ITEM:")); + putIf(filter, "date", extractLine(out, "DATE:")); + if (!filter.isEmpty()) { filter.put("source", "ollama"); return filter; } + } + // Java 폴백: 키워드 매칭 + String q = naturalQuery.toLowerCase(); + if (q.contains("진행") || q.contains("inprogress")) filter.put("status", "INPROGRESS"); + else if (q.contains("완료") || q.contains("done")) filter.put("status", "DONE"); + else if (q.contains("대기") || q.contains("created")) filter.put("status", "CREATED"); + if (q.contains("부족") || q.contains("안전재고")) filter.put("belowSafety", true); + filter.put("source", "fallback"); + return filter; + } + + // 6. 검사 판정 보조 (측정값 vs 스펙) + public Map inspectionJudge(double measured, Double lsl, Double usl, Double target) { + Map result = new LinkedHashMap<>(); + boolean pass = true; + StringBuilder reason = new StringBuilder(); + if (lsl != null && measured < lsl) { pass = false; reason.append("하한(").append(lsl).append(") 미달; "); } + if (usl != null && measured > usl) { pass = false; reason.append("상한(").append(usl).append(") 초과; "); } + double deviation = target != null ? round2(measured - target) : 0.0; + result.put("judgement", pass ? "PASS" : "FAIL"); + result.put("measured", measured); + result.put("deviation", deviation); + result.put("reason", reason.length() == 0 ? "규격 내" : reason.toString().trim()); + result.put("source", "fallback"); + return result; + } + + // 7. 재고 최적화·안전재고 추천 (수요 변동 기반) + 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; + } + + // 8. 생산 일정 최적화 제안 (납기·셋업 기준 단순 우선순위) + public List> scheduleOptimize(List> orders) { + if (orders == null || orders.isEmpty()) return List.of(); + // EDD(Earliest Due Date) + 동일품목 셋업 묶기 폴백 + List> sorted = new ArrayList<>(orders); + sorted.sort((a, b) -> { + String d1 = String.valueOf(a.getOrDefault("dueDate", "9999-12-31")); + String d2 = String.valueOf(b.getOrDefault("dueDate", "9999-12-31")); + int c = d1.compareTo(d2); + if (c != 0) return c; + return String.valueOf(a.getOrDefault("itemCode", "")).compareTo(String.valueOf(b.getOrDefault("itemCode", ""))); + }); + int seq = 1; + for (Map o : sorted) { + o.put("suggestedSeq", seq++); + } + return sorted; + } + + // ── 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; + } + + @SuppressWarnings("unused") + private List tokenize(String s) { + return Arrays.stream(s.split("[\\s,.]+")).filter(w -> w.length() > 1).toList(); + } +} diff --git a/backend/src/main/java/com/zioinfo/mes/ai/OllamaClient.java b/backend/src/main/java/com/zioinfo/mes/ai/OllamaClient.java new file mode 100644 index 0000000..e3844b1 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/ai/OllamaClient.java @@ -0,0 +1,94 @@ +package com.zioinfo.mes.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}") String textModel, + @Value("${guardia.ollama-vision-model:llava}") String visionModel) { + this.builder = builder; + this.ollamaUrl = ollamaUrl; + this.textModel = textModel; + this.visionModel = visionModel; + } + + /** 프롬프트로 텍스트 생성. 실패 시 빈 문자열 반환(예외 없음). */ + @SuppressWarnings("unchecked") + public String generate(String prompt) { + try { + Map body = Map.of("model", textModel, "prompt", prompt, "stream", false); + Map res = builder.baseUrl(ollamaUrl).build() + .post().uri("/api/generate") + .bodyValue(body) + .retrieve() + .bodyToMono(Map.class) + .timeout(Duration.ofSeconds(30)) + .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 ""; + } + } + + /** llava 비전 모델로 이미지(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(45)) + .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/mes/analytics/AnalyticsController.java b/backend/src/main/java/com/zioinfo/mes/analytics/AnalyticsController.java new file mode 100644 index 0000000..f2adc37 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/analytics/AnalyticsController.java @@ -0,0 +1,64 @@ +package com.zioinfo.mes.analytics; + +import com.zioinfo.mes.analytics.mapper.AnalyticsMapper; +import com.zioinfo.mes.common.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 분석/KPI API (생산·품질·재고) — BI 피드 겸용. 조회 Viewer+. + */ +@RestController +@RequestMapping("/api/mes/analytics") +@RequiredArgsConstructor +public class AnalyticsController { + + private final AnalyticsMapper mapper; + + /** 생산 KPI. */ + @GetMapping("/production-kpi") + public ApiResponse> productionKpi(@RequestParam(defaultValue = "30") int days) { + return ApiResponse.ok(mapper.productionKpi(days)); + } + + /** 품질 KPI. */ + @GetMapping("/quality-kpi") + public ApiResponse> qualityKpi(@RequestParam(defaultValue = "30") int days) { + return ApiResponse.ok(mapper.qualityKpi(days)); + } + + /** 재고 KPI. */ + @GetMapping("/inventory-kpi") + public ApiResponse> inventoryKpi() { + return ApiResponse.ok(mapper.inventoryKpi()); + } + + /** 일자별 생산 추이. */ + @GetMapping("/daily-production") + public ApiResponse>> dailyProduction(@RequestParam(defaultValue = "14") int days) { + return ApiResponse.ok(mapper.dailyProduction(days)); + } + + /** 품목별 생산량 Top. */ + @GetMapping("/top-items") + public ApiResponse>> topItems(@RequestParam(defaultValue = "30") int days, + @RequestParam(defaultValue = "5") int limit) { + return ApiResponse.ok(mapper.topItems(days, limit)); + } + + /** BI 피드 — 생산·품질·재고 KPI 통합(GUARDiA BI 연동용). */ + @GetMapping("/bi-feed") + public ApiResponse> biFeed(@RequestParam(defaultValue = "30") int days) { + Map feed = new LinkedHashMap<>(); + feed.put("production", mapper.productionKpi(days)); + feed.put("quality", mapper.qualityKpi(days)); + feed.put("inventory", mapper.inventoryKpi()); + feed.put("dailyProduction", mapper.dailyProduction(days)); + feed.put("topItems", mapper.topItems(days, 10)); + return ApiResponse.ok(feed); + } +} diff --git a/backend/src/main/java/com/zioinfo/mes/analytics/mapper/AnalyticsMapper.java b/backend/src/main/java/com/zioinfo/mes/analytics/mapper/AnalyticsMapper.java new file mode 100644 index 0000000..f4dfda1 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/analytics/mapper/AnalyticsMapper.java @@ -0,0 +1,24 @@ +package com.zioinfo.mes.analytics.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; +import java.util.Map; + +/** + * 분석/KPI 매퍼 — 생산·품질·재고 KPI (BI 피드용, 읽기 전용). + */ +@Mapper +public interface AnalyticsMapper { + /** 생산 KPI — 기간 생산량·불량률·작업지시 완료율. */ + Map productionKpi(@Param("days") int days); + /** 품질 KPI — 검사 합격률·NCR·CAPA. */ + Map qualityKpi(@Param("days") int days); + /** 재고 KPI — 품목수·총수량·안전재고 미달수. */ + Map inventoryKpi(); + /** 일자별 생산 추이. */ + List> dailyProduction(@Param("days") int days); + /** 품목별 생산량 Top. */ + List> topItems(@Param("days") int days, @Param("limit") int limit); +} diff --git a/backend/src/main/java/com/zioinfo/mes/auth/AuthController.java b/backend/src/main/java/com/zioinfo/mes/auth/AuthController.java new file mode 100644 index 0000000..cb500bf --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/auth/AuthController.java @@ -0,0 +1,29 @@ +package com.zioinfo.mes.auth; + +import com.zioinfo.mes.common.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequestMapping("/api/mes/auth") +@RequiredArgsConstructor +public class AuthController { + + private final AuthService authService; + + @PostMapping("/login") + public ApiResponse> login(@RequestBody LoginRequest req) { + String token = authService.login(req.username(), req.password()); + return ApiResponse.ok(Map.of("token", token, "type", "Bearer")); + } + + @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/mes/auth/AuthService.java b/backend/src/main/java/com/zioinfo/mes/auth/AuthService.java new file mode 100644 index 0000000..da7156b --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/auth/AuthService.java @@ -0,0 +1,40 @@ +package com.zioinfo.mes.auth; + +import com.zioinfo.mes.auth.mapper.UserMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class AuthService { + + private final UserMapper userMapper; + private final PasswordEncoder passwordEncoder; + private final JwtUtil jwtUtil; + + public String login(String username, String password) { + MesUser 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: 비밀번호 불일치"); + } + return jwtUtil.generate(username, user.getRole()); + } + + public Map me(String token) { + String username = jwtUtil.getUsername(token); + String role = jwtUtil.getRole(token); + MesUser 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/mes/auth/JwtFilter.java b/backend/src/main/java/com/zioinfo/mes/auth/JwtFilter.java new file mode 100644 index 0000000..b1897e3 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/auth/JwtFilter.java @@ -0,0 +1,40 @@ +package com.zioinfo.mes.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/mes/auth/JwtUtil.java b/backend/src/main/java/com/zioinfo/mes/auth/JwtUtil.java new file mode 100644 index 0000000..70cc21e --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/auth/JwtUtil.java @@ -0,0 +1,59 @@ +package com.zioinfo.mes.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-mes-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/mes/auth/MesUser.java b/backend/src/main/java/com/zioinfo/mes/auth/MesUser.java new file mode 100644 index 0000000..c4f97c0 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/auth/MesUser.java @@ -0,0 +1,20 @@ +package com.zioinfo.mes.auth; + +import lombok.Data; +import java.time.LocalDateTime; + +/** + * MES 운영자 계정 (mes_user 테이블). + * + *

role: SUPERADMIN / MANAGER / WORKER / VIEWER. + */ +@Data +public class MesUser { + private Long id; + private String username; + private String passwordHash; + private String displayName; + private String role; + private boolean active; + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/com/zioinfo/mes/auth/mapper/UserMapper.java b/backend/src/main/java/com/zioinfo/mes/auth/mapper/UserMapper.java new file mode 100644 index 0000000..a095b8b --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/auth/mapper/UserMapper.java @@ -0,0 +1,13 @@ +package com.zioinfo.mes.auth.mapper; + +import com.zioinfo.mes.auth.MesUser; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +@Mapper +public interface UserMapper { + + MesUser findByUsername(@Param("username") String username); + + int insert(MesUser user); +} diff --git a/backend/src/main/java/com/zioinfo/mes/barcode/BarcodeController.java b/backend/src/main/java/com/zioinfo/mes/barcode/BarcodeController.java new file mode 100644 index 0000000..d7cf351 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/barcode/BarcodeController.java @@ -0,0 +1,92 @@ +package com.zioinfo.mes.barcode; + +import com.zioinfo.mes.common.ApiResponse; +import com.zioinfo.mes.common.AuthSupport; +import com.zioinfo.mes.inventory.mapper.InventoryMapper; +import com.zioinfo.mes.item.mapper.ItemMapper; +import com.zioinfo.mes.lotserial.mapper.LotSerialMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 바코드/QR API — 발번·검증·스캔 해석. 모바일 현장 스캔(입출고/이동/조회)에서 사용. + * + *

코드 규칙: {@code TYPE:VALUE} (예 ITEM:FG-1001, LOT:LOT-001, LOC:WH-MAIN/A-01-01). + * 조회 Viewer+, 발번 Worker+. + */ +@RestController +@RequestMapping("/api/mes/barcode") +@RequiredArgsConstructor +public class BarcodeController { + + private final ItemMapper itemMapper; + private final LotSerialMapper lotMapper; + private final InventoryMapper inventoryMapper; + + /** 바코드 발번 — TYPE:VALUE 인코딩 문자열 반환. */ + @PostMapping("/generate") + public ApiResponse> generate(@RequestBody GenerateRequest req, Authentication auth) { + AuthSupport.actor(auth); + String type = req.type() == null ? "ITEM" : req.type().toUpperCase(); + if (req.value() == null || req.value().isBlank()) + throw new IllegalArgumentException("ERR-BC-400: value 필수"); + String code = type + ":" + req.value(); + Map m = new LinkedHashMap<>(); + m.put("type", type); + m.put("value", req.value()); + m.put("code", code); + return ApiResponse.ok(m); + } + + /** + * 스캔 해석 — TYPE:VALUE 를 파싱하고 대상 엔티티를 조회한다. + * 모바일 입출고/이동/조회 진입점. + */ + @GetMapping("/resolve") + public ApiResponse> resolve(@RequestParam String code) { + Map m = new LinkedHashMap<>(); + if (code == null || code.isBlank()) + throw new IllegalArgumentException("ERR-BC-400: code 필수"); + String type, value; + int idx = code.indexOf(':'); + if (idx > 0) { + type = code.substring(0, idx).toUpperCase(); + value = code.substring(idx + 1); + } else { + // 접두사 없으면 품목 바코드/코드로 추정 + type = "ITEM"; + value = code; + } + m.put("type", type); + m.put("value", value); + switch (type) { + case "ITEM" -> { + var item = itemMapper.findByCode(value); + if (item == null) item = itemMapper.findAll(null, null, value).stream().findFirst().orElse(null); + m.put("found", item != null); + m.put("item", item); + if (item != null) m.put("stock", inventoryMapper.sumQty(item.getItemCode())); + } + case "LOT" -> { + var lot = lotMapper.findByLot(value); + m.put("found", lot != null); + m.put("lot", lot); + } + case "LOC" -> { + m.put("found", true); + m.put("location", value); + } + default -> { + m.put("found", false); + m.put("message", "알 수 없는 코드 유형"); + } + } + return ApiResponse.ok(m); + } + + record GenerateRequest(String type, String value) {} +} diff --git a/backend/src/main/java/com/zioinfo/mes/bom/BomController.java b/backend/src/main/java/com/zioinfo/mes/bom/BomController.java new file mode 100644 index 0000000..afb8ba5 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/bom/BomController.java @@ -0,0 +1,67 @@ +package com.zioinfo.mes.bom; + +import com.zioinfo.mes.common.ApiResponse; +import com.zioinfo.mes.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; + +/** + * BOM API — 구성 CRUD + 트리 전개 + 소요량(MRP). 조회 Viewer+, 변경 Manager+. + */ +@RestController +@RequestMapping("/api/mes/bom") +@RequiredArgsConstructor +public class BomController { + + private final BomService service; + + @GetMapping + public ApiResponse> list(@RequestParam(required = false) String status) { + return ApiResponse.ok(service.listAll(status)); + } + + @GetMapping("/parent/{parentItemCode}") + public ApiResponse> byParent(@PathVariable String parentItemCode, + @RequestParam(required = false) String bomVersion) { + return ApiResponse.ok(service.listByParent(parentItemCode, bomVersion)); + } + + /** 다단 BOM 트리 전개. */ + @GetMapping("/explode/{parentItemCode}") + public ApiResponse> explode(@PathVariable String parentItemCode, + @RequestParam(defaultValue = "1") double qty, + @RequestParam(required = false) String bomVersion) { + return ApiResponse.ok(service.explode(parentItemCode, qty, bomVersion)); + } + + /** 소요량 집계(MRP) — 리프 자재별 총 소요량. */ + @GetMapping("/requirement/{parentItemCode}") + public ApiResponse> requirement(@PathVariable String parentItemCode, + @RequestParam(defaultValue = "1") double qty, + @RequestParam(required = false) String bomVersion) { + return ApiResponse.ok(service.materialRequirement(parentItemCode, qty, bomVersion)); + } + + @PostMapping + public ApiResponse create(@RequestBody MesBom bom, Authentication auth) { + AuthSupport.requireManager(auth); + return ApiResponse.ok(service.create(bom, AuthSupport.actor(auth))); + } + + @PutMapping("/{id}") + public ApiResponse update(@PathVariable Long id, @RequestBody MesBom bom, Authentication auth) { + AuthSupport.requireManager(auth); + return ApiResponse.ok(service.update(id, bom, 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/mes/bom/BomService.java b/backend/src/main/java/com/zioinfo/mes/bom/BomService.java new file mode 100644 index 0000000..32af73a --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/bom/BomService.java @@ -0,0 +1,120 @@ +package com.zioinfo.mes.bom; + +import com.zioinfo.mes.admin.AuditService; +import com.zioinfo.mes.bom.mapper.BomMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * BOM 서비스 — 구성 CRUD + 트리 전개 + 소요량(MRP) 계산. + */ +@Service +@RequiredArgsConstructor +public class BomService { + + private static final int MAX_DEPTH = 20; // 순환 방지 + private final BomMapper mapper; + private final AuditService audit; + + public List listByParent(String parentItemCode, String bomVersion) { + return mapper.findByParent(parentItemCode, bomVersion); + } + + public List listAll(String status) { + return mapper.findAll(status); + } + + public MesBom create(MesBom bom, String actor) { + if (bom.getParentItemCode() == null || bom.getChildItemCode() == null) + throw new IllegalArgumentException("ERR-BOM-400: parent/child 품목코드 필수"); + if (bom.getParentItemCode().equals(bom.getChildItemCode())) + throw new IllegalArgumentException("ERR-BOM-400: 자기 자신 구성 불가"); + if (bom.getQtyPer() == null || bom.getQtyPer() <= 0) bom.setQtyPer(1.0); + if (bom.getStatus() == null) bom.setStatus("ACTIVE"); + bom.setCreatedBy(actor); + mapper.insert(bom); + audit.log("BOM_CREATE", bom.getParentItemCode(), "child=" + bom.getChildItemCode()); + return mapper.findById(bom.getId()); + } + + public MesBom update(Long id, MesBom bom, String actor) { + MesBom cur = require(id); + bom.setId(id); + mapper.update(bom); + audit.log("BOM_UPDATE", cur.getParentItemCode(), "id=" + id); + return mapper.findById(id); + } + + public void delete(Long id) { + MesBom cur = require(id); + mapper.delete(id); + audit.log("BOM_DELETE", cur.getParentItemCode(), "id=" + id); + } + + /** 트리 전개 — 부모 품목에서 시작하는 다단 BOM 구조. */ + public Map explode(String parentItemCode, double qty, String bomVersion) { + return node(parentItemCode, qty, bomVersion, 0, new ArrayList<>()); + } + + private Map node(String itemCode, double qty, String version, int depth, List path) { + Map m = new LinkedHashMap<>(); + m.put("itemCode", itemCode); + m.put("requiredQty", round3(qty)); + m.put("depth", depth); + if (depth >= MAX_DEPTH || path.contains(itemCode)) { + m.put("children", List.of()); + if (path.contains(itemCode)) m.put("warning", "순환 BOM 감지"); + return m; + } + List newPath = new ArrayList<>(path); + newPath.add(itemCode); + List comps = mapper.findByParent(itemCode, version); + List> children = new ArrayList<>(); + for (MesBom c : comps) { + double loss = c.getLossRate() == null ? 0 : c.getLossRate(); + double childQty = qty * c.getQtyPer() * (1 + loss); + children.add(node(c.getChildItemCode(), childQty, version, depth + 1, newPath)); + } + m.put("children", children); + return m; + } + + /** 소요량 집계(MRP) — 리프 자재별 총 소요량 평탄화. */ + public Map materialRequirement(String parentItemCode, double qty, String version) { + Map agg = new LinkedHashMap<>(); + accumulate(parentItemCode, qty, version, 0, new ArrayList<>(), agg); + return agg; + } + + private void accumulate(String itemCode, double qty, String version, int depth, + List path, Map agg) { + if (depth >= MAX_DEPTH || path.contains(itemCode)) return; + List comps = mapper.findByParent(itemCode, version); + if (comps.isEmpty()) { + if (depth > 0) agg.merge(itemCode, round3(qty), Double::sum); // 리프 자재 + return; + } + List newPath = new ArrayList<>(path); + newPath.add(itemCode); + for (MesBom c : comps) { + double loss = c.getLossRate() == null ? 0 : c.getLossRate(); + double childQty = qty * c.getQtyPer() * (1 + loss); + accumulate(c.getChildItemCode(), childQty, version, depth + 1, newPath, agg); + } + } + + private MesBom require(Long id) { + MesBom b = mapper.findById(id); + if (b == null) throw new RuntimeException("ERR-BOM-404: BOM 없음"); + return b; + } + + private double round3(double d) { + return Math.round(d * 1000.0) / 1000.0; + } +} diff --git a/backend/src/main/java/com/zioinfo/mes/bom/MesBom.java b/backend/src/main/java/com/zioinfo/mes/bom/MesBom.java new file mode 100644 index 0000000..d5e6f63 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/bom/MesBom.java @@ -0,0 +1,24 @@ +package com.zioinfo.mes.bom; + +import lombok.Data; +import java.time.LocalDateTime; + +/** + * BOM 구성 (mes_bom) — 부모 품목(parentItemCode)에 대한 자재 구성 라인. + * + *

소요량(qtyPer): 부모 1단위 생산에 필요한 자식 수량. 트리는 parent/child 코드로 재귀 전개. + */ +@Data +public class MesBom { + private Long id; + private String parentItemCode; + private String childItemCode; + private Double qtyPer; // 소요량 + private String unit; + private Integer seq; + private String bomVersion; + private Double lossRate; // 손실률(0~1) + private String status; // ACTIVE / INACTIVE + private String createdBy; + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/com/zioinfo/mes/bom/mapper/BomMapper.java b/backend/src/main/java/com/zioinfo/mes/bom/mapper/BomMapper.java new file mode 100644 index 0000000..6c82c2d --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/bom/mapper/BomMapper.java @@ -0,0 +1,18 @@ +package com.zioinfo.mes.bom.mapper; + +import com.zioinfo.mes.bom.MesBom; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface BomMapper { + List findByParent(@Param("parentItemCode") String parentItemCode, + @Param("bomVersion") String bomVersion); + List findAll(@Param("status") String status); + MesBom findById(@Param("id") Long id); + int insert(MesBom bom); + int update(MesBom bom); + int delete(@Param("id") Long id); +} diff --git a/backend/src/main/java/com/zioinfo/mes/capa/CapaController.java b/backend/src/main/java/com/zioinfo/mes/capa/CapaController.java new file mode 100644 index 0000000..7e6857d --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/capa/CapaController.java @@ -0,0 +1,74 @@ +package com.zioinfo.mes.capa; + +import com.zioinfo.mes.admin.AuditService; +import com.zioinfo.mes.capa.mapper.CapaMapper; +import com.zioinfo.mes.common.ApiResponse; +import com.zioinfo.mes.common.AuthSupport; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; + +/** + * 시정·예방조치(CAPA) API (QMS). 조회 Viewer+, 등록/수정/상태전이 Worker+. + */ +@RestController +@RequestMapping("/api/mes/capa") +@RequiredArgsConstructor +public class CapaController { + + private final CapaMapper mapper; + private final AuditService audit; + + @GetMapping + public ApiResponse> list(@RequestParam(required = false) String status, + @RequestParam(required = false) String capaType) { + return ApiResponse.ok(mapper.findAll(status, capaType)); + } + + @GetMapping("/{id}") + public ApiResponse get(@PathVariable Long id) { + MesCapa c = mapper.findById(id); + if (c == null) throw new RuntimeException("ERR-CAPA-404: CAPA 없음"); + return ApiResponse.ok(c); + } + + @PostMapping + public ApiResponse create(@RequestBody MesCapa c, Authentication auth) { + if (c.getCapaNo() == null || c.getCapaNo().isBlank()) + c.setCapaNo("CAPA-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + + "-" + (System.currentTimeMillis() % 100000)); + if (c.getCapaType() == null) c.setCapaType("CORRECTIVE"); + c.setStatus("OPEN"); + c.setCreatedBy(AuthSupport.actor(auth)); + mapper.insert(c); + audit.log("CAPA_CREATE", c.getCapaNo(), "ncr=" + c.getNcrNo()); + return ApiResponse.ok(mapper.findById(c.getId())); + } + + @PutMapping("/{id}") + public ApiResponse update(@PathVariable Long id, @RequestBody MesCapa c, Authentication auth) { + MesCapa cur = mapper.findById(id); + if (cur == null) throw new RuntimeException("ERR-CAPA-404: CAPA 없음"); + c.setId(id); + mapper.update(c); + audit.log("CAPA_UPDATE", cur.getCapaNo(), "id=" + id); + return ApiResponse.ok(mapper.findById(id)); + } + + /** 상태 전이(OPEN/INPROGRESS/VERIFY/CLOSED). CLOSE 시 effectiveness 권장. */ + @PostMapping("/{id}/transition") + public ApiResponse transition(@PathVariable Long id, @RequestBody TransitionRequest req, + Authentication auth) { + MesCapa cur = mapper.findById(id); + if (cur == null) throw new RuntimeException("ERR-CAPA-404: CAPA 없음"); + mapper.updateStatus(id, req.status(), req.effectiveness()); + audit.log("CAPA_STATUS", cur.getCapaNo(), cur.getStatus() + " -> " + req.status()); + return ApiResponse.ok(mapper.findById(id)); + } + + record TransitionRequest(String status, String effectiveness) {} +} diff --git a/backend/src/main/java/com/zioinfo/mes/capa/MesCapa.java b/backend/src/main/java/com/zioinfo/mes/capa/MesCapa.java new file mode 100644 index 0000000..a1759fe --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/capa/MesCapa.java @@ -0,0 +1,28 @@ +package com.zioinfo.mes.capa; + +import lombok.Data; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 시정·예방조치 (mes_capa) — Corrective And Preventive Action(8D). + * + *

status: OPEN → INPROGRESS → VERIFY → CLOSED. capaType: CORRECTIVE/PREVENTIVE. + */ +@Data +public class MesCapa { + private Long id; + private String capaNo; + private String ncrNo; + private String capaType; // CORRECTIVE/PREVENTIVE + private String title; + private String rootCause; + private String actionPlan; // 8D/시정조치 계획 + private String owner; + private LocalDate dueDate; + private String effectiveness; + private String status; // OPEN/INPROGRESS/VERIFY/CLOSED + private String createdBy; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/backend/src/main/java/com/zioinfo/mes/capa/mapper/CapaMapper.java b/backend/src/main/java/com/zioinfo/mes/capa/mapper/CapaMapper.java new file mode 100644 index 0000000..550c54d --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/capa/mapper/CapaMapper.java @@ -0,0 +1,17 @@ +package com.zioinfo.mes.capa.mapper; + +import com.zioinfo.mes.capa.MesCapa; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface CapaMapper { + List findAll(@Param("status") String status, @Param("capaType") String capaType); + MesCapa findById(@Param("id") Long id); + int insert(MesCapa c); + int update(MesCapa c); + int updateStatus(@Param("id") Long id, @Param("status") String status, + @Param("effectiveness") String effectiveness); +} diff --git a/backend/src/main/java/com/zioinfo/mes/certificate/CertificateController.java b/backend/src/main/java/com/zioinfo/mes/certificate/CertificateController.java new file mode 100644 index 0000000..16fa774 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/certificate/CertificateController.java @@ -0,0 +1,55 @@ +package com.zioinfo.mes.certificate; + +import com.zioinfo.mes.admin.AuditService; +import com.zioinfo.mes.certificate.mapper.CertificateMapper; +import com.zioinfo.mes.common.ApiResponse; +import com.zioinfo.mes.common.AuthSupport; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; + +/** + * 성적서/MSDS API (QMS). 조회 Viewer+, 발행 Worker+. + */ +@RestController +@RequestMapping("/api/mes/certificates") +@RequiredArgsConstructor +public class CertificateController { + + private final CertificateMapper mapper; + private final AuditService audit; + + @GetMapping + public ApiResponse> list(@RequestParam(required = false) String certType, + @RequestParam(required = false) String itemCode, + @RequestParam(required = false) String lotNo) { + return ApiResponse.ok(mapper.findAll(certType, itemCode, lotNo)); + } + + @GetMapping("/{id}") + public ApiResponse get(@PathVariable Long id) { + MesCertificate c = mapper.findById(id); + if (c == null) throw new RuntimeException("ERR-CERT-404: 성적서 없음"); + return ApiResponse.ok(c); + } + + @PostMapping + public ApiResponse create(@RequestBody MesCertificate c, Authentication auth) { + if (c.getCertType() == null) c.setCertType("COA"); + if (c.getCertificateNo() == null || c.getCertificateNo().isBlank()) + c.setCertificateNo(c.getCertType() + "-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + + "-" + (System.currentTimeMillis() % 100000)); + if (mapper.countByNo(c.getCertificateNo()) > 0) + throw new RuntimeException("ERR-CERT-409: 중복 성적서번호"); + if (c.getIssuedDate() == null) c.setIssuedDate(LocalDate.now()); + if (c.getStatus() == null) c.setStatus("ISSUED"); + c.setCreatedBy(AuthSupport.actor(auth)); + mapper.insert(c); + audit.log("CERTIFICATE_ISSUE", c.getCertificateNo(), "type=" + c.getCertType() + " item=" + c.getItemCode()); + return ApiResponse.ok(mapper.findById(c.getId())); + } +} diff --git a/backend/src/main/java/com/zioinfo/mes/certificate/MesCertificate.java b/backend/src/main/java/com/zioinfo/mes/certificate/MesCertificate.java new file mode 100644 index 0000000..7f7951e --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/certificate/MesCertificate.java @@ -0,0 +1,26 @@ +package com.zioinfo.mes.certificate; + +import lombok.Data; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 성적서/MSDS (mes_certificate) — COA(성적서)/MSDS/TEST_REPORT 발행·조회. + */ +@Data +public class MesCertificate { + private Long id; + private String certificateNo; + private String certType; // COA/MSDS/TEST_REPORT + private String itemCode; + private String lotNo; + private String partnerCode; + private String refType; + private String refNo; + private LocalDate issuedDate; + private String content; // JSONB 문자열(시험 항목/결과) + private String filePath; + private String status; + private String createdBy; + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/com/zioinfo/mes/certificate/mapper/CertificateMapper.java b/backend/src/main/java/com/zioinfo/mes/certificate/mapper/CertificateMapper.java new file mode 100644 index 0000000..e5d26fc --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/certificate/mapper/CertificateMapper.java @@ -0,0 +1,17 @@ +package com.zioinfo.mes.certificate.mapper; + +import com.zioinfo.mes.certificate.MesCertificate; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface CertificateMapper { + List findAll(@Param("certType") String certType, + @Param("itemCode") String itemCode, + @Param("lotNo") String lotNo); + MesCertificate findById(@Param("id") Long id); + int countByNo(@Param("certificateNo") String certificateNo); + int insert(MesCertificate c); +} diff --git a/backend/src/main/java/com/zioinfo/mes/common/ApiResponse.java b/backend/src/main/java/com/zioinfo/mes/common/ApiResponse.java new file mode 100644 index 0000000..2b01c0b --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/common/ApiResponse.java @@ -0,0 +1,20 @@ +package com.zioinfo.mes.common; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@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/mes/common/AuthSupport.java b/backend/src/main/java/com/zioinfo/mes/common/AuthSupport.java new file mode 100644 index 0000000..e0011e1 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/common/AuthSupport.java @@ -0,0 +1,36 @@ +package com.zioinfo.mes.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-MES-403: 기준정보/마감/승인은 MANAGER 이상만 가능합니다"); + } + } +} diff --git a/backend/src/main/java/com/zioinfo/mes/common/CryptoUtil.java b/backend/src/main/java/com/zioinfo/mes/common/CryptoUtil.java new file mode 100644 index 0000000..3205af7 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/common/CryptoUtil.java @@ -0,0 +1,93 @@ +package com.zioinfo.mes.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-mes-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-MES-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/mes/common/GlobalExceptionHandler.java b/backend/src/main/java/com/zioinfo/mes/common/GlobalExceptionHandler.java new file mode 100644 index 0000000..1586a58 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/common/GlobalExceptionHandler.java @@ -0,0 +1,56 @@ +package com.zioinfo.mes.common; + +import lombok.extern.slf4j.Slf4j; +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-MES-413: 파일 크기 초과 (최대 20MB)"); + } + + @ExceptionHandler(AccessDeniedException.class) + @ResponseStatus(HttpStatus.FORBIDDEN) + public ApiResponse handleAccessDenied(AccessDeniedException e) { + log.warn("권한 거부: {}", e.getMessage()); + return ApiResponse.fail("ERR-MES-403: 권한이 없습니다"); + } + + @ExceptionHandler(IllegalArgumentException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ApiResponse handleIllegalArg(IllegalArgumentException e) { + log.warn("잘못된 요청: {}", e.getMessage()); + return ApiResponse.fail(e.getMessage()); + } + + @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/mes/config/CorsConfig.java b/backend/src/main/java/com/zioinfo/mes/config/CorsConfig.java new file mode 100644 index 0000000..afb85b9 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/config/CorsConfig.java @@ -0,0 +1,25 @@ +package com.zioinfo.mes.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/mes/config/MyBatisConfig.java b/backend/src/main/java/com/zioinfo/mes/config/MyBatisConfig.java new file mode 100644 index 0000000..522b8c6 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/config/MyBatisConfig.java @@ -0,0 +1,36 @@ +package com.zioinfo.mes.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 @Mapper} 인터페이스만 등록(annotationClass=Mapper.class). + * 모든 매퍼 인터페이스에 {@code @Mapper} 필수, XML namespace=FQN. + */ +@Configuration +@MapperScan(basePackages = "com.zioinfo.mes", 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/mes/config/SecurityConfig.java b/backend/src/main/java/com/zioinfo/mes/config/SecurityConfig.java new file mode 100644 index 0000000..bf3a2bd --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/config/SecurityConfig.java @@ -0,0 +1,83 @@ +package com.zioinfo.mes.config; + +import com.zioinfo.mes.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 MES 보안 설정 — JWT 무상태 인증 + RBAC. + * + *

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

    + *
  • auth/health/swagger/정적 — permitAll
  • + *
  • 조회(GET /api/mes/**) — 인증 사용자 전체(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/mes/auth/**").permitAll() + .requestMatchers("/actuator/health").permitAll() + .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/api/mes/docs/**", "/api/mes/swagger/**").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") + // 감사 로그 조회 — SUPERADMIN/MANAGER + .requestMatchers("/api/admin/audit").hasAnyRole("SUPERADMIN", "MANAGER") + .requestMatchers("/api/admin/**").hasRole("SUPERADMIN") + + // 변경(실적·검사·입출고·재고이동) — WORKER 이상 (기준정보/마감/승인은 서비스에서 MANAGER+ 가드) + .requestMatchers(HttpMethod.POST, "/api/mes/**").hasAnyRole("SUPERADMIN", "MANAGER", "WORKER") + .requestMatchers(HttpMethod.PUT, "/api/mes/**").hasAnyRole("SUPERADMIN", "MANAGER", "WORKER") + .requestMatchers(HttpMethod.PATCH, "/api/mes/**").hasAnyRole("SUPERADMIN", "MANAGER", "WORKER") + .requestMatchers(HttpMethod.DELETE, "/api/mes/**").hasAnyRole("SUPERADMIN", "MANAGER", "WORKER") + // 조회 — 인증 사용자 전체(Viewer+) + .requestMatchers(HttpMethod.GET, "/api/mes/**").hasAnyRole("SUPERADMIN", "MANAGER", "WORKER", "VIEWER") + + .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/mes/config/SpaForwardController.java b/backend/src/main/java/com/zioinfo/mes/config/SpaForwardController.java new file mode 100644 index 0000000..a660efe --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/config/SpaForwardController.java @@ -0,0 +1,26 @@ +package com.zioinfo.mes.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 MES UP"; + } +} diff --git a/backend/src/main/java/com/zioinfo/mes/dashboard/DashboardController.java b/backend/src/main/java/com/zioinfo/mes/dashboard/DashboardController.java new file mode 100644 index 0000000..acfd972 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/dashboard/DashboardController.java @@ -0,0 +1,42 @@ +package com.zioinfo.mes.dashboard; + +import com.zioinfo.mes.common.ApiResponse; +import com.zioinfo.mes.dashboard.mapper.DashboardMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; + +/** + * MES 대시보드 API — 실시간 생산·재고·품질·설비·출하 요약(조회 Viewer+). + */ +@RestController +@RequestMapping("/api/mes/dashboard") +@RequiredArgsConstructor +public class DashboardController { + + private final DashboardMapper mapper; + + @GetMapping("/summary") + public ApiResponse> summary() { + return ApiResponse.ok(mapper.summary()); + } + + @GetMapping("/workorder-status") + public ApiResponse>> workorderStatus() { + return ApiResponse.ok(mapper.workorderByStatus()); + } + + @GetMapping("/shipping-status") + public ApiResponse>> shippingStatus() { + return ApiResponse.ok(mapper.shippingByStatus()); + } + + @GetMapping("/equipment-status") + public ApiResponse>> equipmentStatus() { + return ApiResponse.ok(mapper.equipmentRunStatus()); + } +} diff --git a/backend/src/main/java/com/zioinfo/mes/dashboard/mapper/DashboardMapper.java b/backend/src/main/java/com/zioinfo/mes/dashboard/mapper/DashboardMapper.java new file mode 100644 index 0000000..4533e5c --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/dashboard/mapper/DashboardMapper.java @@ -0,0 +1,17 @@ +package com.zioinfo.mes.dashboard.mapper; + +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; +import java.util.Map; + +/** + * MES 대시보드 매퍼 — 실시간 생산·재고·품질·설비 요약(읽기 전용). + */ +@Mapper +public interface DashboardMapper { + Map summary(); + List> workorderByStatus(); + List> shippingByStatus(); + List> equipmentRunStatus(); +} diff --git a/backend/src/main/java/com/zioinfo/mes/equipment/EquipmentController.java b/backend/src/main/java/com/zioinfo/mes/equipment/EquipmentController.java new file mode 100644 index 0000000..8955335 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/equipment/EquipmentController.java @@ -0,0 +1,105 @@ +package com.zioinfo.mes.equipment; + +import com.zioinfo.mes.admin.AuditService; +import com.zioinfo.mes.ai.AiService; +import com.zioinfo.mes.common.ApiResponse; +import com.zioinfo.mes.common.AuthSupport; +import com.zioinfo.mes.equipment.mapper.EquipmentMapper; +import com.zioinfo.mes.integration.ItsmClient; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** + * 설비 마스터 API + 예지보전(AI) + 비가동→ITSM SR 연계. + * 조회 Viewer+, 기준정보 변경 Manager+, 상태변경 Worker+. + */ +@RestController +@RequestMapping("/api/mes/equipment") +@RequiredArgsConstructor +public class EquipmentController { + + private final EquipmentMapper mapper; + private final AuditService audit; + private final AiService ai; + private final ItsmClient itsm; + + @GetMapping + public ApiResponse> list(@RequestParam(required = false) String status, + @RequestParam(required = false) String runStatus) { + return ApiResponse.ok(mapper.findAll(status, runStatus)); + } + + @GetMapping("/{id}") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.ok(require(id)); + } + + @PostMapping + public ApiResponse create(@RequestBody MesEquipment e, Authentication auth) { + AuthSupport.requireManager(auth); + if (e.getEquipmentCode() == null) throw new IllegalArgumentException("ERR-EQP-400: equipmentCode 필수"); + if (mapper.countByCode(e.getEquipmentCode(), null) > 0) + throw new RuntimeException("ERR-EQP-409: 중복 설비코드"); + if (e.getStatus() == null) e.setStatus("ACTIVE"); + if (e.getRunStatus() == null) e.setRunStatus("IDLE"); + e.setCreatedBy(AuthSupport.actor(auth)); + mapper.insert(e); + audit.log("EQUIPMENT_CREATE", e.getEquipmentCode(), ""); + return ApiResponse.ok(mapper.findById(e.getId())); + } + + @PutMapping("/{id}") + public ApiResponse update(@PathVariable Long id, @RequestBody MesEquipment e, Authentication auth) { + AuthSupport.requireManager(auth); + require(id); + e.setId(id); + mapper.update(e); + audit.log("EQUIPMENT_UPDATE", e.getEquipmentCode(), "id=" + id); + return ApiResponse.ok(mapper.findById(id)); + } + + /** 가동상태 변경(RUN/IDLE/DOWN/MAINT) — DOWN 시 ITSM SR 자동 생성. Worker+. */ + @PutMapping("/{id}/run-status") + public ApiResponse runStatus(@PathVariable Long id, @RequestBody RunStatusRequest req) { + MesEquipment e = require(id); + mapper.updateRunStatus(id, req.runStatus()); + audit.log("EQUIPMENT_RUNSTATUS", e.getEquipmentCode(), "-> " + req.runStatus()); + if ("DOWN".equalsIgnoreCase(req.runStatus())) { + String srId = itsm.createSr( + "[MES] 설비 비가동: " + e.getEquipmentName(), + "설비 " + e.getEquipmentCode() + " DOWN 발생. 사유: " + (req.reason() == null ? "미입력" : req.reason()), + "HIGH"); + audit.log("EQUIPMENT_DOWN_SR", e.getEquipmentCode(), "srId=" + srId); + } + return ApiResponse.ok(mapper.findById(id)); + } + + /** 설비 예지보전 분석(AI). */ + @PostMapping("/{id}/predictive-maintenance") + public ApiResponse> pdm(@PathVariable Long id, @RequestBody PdmRequest req) { + MesEquipment e = require(id); + return ApiResponse.ok(ai.predictiveMaintenance( + e.getEquipmentCode(), req.availability(), req.downtimeCount(), req.mtbfHours())); + } + + @DeleteMapping("/{id}") + public ApiResponse delete(@PathVariable Long id, Authentication auth) { + AuthSupport.requireManager(auth); + mapper.delete(id); + audit.log("EQUIPMENT_DELETE", String.valueOf(id), ""); + return ApiResponse.ok(null); + } + + private MesEquipment require(Long id) { + MesEquipment e = mapper.findById(id); + if (e == null) throw new RuntimeException("ERR-EQP-404: 설비 없음"); + return e; + } + + record RunStatusRequest(String runStatus, String reason) {} + record PdmRequest(double availability, int downtimeCount, double mtbfHours) {} +} diff --git a/backend/src/main/java/com/zioinfo/mes/equipment/MesEquipment.java b/backend/src/main/java/com/zioinfo/mes/equipment/MesEquipment.java new file mode 100644 index 0000000..be962be --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/equipment/MesEquipment.java @@ -0,0 +1,24 @@ +package com.zioinfo.mes.equipment; + +import lombok.Data; +import java.time.LocalDateTime; + +/** + * 설비 마스터 (mes_equipment) — 점검주기·파라미터(JSONB)·상태. + */ +@Data +public class MesEquipment { + private Long id; + private String equipmentCode; + private String equipmentName; + private String processCode; + private String warehouseCode; // 위치(라인/공장) + private Integer checkCycleDays; // 점검 주기(일) + private LocalDateTime lastCheckedAt; + private String parameters; // JSONB — 가변 설비 파라미터 + private String runStatus; // RUN / IDLE / DOWN / MAINT + private String status; // ACTIVE / INACTIVE + private String createdBy; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/backend/src/main/java/com/zioinfo/mes/equipment/mapper/EquipmentMapper.java b/backend/src/main/java/com/zioinfo/mes/equipment/mapper/EquipmentMapper.java new file mode 100644 index 0000000..b87ed14 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/equipment/mapper/EquipmentMapper.java @@ -0,0 +1,19 @@ +package com.zioinfo.mes.equipment.mapper; + +import com.zioinfo.mes.equipment.MesEquipment; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface EquipmentMapper { + List findAll(@Param("status") String status, @Param("runStatus") String runStatus); + MesEquipment findById(@Param("id") Long id); + MesEquipment findByCode(@Param("equipmentCode") String equipmentCode); + int countByCode(@Param("equipmentCode") String equipmentCode, @Param("excludeId") Long excludeId); + int insert(MesEquipment e); + int update(MesEquipment 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/mes/inspection/InspectionController.java b/backend/src/main/java/com/zioinfo/mes/inspection/InspectionController.java new file mode 100644 index 0000000..d43468e --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/inspection/InspectionController.java @@ -0,0 +1,63 @@ +package com.zioinfo.mes.inspection; + +import com.zioinfo.mes.common.ApiResponse; +import com.zioinfo.mes.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 (QMS) — IQC/IPQC/OQC. 조회 Viewer+, 등록/판정 Worker+. + */ +@RestController +@RequestMapping("/api/mes/inspections") +@RequiredArgsConstructor +public class InspectionController { + + private final InspectionService service; + + @GetMapping + public ApiResponse> list(@RequestParam(required = false) String inspectionType, + @RequestParam(required = false) String result, + @RequestParam(required = false) String itemCode) { + return ApiResponse.ok(service.list(inspectionType, result, itemCode)); + } + + @GetMapping("/pass-rate") + public ApiResponse>> passRate(@RequestParam(defaultValue = "30") int days) { + return ApiResponse.ok(service.passRate(days)); + } + + @GetMapping("/{id}") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.ok(service.get(id)); + } + + @PostMapping + public ApiResponse create(@RequestBody MesInspection i, Authentication auth) { + return ApiResponse.ok(service.create(i, AuthSupport.actor(auth))); + } + + /** AI 판정 보조 — 측정값 vs 스펙 자동 판정(FAIL 시 NCR 자동 생성). */ + @PostMapping("/{id}/judge") + public ApiResponse> judge(@PathVariable Long id, @RequestBody JudgeRequest req, + Authentication auth) { + return ApiResponse.ok(service.judge(id, req.measured(), req.lsl(), req.usl(), req.target(), + req.defectQty(), req.defectCode(), AuthSupport.actor(auth))); + } + + /** 수동 결과 입력(PASS/FAIL). FAIL 시 NCR 자동 생성. */ + @PostMapping("/{id}/result") + public ApiResponse setResult(@PathVariable Long id, @RequestBody ResultRequest req, + Authentication auth) { + return ApiResponse.ok(service.setResult(id, req.result(), req.defectQty(), req.defectCode(), + AuthSupport.actor(auth))); + } + + record JudgeRequest(double measured, Double lsl, Double usl, Double target, + double defectQty, String defectCode) {} + record ResultRequest(String result, double defectQty, String defectCode) {} +} diff --git a/backend/src/main/java/com/zioinfo/mes/inspection/InspectionService.java b/backend/src/main/java/com/zioinfo/mes/inspection/InspectionService.java new file mode 100644 index 0000000..0e258c5 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/inspection/InspectionService.java @@ -0,0 +1,100 @@ +package com.zioinfo.mes.inspection; + +import com.zioinfo.mes.admin.AuditService; +import com.zioinfo.mes.ai.AiService; +import com.zioinfo.mes.inspection.mapper.InspectionMapper; +import com.zioinfo.mes.ncr.NcrService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Map; + +/** + * 검사 서비스 — IQC/IPQC/OQC 검사 등록·판정·불합격 시 NCR 자동 생성. + * + *

판정(judge)은 {@link AiService#inspectionJudge}(Ollama+Java 폴백)로 측정값 vs 스펙 자동 판정. + * FAIL 시 {@link NcrService#createFromSource}로 부적합을 자동 기록한다. + */ +@Service +@RequiredArgsConstructor +public class InspectionService { + + private final InspectionMapper mapper; + private final AiService ai; + private final NcrService ncrService; + private final AuditService audit; + + public List list(String inspectionType, String result, String itemCode) { + return mapper.findAll(inspectionType, result, itemCode); + } + + public MesInspection get(Long id) { + MesInspection i = mapper.findById(id); + if (i == null) throw new RuntimeException("ERR-INS-404: 검사 없음"); + return i; + } + + public List> passRate(int days) { + return mapper.passRateByType(days); + } + + @Transactional + public MesInspection create(MesInspection i, String actor) { + if (i.getItemCode() == null || i.getItemCode().isBlank()) + throw new IllegalArgumentException("ERR-INS-400: itemCode 필수"); + if (i.getInspectionType() == null) i.setInspectionType("IQC"); + if (i.getInspectionNo() == null || i.getInspectionNo().isBlank()) + i.setInspectionNo("INS-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + + "-" + (System.currentTimeMillis() % 100000)); + if (i.getResult() == null) i.setResult("PENDING"); + if (i.getSampleSize() == null) i.setSampleSize(1); + i.setCreatedBy(actor); + mapper.insert(i); + audit.log("INSPECTION_CREATE", i.getInspectionNo(), "type=" + i.getInspectionType() + " item=" + i.getItemCode()); + return mapper.findById(i.getId()); + } + + /** + * 검사 판정 — 측정값 vs 스펙. AI 판정 보조 호출. + * FAIL 시 NCR 자동 생성. + */ + @Transactional + public Map judge(Long id, double measured, Double lsl, Double usl, Double target, + double defectQty, String defectCode, String actor) { + MesInspection i = get(id); + Map judgement = ai.inspectionJudge(measured, lsl, usl, target); + String result = String.valueOf(judgement.getOrDefault("judgement", "PASS")); + String measurements = "[{\"measured\":" + measured + ",\"lsl\":" + lsl + ",\"usl\":" + usl + + ",\"judge\":\"" + result + "\"}]"; + mapper.updateResult(id, result, measurements, defectQty); + audit.log("INSPECTION_JUDGE", i.getInspectionNo(), "result=" + result + " measured=" + measured); + if ("FAIL".equals(result)) { + String severity = "OQC".equals(i.getInspectionType()) ? "CRITICAL" : "MAJOR"; + ncrService.createFromSource(i.getItemCode(), i.getLotNo(), "INSPECTION", i.getInspectionNo(), + defectCode == null ? "INSP-FAIL" : defectCode, defectQty, severity, actor); + judgement.put("ncrCreated", true); + } + return judgement; + } + + /** 수동 판정 결과 입력(PASS/FAIL). FAIL 시 NCR 자동 생성. */ + @Transactional + public MesInspection setResult(Long id, String result, double defectQty, String defectCode, String actor) { + MesInspection i = get(id); + String r = result == null ? "PASS" : result.toUpperCase(); + if (!r.equals("PASS") && !r.equals("FAIL")) + throw new IllegalArgumentException("ERR-INS-400: 결과는 PASS/FAIL"); + mapper.updateResult(id, r, null, defectQty); + audit.log("INSPECTION_RESULT", i.getInspectionNo(), "result=" + r); + if ("FAIL".equals(r)) { + String severity = "OQC".equals(i.getInspectionType()) ? "CRITICAL" : "MAJOR"; + ncrService.createFromSource(i.getItemCode(), i.getLotNo(), "INSPECTION", i.getInspectionNo(), + defectCode == null ? "INSP-FAIL" : defectCode, defectQty, severity, actor); + } + return mapper.findById(id); + } +} diff --git a/backend/src/main/java/com/zioinfo/mes/inspection/MesInspection.java b/backend/src/main/java/com/zioinfo/mes/inspection/MesInspection.java new file mode 100644 index 0000000..f83a9d2 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/inspection/MesInspection.java @@ -0,0 +1,29 @@ +package com.zioinfo.mes.inspection; + +import lombok.Data; +import java.time.LocalDateTime; + +/** + * 검사 (mes_inspection) — IQC(수입)/IPQC(공정)/OQC(출하) 검사 + 측정값(JSONB) + 합/불 판정. + * + *

result: PENDING → PASS / FAIL. + */ +@Data +public class MesInspection { + private Long id; + private String inspectionNo; + private String inspectionType; // IQC/IPQC/OQC + private String itemCode; + private String lotNo; + private String refType; // RECEIVING/WORKORDER/SHIPPING + private String refNo; + private String specCode; + private Integer sampleSize; + private String measurements; // JSONB 문자열 [{characteristic, measured, lsl, usl, judge}] + private String result; // PENDING/PASS/FAIL + private Double defectQty; + private String inspector; + private String remark; + private String createdBy; + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/com/zioinfo/mes/inspection/mapper/InspectionMapper.java b/backend/src/main/java/com/zioinfo/mes/inspection/mapper/InspectionMapper.java new file mode 100644 index 0000000..9ec994d --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/inspection/mapper/InspectionMapper.java @@ -0,0 +1,22 @@ +package com.zioinfo.mes.inspection.mapper; + +import com.zioinfo.mes.inspection.MesInspection; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; +import java.util.Map; + +@Mapper +public interface InspectionMapper { + List findAll(@Param("inspectionType") String inspectionType, + @Param("result") String result, + @Param("itemCode") String itemCode); + MesInspection findById(@Param("id") Long id); + int countByNo(@Param("inspectionNo") String inspectionNo); + int insert(MesInspection i); + int updateResult(@Param("id") Long id, @Param("result") String result, + @Param("measurements") String measurements, @Param("defectQty") Double defectQty); + /** 검사 합격률 요약(유형별). */ + List> passRateByType(@Param("days") int days); +} diff --git a/backend/src/main/java/com/zioinfo/mes/integration/BiClient.java b/backend/src/main/java/com/zioinfo/mes/integration/BiClient.java new file mode 100644 index 0000000..e887775 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/integration/BiClient.java @@ -0,0 +1,30 @@ +package com.zioinfo.mes.integration; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpMethod; +import org.springframework.stereotype.Component; + +import java.util.Map; + +/** + * GUARDiA BI 연계 — 생산/품질/재고 KPI를 BI 데이터 피드로 push. + */ +@Component +@RequiredArgsConstructor +public class BiClient { + + @Value("${guardia.bi-url:http://localhost:8006}") + private String biUrl; + + private final GuardiaHttpClient http; + + /** KPI 스냅샷을 BI 피드로 전송(연계 실패 시 무시). */ + public Map pushKpiFeed(Map kpi) { + return http.callMap(biUrl, "/api/bi/feed/mes", HttpMethod.POST, kpi); + } + + public boolean available() { + return !http.callMap(biUrl, "/actuator/health", HttpMethod.GET, null).isEmpty(); + } +} diff --git a/backend/src/main/java/com/zioinfo/mes/integration/ErpClient.java b/backend/src/main/java/com/zioinfo/mes/integration/ErpClient.java new file mode 100644 index 0000000..43bb698 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/integration/ErpClient.java @@ -0,0 +1,38 @@ +package com.zioinfo.mes.integration; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpMethod; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; + +/** + * GUARDiA ERP 연계 — 생산계획·BOM·MRP 자재소요·생산실적→정산. + * + *

연계 실패 시 빈 결과(폴백은 호출 측). 응답은 새니타이즈됨. + */ +@Component +@RequiredArgsConstructor +public class ErpClient { + + @Value("${guardia.erp-url:http://localhost:8003}") + private String erpUrl; + + private final GuardiaHttpClient http; + + /** ERP 생산계획 조회(연계 실패 시 빈 리스트 → MES 자체 plan 사용). */ + public List fetchProductionPlans() { + return http.callList(erpUrl, "/api/erp/production/plans", HttpMethod.GET, null); + } + + /** 생산실적 → ERP 정산 피드(연계 실패 시 무시). */ + public Map pushProductionResult(Map result) { + return http.callMap(erpUrl, "/api/erp/production/result", HttpMethod.POST, result); + } + + public boolean available() { + return !http.callMap(erpUrl, "/actuator/health", HttpMethod.GET, null).isEmpty(); + } +} diff --git a/backend/src/main/java/com/zioinfo/mes/integration/GuardiaHttpClient.java b/backend/src/main/java/com/zioinfo/mes/integration/GuardiaHttpClient.java new file mode 100644 index 0000000..ebdc402 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/integration/GuardiaHttpClient.java @@ -0,0 +1,68 @@ +package com.zioinfo.mes.integration; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpMethod; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; + +import java.time.Duration; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * GUARDiA 연계 솔루션(ERP/ITSM/OCR/BI) 공용 HTTP 클라이언트. + * + *

모든 응답은 {@link ItsmSecuritySanitizer#clean(Object)}로 자격증명을 제거한 뒤 반환한다. + * 연계 실패 시 빈 Map/List 반환(스택트레이스 미노출, 요약 로그만) — 폴백을 호출 측이 수행한다. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class GuardiaHttpClient { + + private final WebClient.Builder webClientBuilder; + + @SuppressWarnings("unchecked") + public Map callMap(String baseUrl, String path, HttpMethod method, Object body) { + try { + WebClient.RequestBodySpec spec = webClientBuilder.baseUrl(baseUrl).build().method(method).uri(path); + WebClient.RequestHeadersSpec headersSpec = + (body != null && method != HttpMethod.GET) ? spec.bodyValue(body) : spec; + Map result = headersSpec.retrieve() + .bodyToMono(Map.class) + .timeout(Duration.ofSeconds(10)) + .map(m -> (Map) m) + .block(); + if (result == null) { + return Collections.emptyMap(); + } + return (Map) ItsmSecuritySanitizer.clean(result); + } catch (Exception e) { + log.warn("GUARDiA 연계 일시 실패 [{} {}{}]: {}", method, baseUrl, path, e.getMessage()); + return Collections.emptyMap(); + } + } + + @SuppressWarnings("unchecked") + public List callList(String baseUrl, String path, HttpMethod method, Object body) { + try { + WebClient.RequestBodySpec spec = webClientBuilder.baseUrl(baseUrl).build().method(method).uri(path); + WebClient.RequestHeadersSpec headersSpec = + (body != null && method != HttpMethod.GET) ? spec.bodyValue(body) : spec; + List result = headersSpec.retrieve() + .bodyToMono(List.class) + .timeout(Duration.ofSeconds(10)) + .map(l -> (List) l) + .block(); + if (result == null) { + return Collections.emptyList(); + } + return (List) ItsmSecuritySanitizer.clean(result); + } catch (Exception e) { + log.warn("GUARDiA 연계 일시 실패 [{} {}{}]: {}", method, baseUrl, path, e.getMessage()); + return Collections.emptyList(); + } + } +} diff --git a/backend/src/main/java/com/zioinfo/mes/integration/IntegrationController.java b/backend/src/main/java/com/zioinfo/mes/integration/IntegrationController.java new file mode 100644 index 0000000..9609532 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/integration/IntegrationController.java @@ -0,0 +1,48 @@ +package com.zioinfo.mes.integration; + +import com.zioinfo.mes.common.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * GUARDiA 연계 상태/조회 API. 모든 응답은 자격증명 새니타이즈됨. + * + *

RBAC: 조회 Viewer+. + */ +@RestController +@RequestMapping("/api/mes/integration") +@RequiredArgsConstructor +public class IntegrationController { + + private final ErpClient erp; + private final ItsmClient itsm; + private final OcrClient ocr; + private final BiClient bi; + + /** 연계 솔루션 헬스 요약. */ + @GetMapping("/status") + public ApiResponse> status() { + Map m = new LinkedHashMap<>(); + m.put("erp", erp.available()); + m.put("itsm", itsm.available()); + m.put("ocr", ocr.available()); + m.put("bi", bi.available()); + return ApiResponse.ok(m); + } + + /** ERP 생산계획 조회(연계). */ + @GetMapping("/erp/plans") + public ApiResponse> erpPlans() { + return ApiResponse.ok(erp.fetchProductionPlans()); + } + + /** OCR 문서 파싱 결과 조회(연계). */ + @GetMapping("/ocr/{docId}") + public ApiResponse> ocrResult(@PathVariable String docId) { + return ApiResponse.ok(ocr.fetchParsed(docId)); + } +} diff --git a/backend/src/main/java/com/zioinfo/mes/integration/ItsmClient.java b/backend/src/main/java/com/zioinfo/mes/integration/ItsmClient.java new file mode 100644 index 0000000..ad97cec --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/integration/ItsmClient.java @@ -0,0 +1,45 @@ +package com.zioinfo.mes.integration; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpMethod; +import org.springframework.stereotype.Component; + +import java.util.Map; + +/** + * GUARDiA ITSM 연계 — 설비 장애/비가동을 ITSM SR로 자동 생성한다. + * + *

응답은 {@link GuardiaHttpClient}가 자격증명을 새니타이즈한다. + */ +@Component +@RequiredArgsConstructor +public class ItsmClient { + + @Value("${guardia.itsm-url:http://localhost:9001}") + private String itsmUrl; + + private final GuardiaHttpClient http; + + /** + * 설비 장애/비가동 → ITSM SR 생성. + * + * @return 생성된 SR ID(연계 실패 시 null) + */ + public String createSr(String title, String content, String priority) { + Map payload = Map.of( + "title", title, + "content", content, + "priority", priority == null ? "MEDIUM" : priority, + "source", "MES" + ); + Map result = http.callMap(itsmUrl, "/api/tasks", HttpMethod.POST, payload); + Object id = result.getOrDefault("sr_id", result.get("id")); + return id != null ? String.valueOf(id) : null; + } + + public boolean available() { + Map r = http.callMap(itsmUrl, "/actuator/health", HttpMethod.GET, null); + return !r.isEmpty(); + } +} diff --git a/backend/src/main/java/com/zioinfo/mes/integration/ItsmSecuritySanitizer.java b/backend/src/main/java/com/zioinfo/mes/integration/ItsmSecuritySanitizer.java new file mode 100644 index 0000000..8a542f6 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/integration/ItsmSecuritySanitizer.java @@ -0,0 +1,62 @@ +package com.zioinfo.mes.integration; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * GUARDiA 연계 응답 보안 새니타이저. + * + *

보안 불변 규칙: 서버 자격증명(IP/SSH 계정/비밀번호 등)을 API 응답에 절대 노출하지 않는다. + * ITSM/ERP/OCR/BI 연계 응답을 MES로 반환하기 전 반드시 {@link #clean(Object)}를 호출한다. + * + *

Map/List 구조를 재귀적으로 순회하며 민감 필드를 제거한다. + */ +public final class ItsmSecuritySanitizer { + + private static final Set SENSITIVE_KEYS = Set.of( + "ip_addr", "ipaddr", "ssh_user", "sshuser", "os_pw_enc", "ospwenc", + "password", "password_enc", "passwordenc", "ssh_key", "sshkey", + "secret", "token", "private_key", "credential" + ); + + private ItsmSecuritySanitizer() { + } + + /** + * 응답 데이터에서 민감 필드를 재귀 제거한다. + * + * @param data Map(필터링), List(각 원소 적용), 그 외(그대로 반환) + * @return 민감 필드가 제거된 데이터 + */ + public static Object clean(Object data) { + if (data instanceof Map map) { + Map cleaned = new LinkedHashMap<>(); + for (Map.Entry entry : map.entrySet()) { + String key = String.valueOf(entry.getKey()); + if (isSensitive(key)) { + continue; + } + cleaned.put(key, clean(entry.getValue())); + } + return cleaned; + } + if (data instanceof List list) { + List cleaned = new ArrayList<>(list.size()); + for (Object item : list) { + cleaned.add(clean(item)); + } + return cleaned; + } + return data; + } + + private static boolean isSensitive(String key) { + if (key == null) { + return false; + } + return SENSITIVE_KEYS.contains(key.toLowerCase()); + } +} diff --git a/backend/src/main/java/com/zioinfo/mes/integration/OcrClient.java b/backend/src/main/java/com/zioinfo/mes/integration/OcrClient.java new file mode 100644 index 0000000..8901eee --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/integration/OcrClient.java @@ -0,0 +1,30 @@ +package com.zioinfo.mes.integration; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpMethod; +import org.springframework.stereotype.Component; + +import java.util.Map; + +/** + * GUARDiA OCR 연계 — 성적서·도면·납품서 파싱 결과를 품질/입고 등록 보조에 활용. + */ +@Component +@RequiredArgsConstructor +public class OcrClient { + + @Value("${guardia.ocr-url:http://localhost:8005}") + private String ocrUrl; + + private final GuardiaHttpClient http; + + /** OCR 문서 파싱 결과 조회(docId 기준). 연계 실패 시 빈 Map. */ + public Map fetchParsed(String docId) { + return http.callMap(ocrUrl, "/api/ocr/documents/" + docId + "/result", HttpMethod.GET, null); + } + + public boolean available() { + return !http.callMap(ocrUrl, "/actuator/health", HttpMethod.GET, null).isEmpty(); + } +} diff --git a/backend/src/main/java/com/zioinfo/mes/inventory/InventoryController.java b/backend/src/main/java/com/zioinfo/mes/inventory/InventoryController.java new file mode 100644 index 0000000..11d67fa --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/inventory/InventoryController.java @@ -0,0 +1,56 @@ +package com.zioinfo.mes.inventory; + +import com.zioinfo.mes.common.ApiResponse; +import com.zioinfo.mes.common.AuthSupport; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 재고 API — 조회/이동(transfer)/조정/이력. 조회 Viewer+, 이동·조정 Worker+. + */ +@RestController +@RequestMapping("/api/mes/inventory") +@RequiredArgsConstructor +public class InventoryController { + + private final InventoryService service; + + @GetMapping + public ApiResponse> list(@RequestParam(required = false) String itemCode, + @RequestParam(required = false) String warehouseCode, + @RequestParam(required = false) String keyword) { + return ApiResponse.ok(service.list(itemCode, warehouseCode, keyword)); + } + + @GetMapping("/available/{itemCode}") + public ApiResponse available(@PathVariable String itemCode) { + return ApiResponse.ok(service.available(itemCode)); + } + + @GetMapping("/below-safety") + public ApiResponse> belowSafety() { + return ApiResponse.ok(service.belowSafety()); + } + + @GetMapping("/txns") + public ApiResponse> txns(@RequestParam(required = false) String itemCode, + @RequestParam(required = false) String refType, + @RequestParam(required = false) String refNo, + @RequestParam(defaultValue = "200") int limit) { + return ApiResponse.ok(service.txns(itemCode, refType, refNo, limit)); + } + + /** 재고 이동(로케이션/창고 간). Worker+. */ + @PostMapping("/transfer") + public ApiResponse transfer(@RequestBody TransferRequest req, Authentication auth) { + service.transfer(req.itemCode(), req.fromWarehouse(), req.fromLocation(), + req.toWarehouse(), req.toLocation(), req.lotNo(), req.qty(), AuthSupport.actor(auth)); + return ApiResponse.ok(null); + } + + record TransferRequest(String itemCode, String fromWarehouse, String fromLocation, + String toWarehouse, String toLocation, String lotNo, double qty) {} +} diff --git a/backend/src/main/java/com/zioinfo/mes/inventory/InventoryService.java b/backend/src/main/java/com/zioinfo/mes/inventory/InventoryService.java new file mode 100644 index 0000000..2e9640f --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/inventory/InventoryService.java @@ -0,0 +1,128 @@ +package com.zioinfo.mes.inventory; + +import com.zioinfo.mes.admin.AuditService; +import com.zioinfo.mes.inventory.mapper.InventoryMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * 재고 서비스 — 수불(입고/출고/투입/산출/이동/조정) 원장 + 잔고 갱신. + * + *

모든 수량 변동은 {@link MesInventoryTxn} 이력 + 잔고({@link MesInventory}) 동시 갱신(트랜잭션). + * WMS(receiving/shipping/stocktake)·MES(production)·tracking 이 공용으로 호출한다. + */ +@Service +@RequiredArgsConstructor +public class InventoryService { + + private final InventoryMapper mapper; + private final AuditService audit; + + public List list(String itemCode, String warehouseCode, String keyword) { + return mapper.findAll(itemCode, warehouseCode, keyword); + } + + public Double available(String itemCode) { + Double q = mapper.sumQty(itemCode); + return q == null ? 0.0 : q; + } + + public List belowSafety() { + return mapper.findBelowSafety(); + } + + public List txns(String itemCode, String refType, String refNo, int limit) { + return mapper.findTxns(itemCode, refType, refNo, limit <= 0 ? 200 : limit); + } + + /** 입고/산출 — 잔고 증가 + 이력. */ + @Transactional + public void inbound(String txnType, String itemCode, String warehouseCode, String locationCode, + String lotNo, double qty, String refType, String refNo, String actor) { + if (qty <= 0) throw new IllegalArgumentException("ERR-INV-400: 수량은 0보다 커야 합니다"); + applyDelta(itemCode, warehouseCode, locationCode, lotNo, qty); + recordTxn(txnType, itemCode, null, null, warehouseCode, locationCode, lotNo, qty, refType, refNo, actor); + } + + /** 출고/투입 — 잔고 감소 + 이력. 음수 재고 차단. */ + @Transactional + public void outbound(String txnType, String itemCode, String warehouseCode, String locationCode, + String lotNo, double qty, String refType, String refNo, String actor) { + if (qty <= 0) throw new IllegalArgumentException("ERR-INV-400: 수량은 0보다 커야 합니다"); + MesInventory inv = mapper.findOne(itemCode, warehouseCode, locationCode, lotNo); + double cur = inv == null || inv.getQty() == null ? 0 : inv.getQty(); + if (cur < qty) { + throw new RuntimeException("ERR-INV-409: 재고 부족(" + itemCode + " 보유 " + cur + " < 요청 " + qty + ")"); + } + applyDelta(itemCode, warehouseCode, locationCode, lotNo, -qty); + recordTxn(txnType, itemCode, warehouseCode, locationCode, null, null, lotNo, qty, refType, refNo, actor); + } + + /** 재고 이동(TRANSFER) — from 감소, to 증가, 단일 이력. */ + @Transactional + public void transfer(String itemCode, String fromWh, String fromLoc, String toWh, String toLoc, + String lotNo, double qty, String actor) { + if (qty <= 0) throw new IllegalArgumentException("ERR-INV-400: 수량은 0보다 커야 합니다"); + MesInventory src = mapper.findOne(itemCode, fromWh, fromLoc, lotNo); + double cur = src == null || src.getQty() == null ? 0 : src.getQty(); + if (cur < qty) throw new RuntimeException("ERR-INV-409: 이동 재고 부족"); + applyDelta(itemCode, fromWh, fromLoc, lotNo, -qty); + applyDelta(itemCode, toWh, toLoc, lotNo, qty); + MesInventoryTxn txn = new MesInventoryTxn(); + txn.setTxnType("TRANSFER"); + txn.setItemCode(itemCode); + txn.setFromWarehouse(fromWh); txn.setFromLocation(fromLoc); + txn.setToWarehouse(toWh); txn.setToLocation(toLoc); + txn.setLotNo(lotNo); txn.setQty(qty); + txn.setRefType("TRANSFER"); txn.setCreatedBy(actor); + mapper.insertTxn(txn); + audit.log("INV_TRANSFER", itemCode, fromWh + "->" + toWh + " qty=" + qty); + } + + /** 재고 실사 조정(ADJUST) — 목표 수량으로 보정. */ + @Transactional + public void adjust(String itemCode, String warehouseCode, String locationCode, String lotNo, + double countedQty, String refNo, String actor) { + MesInventory inv = mapper.findOne(itemCode, warehouseCode, locationCode, lotNo); + double cur = inv == null || inv.getQty() == null ? 0 : inv.getQty(); + double delta = countedQty - cur; + applyDelta(itemCode, warehouseCode, locationCode, lotNo, delta); + recordTxn("ADJUST", itemCode, warehouseCode, locationCode, warehouseCode, locationCode, + lotNo, delta, "STOCKTAKE", refNo, actor); + audit.log("INV_ADJUST", itemCode, "delta=" + delta); + } + + /** 잔고 증감 — 행 없으면 생성. */ + private void applyDelta(String itemCode, String warehouseCode, String locationCode, String lotNo, double delta) { + MesInventory inv = mapper.findOne(itemCode, warehouseCode, locationCode, lotNo); + if (inv == null) { + inv = new MesInventory(); + inv.setItemCode(itemCode); + inv.setWarehouseCode(warehouseCode); + inv.setLocationCode(locationCode); + inv.setLotNo(lotNo); + inv.setQty(Math.max(0, delta)); + mapper.insertStock(inv); + } else { + double next = (inv.getQty() == null ? 0 : inv.getQty()) + delta; + mapper.updateQty(inv.getId(), Math.max(0, next)); + } + } + + private void recordTxn(String type, String itemCode, String fromWh, String fromLoc, + String toWh, String toLoc, String lotNo, double qty, + String refType, String refNo, String actor) { + MesInventoryTxn txn = new MesInventoryTxn(); + txn.setTxnType(type); + txn.setItemCode(itemCode); + txn.setFromWarehouse(fromWh); txn.setFromLocation(fromLoc); + txn.setToWarehouse(toWh); txn.setToLocation(toLoc); + txn.setLotNo(lotNo); txn.setQty(qty); + txn.setRefType(refType); txn.setRefNo(refNo); + txn.setCreatedBy(actor); + mapper.insertTxn(txn); + } +} diff --git a/backend/src/main/java/com/zioinfo/mes/inventory/MesInventory.java b/backend/src/main/java/com/zioinfo/mes/inventory/MesInventory.java new file mode 100644 index 0000000..29eacaf --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/inventory/MesInventory.java @@ -0,0 +1,22 @@ +package com.zioinfo.mes.inventory; + +import lombok.Data; +import java.time.LocalDateTime; + +/** + * 재고 (mes_inventory) — 품목·창고·로케이션·LOT 단위 수량. + */ +@Data +public class MesInventory { + private Long id; + private String itemCode; + private String warehouseCode; + private String locationCode; + private String lotNo; + private Double qty; + private String unit; + private LocalDateTime updatedAt; + // 조인 표시용(비영속) + private String itemName; + private Double safetyStock; +} diff --git a/backend/src/main/java/com/zioinfo/mes/inventory/MesInventoryTxn.java b/backend/src/main/java/com/zioinfo/mes/inventory/MesInventoryTxn.java new file mode 100644 index 0000000..2d6fcf6 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/inventory/MesInventoryTxn.java @@ -0,0 +1,26 @@ +package com.zioinfo.mes.inventory; + +import lombok.Data; +import java.time.LocalDateTime; + +/** + * 재고 이동 이력 (mes_inventory_txn) — 입고/출고/투입/산출/이동/조정. + * + *

txnType: RECEIVE, ISSUE, CONSUME(투입), PRODUCE(산출), TRANSFER, ADJUST + */ +@Data +public class MesInventoryTxn { + private Long id; + private String txnType; + private String itemCode; + private String fromWarehouse; + private String fromLocation; + private String toWarehouse; + private String toLocation; + private String lotNo; + private Double qty; + private String refType; // WORKORDER / RECEIVING / SHIPPING / STOCKTAKE + private String refNo; + private String createdBy; + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/com/zioinfo/mes/inventory/mapper/InventoryMapper.java b/backend/src/main/java/com/zioinfo/mes/inventory/mapper/InventoryMapper.java new file mode 100644 index 0000000..e4ad741 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/inventory/mapper/InventoryMapper.java @@ -0,0 +1,39 @@ +package com.zioinfo.mes.inventory.mapper; + +import com.zioinfo.mes.inventory.MesInventory; +import com.zioinfo.mes.inventory.MesInventoryTxn; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; +import java.util.Map; + +@Mapper +public interface InventoryMapper { + List findAll(@Param("itemCode") String itemCode, + @Param("warehouseCode") String warehouseCode, + @Param("keyword") String keyword); + MesInventory findOne(@Param("itemCode") String itemCode, + @Param("warehouseCode") String warehouseCode, + @Param("locationCode") String locationCode, + @Param("lotNo") String lotNo); + /** 품목 총 가용재고. */ + Double sumQty(@Param("itemCode") String itemCode); + /** 안전재고 미달 품목. */ + List findBelowSafety(); + + int insertStock(MesInventory inv); + int updateQty(@Param("id") Long id, @Param("qty") Double qty); + + int insertTxn(MesInventoryTxn txn); + List findTxns(@Param("itemCode") String itemCode, + @Param("refType") String refType, + @Param("refNo") String refNo, + @Param("limit") int limit); + + /** LOT 기준 이동 이력(추적용). */ + List findByLot(@Param("lotNo") String lotNo); + + /** 재고 KPI 요약. */ + Map kpiSummary(); +} diff --git a/backend/src/main/java/com/zioinfo/mes/item/ItemController.java b/backend/src/main/java/com/zioinfo/mes/item/ItemController.java new file mode 100644 index 0000000..2437247 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/item/ItemController.java @@ -0,0 +1,51 @@ +package com.zioinfo.mes.item; + +import com.zioinfo.mes.common.ApiResponse; +import com.zioinfo.mes.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/mes/items") +@RequiredArgsConstructor +public class ItemController { + + private final ItemService service; + + @GetMapping + public ApiResponse> list(@RequestParam(required = false) String itemType, + @RequestParam(required = false) String status, + @RequestParam(required = false) String keyword) { + return ApiResponse.ok(service.list(itemType, status, keyword)); + } + + @GetMapping("/{id}") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.ok(service.get(id)); + } + + @PostMapping + public ApiResponse create(@RequestBody MesItem item, Authentication auth) { + AuthSupport.requireManager(auth); + return ApiResponse.ok(service.create(item, AuthSupport.actor(auth))); + } + + @PutMapping("/{id}") + public ApiResponse update(@PathVariable Long id, @RequestBody MesItem item, Authentication auth) { + AuthSupport.requireManager(auth); + return ApiResponse.ok(service.update(id, item, 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/mes/item/ItemService.java b/backend/src/main/java/com/zioinfo/mes/item/ItemService.java new file mode 100644 index 0000000..194d85e --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/item/ItemService.java @@ -0,0 +1,70 @@ +package com.zioinfo.mes.item; + +import com.zioinfo.mes.admin.AuditService; +import com.zioinfo.mes.item.mapper.ItemMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Set; + +/** + * 품목 마스터 서비스 — 기준정보(MANAGER+ 가드는 SecurityConfig + 컨트롤러 역할 확인 병행). + */ +@Service +@RequiredArgsConstructor +public class ItemService { + + private static final Set TYPES = Set.of("RAW", "WIP", "FG", "SUB"); + private final ItemMapper mapper; + private final AuditService audit; + + public List list(String itemType, String status, String keyword) { + return mapper.findAll(itemType, status, keyword); + } + + public MesItem get(Long id) { + MesItem i = mapper.findById(id); + if (i == null) throw new RuntimeException("ERR-ITEM-404: 품목 없음"); + return i; + } + + public MesItem create(MesItem item, String actor) { + validate(item); + if (mapper.countByCode(item.getItemCode(), null) > 0) { + throw new RuntimeException("ERR-ITEM-409: 중복 품목코드"); + } + if (item.getStatus() == null) item.setStatus("ACTIVE"); + item.setCreatedBy(actor); + mapper.insert(item); + audit.log("ITEM_CREATE", item.getItemCode(), "type=" + item.getItemType()); + return mapper.findById(item.getId()); + } + + public MesItem update(Long id, MesItem item, String actor) { + MesItem cur = get(id); + validate(item); + if (mapper.countByCode(item.getItemCode(), id) > 0) { + throw new RuntimeException("ERR-ITEM-409: 중복 품목코드"); + } + item.setId(id); + mapper.update(item); + audit.log("ITEM_UPDATE", cur.getItemCode(), "id=" + id); + return mapper.findById(id); + } + + public void delete(Long id) { + MesItem cur = get(id); + mapper.delete(id); + audit.log("ITEM_DELETE", cur.getItemCode(), "id=" + id); + } + + private void validate(MesItem item) { + if (item.getItemCode() == null || item.getItemCode().isBlank()) + throw new IllegalArgumentException("ERR-ITEM-400: itemCode 필수"); + if (item.getItemName() == null || item.getItemName().isBlank()) + throw new IllegalArgumentException("ERR-ITEM-400: itemName 필수"); + if (item.getItemType() != null && !TYPES.contains(item.getItemType())) + throw new IllegalArgumentException("ERR-ITEM-400: itemType 은 RAW/WIP/FG/SUB"); + } +} diff --git a/backend/src/main/java/com/zioinfo/mes/item/MesItem.java b/backend/src/main/java/com/zioinfo/mes/item/MesItem.java new file mode 100644 index 0000000..eaf6d03 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/item/MesItem.java @@ -0,0 +1,27 @@ +package com.zioinfo.mes.item; + +import lombok.Data; +import java.time.LocalDateTime; + +/** + * 품목 마스터 (mes_item) — 원자재/반제품/제품/부자재. + * + *

itemType: RAW(원자재), WIP(반제품), FG(완제품), SUB(부자재) + */ +@Data +public class MesItem { + private Long id; + private String itemCode; + private String itemName; + private String itemType; // RAW / WIP / FG / SUB + private String unit; // EA, KG, M, BOX ... + private String barcode; + private String spec; + private Double safetyStock; + private String warehouseCode; // 기본 보관 창고 + 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/mes/item/mapper/ItemMapper.java b/backend/src/main/java/com/zioinfo/mes/item/mapper/ItemMapper.java new file mode 100644 index 0000000..87192cd --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/item/mapper/ItemMapper.java @@ -0,0 +1,20 @@ +package com.zioinfo.mes.item.mapper; + +import com.zioinfo.mes.item.MesItem; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface ItemMapper { + List findAll(@Param("itemType") String itemType, + @Param("status") String status, + @Param("keyword") String keyword); + MesItem findById(@Param("id") Long id); + MesItem findByCode(@Param("itemCode") String itemCode); + int countByCode(@Param("itemCode") String itemCode, @Param("excludeId") Long excludeId); + int insert(MesItem item); + int update(MesItem item); + int delete(@Param("id") Long id); +} diff --git a/backend/src/main/java/com/zioinfo/mes/job/JobController.java b/backend/src/main/java/com/zioinfo/mes/job/JobController.java new file mode 100644 index 0000000..70ef6f9 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/job/JobController.java @@ -0,0 +1,88 @@ +package com.zioinfo.mes.job; + +import com.zioinfo.mes.common.ApiResponse; +import com.zioinfo.mes.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 — 작업/작업자/설비 배정·할당·조회·진행·이력. + * + *

RBAC: 조회 Viewer+, 생성/배정/진행/실적 Worker+, 삭제 Manager+. + */ +@RestController +@RequestMapping("/api/mes/jobs") +@RequiredArgsConstructor +public class JobController { + + private final JobService service; + + @GetMapping + public ApiResponse> list(@RequestParam(required = false) String status, + @RequestParam(required = false) String workerId, + @RequestParam(required = false) String woNo, + @RequestParam(required = false) String equipmentCode) { + return ApiResponse.ok(service.list(status, workerId, woNo, equipmentCode)); + } + + /** 작업 현황 보드(상태별 카운트). */ + @GetMapping("/board") + public ApiResponse>> board() { + return ApiResponse.ok(service.statusBoard()); + } + + /** 작업자별 부하(진행중 작업/잔여수량). */ + @GetMapping("/workload") + public ApiResponse>> workload() { + return ApiResponse.ok(service.workload()); + } + + @GetMapping("/{id}") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.ok(service.get(id)); + } + + /** 작업 생성(작업지시 공정 할당). */ + @PostMapping + public ApiResponse create(@RequestBody MesJob job, Authentication auth) { + return ApiResponse.ok(service.create(job, AuthSupport.actor(auth))); + } + + @PutMapping("/{id}") + public ApiResponse update(@PathVariable Long id, @RequestBody MesJob job, Authentication auth) { + return ApiResponse.ok(service.update(id, job, AuthSupport.actor(auth))); + } + + /** 작업자/설비 배정. */ + @PostMapping("/{id}/assign") + public ApiResponse assign(@PathVariable Long id, @RequestBody AssignRequest req, Authentication auth) { + return ApiResponse.ok(service.assign(id, req.workerId(), req.equipmentCode(), AuthSupport.actor(auth))); + } + + /** 진행 상태 전이(START/PAUSE/RESUME/DONE/CANCEL). */ + @PostMapping("/{id}/transition") + public ApiResponse transition(@PathVariable Long id, @RequestBody TransitionRequest req, Authentication auth) { + return ApiResponse.ok(service.transition(id, req.action(), AuthSupport.actor(auth))); + } + + /** 작업 완료 수량 보고. */ + @PostMapping("/{id}/report") + public ApiResponse report(@PathVariable Long id, @RequestBody QtyRequest req, Authentication auth) { + return ApiResponse.ok(service.reportDone(id, req.qty(), AuthSupport.actor(auth))); + } + + @DeleteMapping("/{id}") + public ApiResponse delete(@PathVariable Long id, Authentication auth) { + AuthSupport.requireManager(auth); + service.delete(id); + return ApiResponse.ok(null); + } + + record AssignRequest(String workerId, String equipmentCode) {} + record TransitionRequest(String action) {} + record QtyRequest(double qty) {} +} diff --git a/backend/src/main/java/com/zioinfo/mes/job/JobService.java b/backend/src/main/java/com/zioinfo/mes/job/JobService.java new file mode 100644 index 0000000..3d9496b --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/job/JobService.java @@ -0,0 +1,140 @@ +package com.zioinfo.mes.job; + +import com.zioinfo.mes.admin.AuditService; +import com.zioinfo.mes.job.mapper.JobMapper; +import com.zioinfo.mes.workorder.MesWorkOrder; +import com.zioinfo.mes.workorder.mapper.WorkOrderMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 작업관리 서비스 — 작업 생성·작업자/설비 배정·할당·진행 전이·실적 누적·이력 조회. + * + *

상태 전이: ASSIGNED → STARTED → (PAUSED ↔ STARTED) → DONE (CANCELLED 가능). + * 작업지시(workorder)를 참조해 itemCode/woNo 를 보정한다. + */ +@Service +@RequiredArgsConstructor +public class JobService { + + private static final Map> TRANSITIONS = Map.of( + "ASSIGNED", Set.of("STARTED", "CANCELLED"), + "STARTED", Set.of("PAUSED", "DONE"), + "PAUSED", Set.of("STARTED", "CANCELLED"), + "DONE", Set.of(), + "CANCELLED", Set.of() + ); + + private final JobMapper mapper; + private final WorkOrderMapper workOrderMapper; + private final AuditService audit; + + public List list(String status, String workerId, String woNo, String equipmentCode) { + return mapper.findAll(status, workerId, woNo, equipmentCode); + } + + public MesJob get(Long id) { + MesJob j = mapper.findById(id); + if (j == null) throw new RuntimeException("ERR-JOB-404: 작업 없음"); + return j; + } + + public List> statusBoard() { + return mapper.countByStatus(); + } + + public List> workload() { + return mapper.workloadByWorker(); + } + + /** 작업 생성(작업지시 공정 할당). workorderId 로 woNo/itemCode 보정. */ + @Transactional + public MesJob create(MesJob job, String actor) { + if (job.getWorkorderId() == null) + throw new IllegalArgumentException("ERR-JOB-400: workorderId 필수"); + MesWorkOrder wo = workOrderMapper.findById(job.getWorkorderId()); + if (wo == null) throw new RuntimeException("ERR-JOB-404: 작업지시 없음"); + job.setWoNo(wo.getWoNo()); + if (job.getItemCode() == null) job.setItemCode(wo.getItemCode()); + if (job.getAssignedQty() == null || job.getAssignedQty() <= 0) + job.setAssignedQty(wo.getPlanQty()); + if (job.getJobNo() == null || job.getJobNo().isBlank()) + job.setJobNo("JOB-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + + "-" + (System.currentTimeMillis() % 100000)); + if (mapper.countByNo(job.getJobNo()) > 0) + throw new RuntimeException("ERR-JOB-409: 중복 작업번호"); + job.setStatus("ASSIGNED"); + job.setCreatedBy(actor); + mapper.insert(job); + audit.log("JOB_CREATE", job.getJobNo(), + "wo=" + job.getWoNo() + " worker=" + job.getWorkerId() + " eqp=" + job.getEquipmentCode()); + return mapper.findById(job.getId()); + } + + /** 작업자/설비 재배정. */ + @Transactional + public MesJob assign(Long id, String workerId, String equipmentCode, String actor) { + MesJob j = get(id); + if (Set.of("DONE", "CANCELLED").contains(j.getStatus())) + throw new RuntimeException("ERR-JOB-409: 종료된 작업은 재배정 불가"); + mapper.assign(id, workerId, equipmentCode); + audit.log("JOB_ASSIGN", j.getJobNo(), "worker=" + workerId + " eqp=" + equipmentCode); + return mapper.findById(id); + } + + /** 진행 상태 전이 — action: START/PAUSE/RESUME/DONE/CANCEL. */ + @Transactional + public MesJob transition(Long id, String action, String actor) { + MesJob j = get(id); + String from = j.getStatus(); + String to = switch (action == null ? "" : action.toUpperCase()) { + case "START", "RESUME" -> "STARTED"; + case "PAUSE" -> "PAUSED"; + case "DONE" -> "DONE"; + case "CANCEL" -> "CANCELLED"; + default -> throw new IllegalArgumentException("ERR-JOB-400: 알 수 없는 action(START/PAUSE/RESUME/DONE/CANCEL)"); + }; + if (!TRANSITIONS.getOrDefault(from, Set.of()).contains(to)) + throw new RuntimeException("ERR-JOB-409: " + from + " → " + to + " 전이 불가"); + mapper.updateStatus(id, to); + audit.log("JOB_" + action.toUpperCase(), j.getJobNo(), from + " -> " + to); + return mapper.findById(id); + } + + /** 작업 실적 누적(완료 수량). */ + @Transactional + public MesJob reportDone(Long id, double qty, String actor) { + MesJob j = get(id); + if (qty <= 0) throw new IllegalArgumentException("ERR-JOB-400: 수량은 0보다 커야 합니다"); + mapper.addDone(id, qty); + audit.log("JOB_DONE_QTY", j.getJobNo(), "+" + qty); + return mapper.findById(id); + } + + @Transactional + public MesJob update(Long id, MesJob job, String actor) { + MesJob cur = get(id); + if (Set.of("DONE", "CANCELLED").contains(cur.getStatus())) + throw new RuntimeException("ERR-JOB-409: 종료된 작업은 수정 불가"); + job.setId(id); + if (job.getAssignedQty() == null) job.setAssignedQty(cur.getAssignedQty()); + mapper.update(job); + audit.log("JOB_UPDATE", cur.getJobNo(), "id=" + id); + return mapper.findById(id); + } + + public void delete(Long id) { + MesJob cur = get(id); + if (!Set.of("ASSIGNED", "CANCELLED").contains(cur.getStatus())) + throw new RuntimeException("ERR-JOB-409: 진행/완료 작업은 삭제 불가"); + mapper.delete(id); + audit.log("JOB_DELETE", cur.getJobNo(), ""); + } +} diff --git a/backend/src/main/java/com/zioinfo/mes/job/MesJob.java b/backend/src/main/java/com/zioinfo/mes/job/MesJob.java new file mode 100644 index 0000000..1cc463c --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/job/MesJob.java @@ -0,0 +1,36 @@ +package com.zioinfo.mes.job; + +import lombok.Data; +import java.time.LocalDateTime; + +/** + * 작업관리 (mes_job) — 작업지시 하위 작업/작업자/설비 배정·할당·진행·이력. + * + *

상태: ASSIGNED → STARTED → (PAUSED) → DONE (CANCELLED 가능). + * 작업지시(workorder)의 공정(op)을 작업자/설비에 할당하고 실작업 진행을 추적한다. + */ +@Data +public class MesJob { + private Long id; + private String jobNo; + private Long workorderId; + private String woNo; + private String itemCode; + private String itemName; // 조인 표시(비영속) + private Integer opSeq; + private String processCode; + private String processName; + private String equipmentCode; + private String workerId; // 배정 작업자 + private Double assignedQty; + private Double doneQty; + private String status; // ASSIGNED/STARTED/PAUSED/DONE/CANCELLED + private LocalDateTime plannedStart; + private LocalDateTime plannedEnd; + private LocalDateTime startedAt; + private LocalDateTime endedAt; + private String remark; + private String createdBy; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/backend/src/main/java/com/zioinfo/mes/job/mapper/JobMapper.java b/backend/src/main/java/com/zioinfo/mes/job/mapper/JobMapper.java new file mode 100644 index 0000000..8afa4de --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/job/mapper/JobMapper.java @@ -0,0 +1,30 @@ +package com.zioinfo.mes.job.mapper; + +import com.zioinfo.mes.job.MesJob; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; +import java.util.Map; + +@Mapper +public interface JobMapper { + List findAll(@Param("status") String status, + @Param("workerId") String workerId, + @Param("woNo") String woNo, + @Param("equipmentCode") String equipmentCode); + MesJob findById(@Param("id") Long id); + int countByNo(@Param("jobNo") String jobNo); + int insert(MesJob job); + int update(MesJob job); + int assign(@Param("id") Long id, @Param("workerId") String workerId, + @Param("equipmentCode") String equipmentCode); + int updateStatus(@Param("id") Long id, @Param("status") String status); + int addDone(@Param("id") Long id, @Param("qty") Double qty); + int delete(@Param("id") Long id); + + /** 상태별 카운트(작업현황 보드). */ + List> countByStatus(); + /** 작업자별 진행중 작업 수(부하 조회). */ + List> workloadByWorker(); +} diff --git a/backend/src/main/java/com/zioinfo/mes/lotserial/LotSerialController.java b/backend/src/main/java/com/zioinfo/mes/lotserial/LotSerialController.java new file mode 100644 index 0000000..7e66715 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/lotserial/LotSerialController.java @@ -0,0 +1,66 @@ +package com.zioinfo.mes.lotserial; + +import com.zioinfo.mes.admin.AuditService; +import com.zioinfo.mes.common.ApiResponse; +import com.zioinfo.mes.common.AuthSupport; +import com.zioinfo.mes.lotserial.mapper.LotSerialMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * LOT/시리얼 관리 API (WMS). 조회 Viewer+, 등록/상태변경 Worker+. + */ +@RestController +@RequestMapping("/api/mes/lotserial") +@RequiredArgsConstructor +public class LotSerialController { + + private final LotSerialMapper mapper; + private final AuditService audit; + + @GetMapping + public ApiResponse> list(@RequestParam(required = false) String itemCode, + @RequestParam(required = false) String status, + @RequestParam(required = false) String keyword) { + return ApiResponse.ok(mapper.findAll(itemCode, status, keyword)); + } + + @GetMapping("/{id}") + public ApiResponse get(@PathVariable Long id) { + MesLotSerial l = mapper.findById(id); + if (l == null) throw new RuntimeException("ERR-LOT-404: LOT 없음"); + return ApiResponse.ok(l); + } + + /** 유효기간 임박 LOT(days 이내, 기본 30일). */ + @GetMapping("/expiring") + public ApiResponse> expiring(@RequestParam(defaultValue = "30") int days) { + return ApiResponse.ok(mapper.findExpiringSoon(days)); + } + + @PostMapping + public ApiResponse create(@RequestBody MesLotSerial l, Authentication auth) { + if (l.getLotNo() == null || l.getLotNo().isBlank()) + throw new IllegalArgumentException("ERR-LOT-400: lotNo 필수"); + if (l.getStatus() == null) l.setStatus("ACTIVE"); + l.setCreatedBy(AuthSupport.actor(auth)); + mapper.insert(l); + audit.log("LOTSERIAL_CREATE", l.getLotNo(), "item=" + l.getItemCode()); + return ApiResponse.ok(mapper.findById(l.getId())); + } + + @PutMapping("/{id}/status") + public ApiResponse updateStatus(@PathVariable Long id, @RequestBody StatusRequest req, + Authentication auth) { + MesLotSerial l = mapper.findById(id); + if (l == null) throw new RuntimeException("ERR-LOT-404: LOT 없음"); + mapper.updateStatus(id, req.status()); + audit.log("LOTSERIAL_STATUS", l.getLotNo(), l.getStatus() + " -> " + req.status()); + return ApiResponse.ok(mapper.findById(id)); + } + + record StatusRequest(String status) {} +} diff --git a/backend/src/main/java/com/zioinfo/mes/lotserial/MesLotSerial.java b/backend/src/main/java/com/zioinfo/mes/lotserial/MesLotSerial.java new file mode 100644 index 0000000..6a97a5e --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/lotserial/MesLotSerial.java @@ -0,0 +1,27 @@ +package com.zioinfo.mes.lotserial; + +import lombok.Data; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * LOT/시리얼 (mes_lotserial) — 생산/입고로 발생한 LOT·시리얼 추적·유효기간. + * + *

status: ACTIVE/CONSUMED/SHIPPED/EXPIRED. + */ +@Data +public class MesLotSerial { + private Long id; + private String lotNo; + private String serialNo; + private String itemCode; + private String itemName; // 조인 표시(비영속) + private Double qty; + private String sourceType; // PRODUCTION/RECEIVING + private String sourceRef; + private LocalDate mfgDate; + private LocalDate expiryDate; + private String status; + private String createdBy; + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/com/zioinfo/mes/lotserial/mapper/LotSerialMapper.java b/backend/src/main/java/com/zioinfo/mes/lotserial/mapper/LotSerialMapper.java new file mode 100644 index 0000000..0ae01e4 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/lotserial/mapper/LotSerialMapper.java @@ -0,0 +1,20 @@ +package com.zioinfo.mes.lotserial.mapper; + +import com.zioinfo.mes.lotserial.MesLotSerial; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface LotSerialMapper { + List findAll(@Param("itemCode") String itemCode, + @Param("status") String status, + @Param("keyword") String keyword); + MesLotSerial findById(@Param("id") Long id); + MesLotSerial findByLot(@Param("lotNo") String lotNo); + int insert(MesLotSerial l); + int updateStatus(@Param("id") Long id, @Param("status") String status); + /** 유효기간 임박(days 이내) LOT. */ + List findExpiringSoon(@Param("days") int days); +} diff --git a/backend/src/main/java/com/zioinfo/mes/member/MemberController.java b/backend/src/main/java/com/zioinfo/mes/member/MemberController.java new file mode 100644 index 0000000..5c1c476 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/member/MemberController.java @@ -0,0 +1,48 @@ +package com.zioinfo.mes.member; + +import com.zioinfo.mes.admin.dto.UserDto; +import com.zioinfo.mes.admin.mapper.AdminUserMapper; +import com.zioinfo.mes.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; + +/** + * 구성원(작업자) 조회 API — 작업 배정·실적 입력 시 작업자 선택용. + * + *

관리자 CRUD 는 /api/admin/users(SuperAdmin)가 담당하고, 본 API 는 조회 전용(Viewer+). + * 보안: {@link UserDto} 로만 반환하여 password_hash 노출 차단. + */ +@RestController +@RequestMapping("/api/mes/members") +@RequiredArgsConstructor +public class MemberController { + + private final AdminUserMapper mapper; + + /** 활성 구성원 목록(작업자 배정 드롭다운용). role 필터 가능. */ + @GetMapping + public ApiResponse> list(@RequestParam(required = false) String role) { + List all = mapper.findAll().stream() + .filter(u -> u.isActive()) + .filter(u -> role == null || role.isBlank() || role.equalsIgnoreCase(u.getRole())) + .map(UserDto::from) + .toList(); + return ApiResponse.ok(all); + } + + /** 현장 작업자(WORKER) 목록 — 작업 배정 전용. */ + @GetMapping("/workers") + public ApiResponse> workers() { + List workers = mapper.findAll().stream() + .filter(u -> u.isActive()) + .filter(u -> "WORKER".equalsIgnoreCase(u.getRole()) || "MANAGER".equalsIgnoreCase(u.getRole())) + .map(UserDto::from) + .toList(); + return ApiResponse.ok(workers); + } +} diff --git a/backend/src/main/java/com/zioinfo/mes/ncr/MesNcr.java b/backend/src/main/java/com/zioinfo/mes/ncr/MesNcr.java new file mode 100644 index 0000000..d184f32 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/ncr/MesNcr.java @@ -0,0 +1,29 @@ +package com.zioinfo.mes.ncr; + +import lombok.Data; +import java.time.LocalDateTime; + +/** + * 부적합 보고 (mes_ncr) — Non-Conformance Report. 검사 불합격·불량 발생 시 기록. + * + *

status: OPEN → REVIEW → DISPOSITIONED → CLOSED. disposition: USE_AS_IS/REWORK/SCRAP/RETURN. + */ +@Data +public class MesNcr { + private Long id; + private String ncrNo; + private String itemCode; + private String lotNo; + private String refType; // INSPECTION/WORKORDER/RECEIVING/SHIPPING + private String refNo; + private String defectCode; + private Double defectQty; + private String description; + private String disposition; // USE_AS_IS/REWORK/SCRAP/RETURN + private String severity; // MINOR/MAJOR/CRITICAL + private String status; // OPEN/REVIEW/DISPOSITIONED/CLOSED + private Long capaId; + private String createdBy; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/backend/src/main/java/com/zioinfo/mes/ncr/NcrController.java b/backend/src/main/java/com/zioinfo/mes/ncr/NcrController.java new file mode 100644 index 0000000..3d16037 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/ncr/NcrController.java @@ -0,0 +1,66 @@ +package com.zioinfo.mes.ncr; + +import com.zioinfo.mes.common.ApiResponse; +import com.zioinfo.mes.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; + +/** + * 부적합(NCR) API (QMS). 조회 Viewer+, 등록/처리 Worker+, CAPA 연계 Worker+. + */ +@RestController +@RequestMapping("/api/mes/ncr") +@RequiredArgsConstructor +public class NcrController { + + private final NcrService service; + + @GetMapping + public ApiResponse> list(@RequestParam(required = false) String status, + @RequestParam(required = false) String severity, + @RequestParam(required = false) String itemCode) { + return ApiResponse.ok(service.list(status, severity, itemCode)); + } + + @GetMapping("/board") + public ApiResponse>> board() { + return ApiResponse.ok(service.statusBoard()); + } + + @GetMapping("/{id}") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.ok(service.get(id)); + } + + @PostMapping + public ApiResponse create(@RequestBody MesNcr n, Authentication auth) { + return ApiResponse.ok(service.create(n, AuthSupport.actor(auth))); + } + + @PostMapping("/{id}/disposition") + public ApiResponse disposition(@PathVariable Long id, @RequestBody DispositionRequest req, + Authentication auth) { + return ApiResponse.ok(service.disposition(id, req.disposition(), AuthSupport.actor(auth))); + } + + @PostMapping("/{id}/transition") + public ApiResponse transition(@PathVariable Long id, @RequestBody StatusRequest req, + Authentication auth) { + return ApiResponse.ok(service.transition(id, req.status(), AuthSupport.actor(auth))); + } + + @PostMapping("/{id}/link-capa") + public ApiResponse linkCapa(@PathVariable Long id, @RequestBody CapaLinkRequest req, + Authentication auth) { + service.linkCapa(id, req.capaId(), AuthSupport.actor(auth)); + return ApiResponse.ok(null); + } + + record DispositionRequest(String disposition) {} + record StatusRequest(String status) {} + record CapaLinkRequest(Long capaId) {} +} diff --git a/backend/src/main/java/com/zioinfo/mes/ncr/NcrService.java b/backend/src/main/java/com/zioinfo/mes/ncr/NcrService.java new file mode 100644 index 0000000..e4a71cb --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/ncr/NcrService.java @@ -0,0 +1,98 @@ +package com.zioinfo.mes.ncr; + +import com.zioinfo.mes.admin.AuditService; +import com.zioinfo.mes.ncr.mapper.NcrMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Map; + +/** + * 부적합(NCR) 서비스 — 부적합 보고 등록·처리방안(disposition)·CAPA 연계. + * + *

검사 불합격 시 inspection 모듈이 {@link #createFromSource}로 자동 생성한다. + */ +@Service +@RequiredArgsConstructor +public class NcrService { + + private final NcrMapper mapper; + private final AuditService audit; + + public List list(String status, String severity, String itemCode) { + return mapper.findAll(status, severity, itemCode); + } + + public MesNcr get(Long id) { + MesNcr n = mapper.findById(id); + if (n == null) throw new RuntimeException("ERR-NCR-404: NCR 없음"); + return n; + } + + public List> statusBoard() { + return mapper.countByStatus(); + } + + @Transactional + public MesNcr create(MesNcr n, String actor) { + if (n.getItemCode() == null || n.getItemCode().isBlank()) + throw new IllegalArgumentException("ERR-NCR-400: itemCode 필수"); + if (n.getNcrNo() == null || n.getNcrNo().isBlank()) + n.setNcrNo(genNo()); + if (n.getSeverity() == null) n.setSeverity("MINOR"); + n.setStatus("OPEN"); + n.setCreatedBy(actor); + mapper.insert(n); + audit.log("NCR_CREATE", n.getNcrNo(), "item=" + n.getItemCode() + " defect=" + n.getDefectCode()); + return mapper.findById(n.getId()); + } + + /** 검사 불합격 → NCR 자동 생성(inspection 모듈 호출용). */ + @Transactional + public MesNcr createFromSource(String itemCode, String lotNo, String refType, String refNo, + String defectCode, double defectQty, String severity, String actor) { + MesNcr n = new MesNcr(); + n.setItemCode(itemCode); + n.setLotNo(lotNo); + n.setRefType(refType); + n.setRefNo(refNo); + n.setDefectCode(defectCode); + n.setDefectQty(defectQty); + n.setSeverity(severity == null ? "MAJOR" : severity); + n.setDescription("검사 불합격 자동 부적합 (" + refType + " " + refNo + ")"); + return create(n, actor); + } + + /** 처리방안 확정 → DISPOSITIONED. */ + @Transactional + public MesNcr disposition(Long id, String disposition, String actor) { + MesNcr n = get(id); + mapper.updateDisposition(id, disposition, "DISPOSITIONED"); + audit.log("NCR_DISPOSITION", n.getNcrNo(), disposition); + return mapper.findById(id); + } + + @Transactional + public MesNcr transition(Long id, String status, String actor) { + MesNcr n = get(id); + mapper.updateStatus(id, status); + audit.log("NCR_STATUS", n.getNcrNo(), n.getStatus() + " -> " + status); + return mapper.findById(id); + } + + @Transactional + public void linkCapa(Long id, Long capaId, String actor) { + MesNcr n = get(id); + mapper.linkCapa(id, capaId); + audit.log("NCR_LINK_CAPA", n.getNcrNo(), "capaId=" + capaId); + } + + private String genNo() { + return "NCR-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + + "-" + (System.currentTimeMillis() % 100000); + } +} diff --git a/backend/src/main/java/com/zioinfo/mes/ncr/mapper/NcrMapper.java b/backend/src/main/java/com/zioinfo/mes/ncr/mapper/NcrMapper.java new file mode 100644 index 0000000..62bdc16 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/ncr/mapper/NcrMapper.java @@ -0,0 +1,23 @@ +package com.zioinfo.mes.ncr.mapper; + +import com.zioinfo.mes.ncr.MesNcr; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; +import java.util.Map; + +@Mapper +public interface NcrMapper { + List findAll(@Param("status") String status, + @Param("severity") String severity, + @Param("itemCode") String itemCode); + MesNcr findById(@Param("id") Long id); + int insert(MesNcr n); + int updateDisposition(@Param("id") Long id, @Param("disposition") String disposition, + @Param("status") String status); + int updateStatus(@Param("id") Long id, @Param("status") String status); + int linkCapa(@Param("id") Long id, @Param("capaId") Long capaId); + /** 상태별 카운트(품질 대시보드). */ + List> countByStatus(); +} diff --git a/backend/src/main/java/com/zioinfo/mes/oee/MesOeeRecord.java b/backend/src/main/java/com/zioinfo/mes/oee/MesOeeRecord.java new file mode 100644 index 0000000..7a6dfe5 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/oee/MesOeeRecord.java @@ -0,0 +1,29 @@ +package com.zioinfo.mes.oee; + +import lombok.Data; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * OEE 일일 기록 (mes_oee) — 설비/일자별 가동·성능·품질 입력값. + * + *

OEE = Availability × Performance × Quality. + * Availability = runTime / plannedTime + * Performance = (idealCycleMin × totalCount) / runTime + * Quality = goodCount / totalCount + */ +@Data +public class MesOeeRecord { + private Long id; + private String equipmentCode; + private LocalDate recordDate; + private Double plannedTimeMin; // 계획 가동시간 + private Double runTimeMin; // 실제 가동시간 + private Double downtimeMin; // 비가동시간 + private String downtimeCode; // 비가동 코드 + private Double idealCycleMin; // 이상 사이클타임(분/개) + private Double totalCount; // 총 생산수 + private Double goodCount; // 양품수 + private String createdBy; + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/com/zioinfo/mes/oee/OeeController.java b/backend/src/main/java/com/zioinfo/mes/oee/OeeController.java new file mode 100644 index 0000000..f6ec820 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/oee/OeeController.java @@ -0,0 +1,48 @@ +package com.zioinfo.mes.oee; + +import com.zioinfo.mes.common.ApiResponse; +import com.zioinfo.mes.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; + +/** + * OEE API — 기록 입력 + 산식 + 비가동 파레토. 조회 Viewer+, 입력 Worker+. + */ +@RestController +@RequestMapping("/api/mes/oee") +@RequiredArgsConstructor +public class OeeController { + + private final OeeService service; + + @GetMapping + public ApiResponse> list(@RequestParam(required = false) String equipmentCode, + @RequestParam(defaultValue = "100") int limit) { + return ApiResponse.ok(service.list(equipmentCode, limit)); + } + + @PostMapping + public ApiResponse create(@RequestBody MesOeeRecord r, Authentication auth) { + return ApiResponse.ok(service.create(r, AuthSupport.actor(auth))); + } + + /** 입력값으로 즉시 OEE 계산(저장 없음). */ + @PostMapping("/compute") + public ApiResponse> compute(@RequestBody MesOeeRecord r) { + return ApiResponse.ok(service.compute(r)); + } + + @GetMapping("/{id}/oee") + public ApiResponse> computeById(@PathVariable Long id) { + return ApiResponse.ok(service.computeById(id)); + } + + @GetMapping("/downtime-pareto") + public ApiResponse>> downtimePareto(@RequestParam(defaultValue = "30") int days) { + return ApiResponse.ok(service.downtimePareto(days)); + } +} diff --git a/backend/src/main/java/com/zioinfo/mes/oee/OeeService.java b/backend/src/main/java/com/zioinfo/mes/oee/OeeService.java new file mode 100644 index 0000000..fb03eda --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/oee/OeeService.java @@ -0,0 +1,72 @@ +package com.zioinfo.mes.oee; + +import com.zioinfo.mes.admin.AuditService; +import com.zioinfo.mes.oee.mapper.OeeMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * OEE 서비스 — 가동률·성능·품질 산식 + 비가동/불량 집계. + */ +@Service +@RequiredArgsConstructor +public class OeeService { + + private final OeeMapper mapper; + private final AuditService audit; + + public List list(String equipmentCode, int limit) { + return mapper.findAll(equipmentCode, limit <= 0 ? 100 : limit); + } + + public MesOeeRecord create(MesOeeRecord r, String actor) { + if (r.getEquipmentCode() == null) throw new IllegalArgumentException("ERR-OEE-400: equipmentCode 필수"); + r.setCreatedBy(actor); + mapper.insert(r); + audit.log("OEE_RECORD", r.getEquipmentCode(), "date=" + r.getRecordDate()); + return mapper.findById(r.getId()); + } + + public List> downtimePareto(int days) { + return mapper.downtimePareto(days <= 0 ? 30 : days); + } + + /** OEE 계산 — 한 기록의 가동률/성능/품질/OEE. */ + public Map compute(MesOeeRecord r) { + double planned = nz(r.getPlannedTimeMin()); + double run = nz(r.getRunTimeMin()); + double ideal = nz(r.getIdealCycleMin()); + double total = nz(r.getTotalCount()); + double good = nz(r.getGoodCount()); + + double availability = planned > 0 ? clamp(run / planned) : 0; + double performance = run > 0 ? clamp((ideal * total) / run) : 0; + double quality = total > 0 ? clamp(good / total) : 0; + double oee = availability * performance * quality; + + Map m = new LinkedHashMap<>(); + m.put("equipmentCode", r.getEquipmentCode()); + m.put("availability", round4(availability)); + m.put("performance", round4(performance)); + m.put("quality", round4(quality)); + m.put("oee", round4(oee)); + m.put("oeePercent", round2(oee * 100)); + return m; + } + + /** id 기준 OEE 계산. */ + public Map computeById(Long id) { + MesOeeRecord r = mapper.findById(id); + if (r == null) throw new RuntimeException("ERR-OEE-404: OEE 기록 없음"); + return compute(r); + } + + private double nz(Double d) { return d == null ? 0 : d; } + private double clamp(double d) { return Math.max(0, Math.min(1, d)); } + private double round4(double d) { return Math.round(d * 10000.0) / 10000.0; } + private double round2(double d) { return Math.round(d * 100.0) / 100.0; } +} diff --git a/backend/src/main/java/com/zioinfo/mes/oee/mapper/OeeMapper.java b/backend/src/main/java/com/zioinfo/mes/oee/mapper/OeeMapper.java new file mode 100644 index 0000000..58448c3 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/oee/mapper/OeeMapper.java @@ -0,0 +1,17 @@ +package com.zioinfo.mes.oee.mapper; + +import com.zioinfo.mes.oee.MesOeeRecord; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; +import java.util.Map; + +@Mapper +public interface OeeMapper { + List findAll(@Param("equipmentCode") String equipmentCode, @Param("limit") int limit); + MesOeeRecord findById(@Param("id") Long id); + int insert(MesOeeRecord r); + /** 비가동 코드별 집계. */ + List> downtimePareto(@Param("days") int days); +} diff --git a/backend/src/main/java/com/zioinfo/mes/partner/MesPartner.java b/backend/src/main/java/com/zioinfo/mes/partner/MesPartner.java new file mode 100644 index 0000000..bf1fea5 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/partner/MesPartner.java @@ -0,0 +1,28 @@ +package com.zioinfo.mes.partner; + +import lombok.Data; +import java.time.LocalDateTime; + +/** + * 거래처 마스터 (mes_partner) — 공급사/고객. + * + *

보안: 담당자 연락처/이메일은 *_enc 컬럼에 AES 암호화 저장, 응답 시 마스킹. + * 평문 contactPhone/contactEmail 은 입력 전용(요청 바인딩), 응답에는 마스킹 값만. + */ +@Data +public class MesPartner { + private Long id; + private String partnerCode; + private String partnerName; + private String partnerType; // SUPPLIER / CUSTOMER / BOTH + private String bizNo; // 사업자번호 + private String contactName; + private String contactPhoneEnc; // AES 암호화 저장 + private String contactEmailEnc; // AES 암호화 저장 + private String contactPhone; // 응답 마스킹용(비영속 — 서비스에서 set) + private String contactEmail; // 응답 마스킹용(비영속) + private String address; + private String status; + private String createdBy; + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/com/zioinfo/mes/partner/PartnerController.java b/backend/src/main/java/com/zioinfo/mes/partner/PartnerController.java new file mode 100644 index 0000000..ea0840b --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/partner/PartnerController.java @@ -0,0 +1,51 @@ +package com.zioinfo.mes.partner; + +import com.zioinfo.mes.common.ApiResponse; +import com.zioinfo.mes.common.AuthSupport; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 거래처 마스터 API. 조회 Viewer+(PII 마스킹), 변경 Manager+. + */ +@RestController +@RequestMapping("/api/mes/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 MesPartner p, Authentication auth) { + AuthSupport.requireManager(auth); + return ApiResponse.ok(service.create(p, AuthSupport.actor(auth))); + } + + @PutMapping("/{id}") + public ApiResponse update(@PathVariable Long id, @RequestBody MesPartner 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/mes/partner/PartnerService.java b/backend/src/main/java/com/zioinfo/mes/partner/PartnerService.java new file mode 100644 index 0000000..730b635 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/partner/PartnerService.java @@ -0,0 +1,93 @@ +package com.zioinfo.mes.partner; + +import com.zioinfo.mes.admin.AuditService; +import com.zioinfo.mes.common.CryptoUtil; +import com.zioinfo.mes.partner.mapper.PartnerMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * 거래처 서비스 — 담당자 연락처 PII 를 AES 암호화 저장, 응답은 마스킹. + * + *

보안 불변 규칙: contactPhone/contactEmail 평문은 절대 저장/응답하지 않는다. + */ +@Service +@RequiredArgsConstructor +public class PartnerService { + + private final PartnerMapper mapper; + private final CryptoUtil crypto; + private final AuditService audit; + + public List list(String partnerType, String status, String keyword) { + List list = mapper.findAll(partnerType, status, keyword); + list.forEach(this::maskForResponse); + return list; + } + + public MesPartner get(Long id) { + MesPartner p = require(id); + maskForResponse(p); + return p; + } + + public MesPartner create(MesPartner p, String actor) { + if (p.getPartnerCode() == null || p.getPartnerCode().isBlank()) + throw new IllegalArgumentException("ERR-PARTNER-400: partnerCode 필수"); + if (mapper.countByCode(p.getPartnerCode(), null) > 0) + throw new RuntimeException("ERR-PARTNER-409: 중복 거래처코드"); + encryptPii(p); + if (p.getStatus() == null) p.setStatus("ACTIVE"); + p.setCreatedBy(actor); + mapper.insert(p); + audit.log("PARTNER_CREATE", p.getPartnerCode(), "type=" + p.getPartnerType()); + return get(p.getId()); + } + + public MesPartner update(Long id, MesPartner p, String actor) { + require(id); + encryptPii(p); + p.setId(id); + mapper.update(p); + audit.log("PARTNER_UPDATE", p.getPartnerCode(), "id=" + id); + return get(id); + } + + public void delete(Long id) { + MesPartner cur = require(id); + mapper.delete(id); + audit.log("PARTNER_DELETE", cur.getPartnerCode(), "id=" + id); + } + + /** 입력 평문 → *_enc 암호화. 평문 필드는 제거. */ + private void encryptPii(MesPartner p) { + if (p.getContactPhone() != null && !p.getContactPhone().isBlank()) { + p.setContactPhoneEnc(crypto.encrypt(p.getContactPhone())); + } + if (p.getContactEmail() != null && !p.getContactEmail().isBlank()) { + p.setContactEmailEnc(crypto.encrypt(p.getContactEmail())); + } + p.setContactPhone(null); + p.setContactEmail(null); + } + + /** 응답용 — 복호화 후 마스킹. *_enc 는 노출하지 않음. */ + private void maskForResponse(MesPartner p) { + if (p.getContactPhoneEnc() != null && !p.getContactPhoneEnc().isBlank()) { + p.setContactPhone(CryptoUtil.mask(crypto.decrypt(p.getContactPhoneEnc()))); + } + if (p.getContactEmailEnc() != null && !p.getContactEmailEnc().isBlank()) { + p.setContactEmail(CryptoUtil.mask(crypto.decrypt(p.getContactEmailEnc()))); + } + p.setContactPhoneEnc(null); + p.setContactEmailEnc(null); + } + + private MesPartner require(Long id) { + MesPartner p = mapper.findById(id); + if (p == null) throw new RuntimeException("ERR-PARTNER-404: 거래처 없음"); + return p; + } +} diff --git a/backend/src/main/java/com/zioinfo/mes/partner/mapper/PartnerMapper.java b/backend/src/main/java/com/zioinfo/mes/partner/mapper/PartnerMapper.java new file mode 100644 index 0000000..fc38412 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/partner/mapper/PartnerMapper.java @@ -0,0 +1,19 @@ +package com.zioinfo.mes.partner.mapper; + +import com.zioinfo.mes.partner.MesPartner; +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); + MesPartner findById(@Param("id") Long id); + int countByCode(@Param("partnerCode") String partnerCode, @Param("excludeId") Long excludeId); + int insert(MesPartner p); + int update(MesPartner p); + int delete(@Param("id") Long id); +} diff --git a/backend/src/main/java/com/zioinfo/mes/plan/MesPlan.java b/backend/src/main/java/com/zioinfo/mes/plan/MesPlan.java new file mode 100644 index 0000000..01c5e74 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/plan/MesPlan.java @@ -0,0 +1,21 @@ +package com.zioinfo.mes.plan; + +import lombok.Data; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** 생산계획 (mes_plan) — 일정/간트 기준 데이터. */ +@Data +public class MesPlan { + private Long id; + private String planNo; + private String itemCode; + private String itemName; // 조인 표시(비영속) + private Double planQty; + private LocalDate startDate; + private LocalDate endDate; + private String status; // PLANNED / CONFIRMED / RELEASED / DONE + private String remark; + private String createdBy; + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/com/zioinfo/mes/plan/PlanController.java b/backend/src/main/java/com/zioinfo/mes/plan/PlanController.java new file mode 100644 index 0000000..b8a555c --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/plan/PlanController.java @@ -0,0 +1,92 @@ +package com.zioinfo.mes.plan; + +import com.zioinfo.mes.admin.AuditService; +import com.zioinfo.mes.ai.AiService; +import com.zioinfo.mes.common.ApiResponse; +import com.zioinfo.mes.common.AuthSupport; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Map; + +/** + * 생산계획 API + AI 일정 최적화. 조회 Viewer+, 변경 Manager+. + */ +@RestController +@RequestMapping("/api/mes/plans") +@RequiredArgsConstructor +public class PlanController { + + private final PlanMapper mapper; + private final AuditService audit; + private final AiService ai; + + @GetMapping + public ApiResponse> list(@RequestParam(required = false) String status, + @RequestParam(required = false) String itemCode) { + return ApiResponse.ok(mapper.findAll(status, itemCode)); + } + + @GetMapping("/{id}") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.ok(require(id)); + } + + @PostMapping + public ApiResponse create(@RequestBody MesPlan p, Authentication auth) { + AuthSupport.requireManager(auth); + if (p.getItemCode() == null) throw new IllegalArgumentException("ERR-PLAN-400: itemCode 필수"); + if (p.getPlanNo() == null || p.getPlanNo().isBlank()) + p.setPlanNo("PLAN-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + "-" + (System.currentTimeMillis() % 100000)); + if (p.getStatus() == null) p.setStatus("PLANNED"); + p.setCreatedBy(AuthSupport.actor(auth)); + mapper.insert(p); + audit.log("PLAN_CREATE", p.getPlanNo(), "item=" + p.getItemCode()); + return ApiResponse.ok(mapper.findById(p.getId())); + } + + @PutMapping("/{id}") + public ApiResponse update(@PathVariable Long id, @RequestBody MesPlan p, Authentication auth) { + AuthSupport.requireManager(auth); + require(id); + p.setId(id); + mapper.update(p); + audit.log("PLAN_UPDATE", p.getPlanNo(), "id=" + id); + return ApiResponse.ok(mapper.findById(id)); + } + + @PutMapping("/{id}/status") + public ApiResponse status(@PathVariable Long id, @RequestBody StatusRequest req, Authentication auth) { + AuthSupport.requireManager(auth); + MesPlan cur = require(id); + mapper.updateStatus(id, req.status()); + audit.log("PLAN_STATUS", cur.getPlanNo(), cur.getStatus() + " -> " + req.status()); + return ApiResponse.ok(mapper.findById(id)); + } + + /** AI 일정 최적화 — 납기/셋업 기준 순서 제안. */ + @PostMapping("/optimize") + public ApiResponse>> optimize(@RequestBody List> orders) { + return ApiResponse.ok(ai.scheduleOptimize(orders)); + } + + @DeleteMapping("/{id}") + public ApiResponse delete(@PathVariable Long id, Authentication auth) { + AuthSupport.requireManager(auth); + mapper.delete(id); + audit.log("PLAN_DELETE", String.valueOf(id), ""); + return ApiResponse.ok(null); + } + + private MesPlan require(Long id) { + MesPlan p = mapper.findById(id); + if (p == null) throw new RuntimeException("ERR-PLAN-404: 생산계획 없음"); + return p; + } + + record StatusRequest(String status) {} +} diff --git a/backend/src/main/java/com/zioinfo/mes/plan/PlanMapper.java b/backend/src/main/java/com/zioinfo/mes/plan/PlanMapper.java new file mode 100644 index 0000000..c29f0ad --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/plan/PlanMapper.java @@ -0,0 +1,16 @@ +package com.zioinfo.mes.plan; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface PlanMapper { + List findAll(@Param("status") String status, @Param("itemCode") String itemCode); + MesPlan findById(@Param("id") Long id); + int insert(MesPlan p); + int update(MesPlan p); + int updateStatus(@Param("id") Long id, @Param("status") String status); + int delete(@Param("id") Long id); +} diff --git a/backend/src/main/java/com/zioinfo/mes/process/MesProcessProgress.java b/backend/src/main/java/com/zioinfo/mes/process/MesProcessProgress.java new file mode 100644 index 0000000..ad12b63 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/process/MesProcessProgress.java @@ -0,0 +1,25 @@ +package com.zioinfo.mes.process; + +import lombok.Data; +import java.time.LocalDateTime; + +/** + * 공정 진행 (mes_process_progress) — 작업지시의 공정별 진행/작업자 배정. + */ +@Data +public class MesProcessProgress { + private Long id; + private Long workorderId; + private String woNo; + private Integer opSeq; + private String processCode; + private String processName; + private String equipmentCode; + private String workerId; + private String status; // WAITING / RUNNING / DONE + private Double inputQty; + private Double outputQty; + private LocalDateTime startedAt; + private LocalDateTime endedAt; + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/com/zioinfo/mes/process/ProcessController.java b/backend/src/main/java/com/zioinfo/mes/process/ProcessController.java new file mode 100644 index 0000000..8645189 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/process/ProcessController.java @@ -0,0 +1,61 @@ +package com.zioinfo.mes.process; + +import com.zioinfo.mes.admin.AuditService; +import com.zioinfo.mes.common.ApiResponse; +import com.zioinfo.mes.common.AuthSupport; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 공정 진행/작업자 배정 API (ProcessBoard 칸반용). 조회 Viewer+, 진행 입력 Worker+. + */ +@RestController +@RequestMapping("/api/mes/process") +@RequiredArgsConstructor +public class ProcessController { + + private final ProcessMapper mapper; + private final AuditService audit; + + @GetMapping("/workorder/{workorderId}") + public ApiResponse> byWorkorder(@PathVariable Long workorderId) { + return ApiResponse.ok(mapper.findByWorkorder(workorderId)); + } + + /** 실시간 진행 중 공정(칸반 RUNNING 칼럼). */ + @GetMapping("/running") + public ApiResponse> running() { + return ApiResponse.ok(mapper.findRunning()); + } + + @PostMapping + public ApiResponse create(@RequestBody MesProcessProgress p, Authentication auth) { + if (p.getWorkorderId() == null) throw new IllegalArgumentException("ERR-PROC-400: workorderId 필수"); + if (p.getStatus() == null) p.setStatus("WAITING"); + mapper.insert(p); + audit.log("PROCESS_CREATE", p.getWoNo(), "op=" + p.getOpSeq()); + return ApiResponse.ok(mapper.findById(p.getId())); + } + + /** 공정 상태 전이 — WAITING→RUNNING→DONE. Worker+. */ + @PutMapping("/{id}/status") + public ApiResponse status(@PathVariable Long id, @RequestBody StatusRequest req) { + MesProcessProgress cur = mapper.findById(id); + if (cur == null) throw new RuntimeException("ERR-PROC-404: 공정 진행 없음"); + mapper.updateStatus(id, req.status()); + audit.log("PROCESS_STATUS", cur.getWoNo(), cur.getStatus() + " -> " + req.status()); + return ApiResponse.ok(mapper.findById(id)); + } + + @PutMapping("/{id}") + public ApiResponse update(@PathVariable Long id, @RequestBody MesProcessProgress p, Authentication auth) { + p.setId(id); + mapper.update(p); + return ApiResponse.ok(mapper.findById(id)); + } + + record StatusRequest(String status) {} +} diff --git a/backend/src/main/java/com/zioinfo/mes/process/ProcessMapper.java b/backend/src/main/java/com/zioinfo/mes/process/ProcessMapper.java new file mode 100644 index 0000000..f1d598f --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/process/ProcessMapper.java @@ -0,0 +1,16 @@ +package com.zioinfo.mes.process; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface ProcessMapper { + List findByWorkorder(@Param("workorderId") Long workorderId); + List findRunning(); + MesProcessProgress findById(@Param("id") Long id); + int insert(MesProcessProgress p); + int update(MesProcessProgress p); + int updateStatus(@Param("id") Long id, @Param("status") String status); +} diff --git a/backend/src/main/java/com/zioinfo/mes/production/MesProduction.java b/backend/src/main/java/com/zioinfo/mes/production/MesProduction.java new file mode 100644 index 0000000..ea346c8 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/production/MesProduction.java @@ -0,0 +1,25 @@ +package com.zioinfo.mes.production; + +import lombok.Data; +import java.time.LocalDateTime; + +/** + * 생산실적 (mes_production) — 작업지시에 대한 산출/불량 보고 + LOT 생성. + */ +@Data +public class MesProduction { + private Long id; + private Long workorderId; + private String woNo; + private String itemCode; + private String lotNo; // 생성된 산출 LOT + private Double goodQty; // 양품 산출 + private Double defectQty; // 불량 + private String defectCode; // 대표 불량코드 + private String processCode; + private String equipmentCode; + private String workerId; + private String warehouseCode; // 산출 입고 창고 + private LocalDateTime reportedAt; + private String createdBy; +} diff --git a/backend/src/main/java/com/zioinfo/mes/production/ProductionController.java b/backend/src/main/java/com/zioinfo/mes/production/ProductionController.java new file mode 100644 index 0000000..466c459 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/production/ProductionController.java @@ -0,0 +1,49 @@ +package com.zioinfo.mes.production; + +import com.zioinfo.mes.common.ApiResponse; +import com.zioinfo.mes.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/mes/production") +@RequiredArgsConstructor +public class ProductionController { + + private final ProductionService service; + + @GetMapping + public ApiResponse> list(@RequestParam(required = false) String woNo, + @RequestParam(required = false) String itemCode, + @RequestParam(defaultValue = "200") int limit) { + return ApiResponse.ok(service.list(woNo, itemCode, limit)); + } + + @GetMapping("/{id}") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.ok(service.get(id)); + } + + @GetMapping("/daily-output") + public ApiResponse>> dailyOutput(@RequestParam(defaultValue = "14") int days) { + return ApiResponse.ok(service.dailyOutput(days)); + } + + @GetMapping("/defect-pareto") + public ApiResponse>> defectPareto(@RequestParam(defaultValue = "30") int days) { + return ApiResponse.ok(service.defectPareto(days)); + } + + /** 생산실적 보고 — LOT 생성·자재 투입·산출 입고·작업지시 누적. Worker+. */ + @PostMapping("/report") + public ApiResponse report(@RequestBody MesProduction p, Authentication auth) { + return ApiResponse.ok(service.report(p, AuthSupport.actor(auth))); + } +} diff --git a/backend/src/main/java/com/zioinfo/mes/production/ProductionService.java b/backend/src/main/java/com/zioinfo/mes/production/ProductionService.java new file mode 100644 index 0000000..f88d7c8 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/production/ProductionService.java @@ -0,0 +1,112 @@ +package com.zioinfo.mes.production; + +import com.zioinfo.mes.admin.AuditService; +import com.zioinfo.mes.bom.BomService; +import com.zioinfo.mes.inventory.InventoryService; +import com.zioinfo.mes.production.mapper.ProductionMapper; +import com.zioinfo.mes.workorder.MesWorkOrder; +import com.zioinfo.mes.workorder.mapper.WorkOrderMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Map; + +/** + * 생산실적 서비스 — 작업지시 산출 보고. + * + *

실적 보고 시: 1) 산출 LOT 생성 2) BOM 소요 자재 투입(재고 차감 CONSUME) + * 3) 산출 양품 입고(재고 증가 PRODUCE) 4) 작업지시 누적 산출/불량 갱신. + * 작업지시는 RELEASED/INPROGRESS 상태에서만 실적 보고 가능(보고 시 INPROGRESS 자동 전이). + */ +@Service +@RequiredArgsConstructor +public class ProductionService { + + private final ProductionMapper mapper; + private final WorkOrderMapper woMapper; + private final BomService bomService; + private final InventoryService inventory; + private final AuditService audit; + + public List list(String woNo, String itemCode, int limit) { + return mapper.findAll(woNo, itemCode, limit <= 0 ? 200 : limit); + } + + public MesProduction get(Long id) { + MesProduction p = mapper.findById(id); + if (p == null) throw new RuntimeException("ERR-PROD-404: 생산실적 없음"); + return p; + } + + public List> dailyOutput(int days) { + return mapper.dailyOutput(days <= 0 ? 14 : days); + } + + public List> defectPareto(int days) { + return mapper.defectPareto(days <= 0 ? 30 : days); + } + + /** + * 생산실적 보고 — LOT 생성 + 자재 투입 + 산출 입고 + 작업지시 누적. + */ + @Transactional + public MesProduction report(MesProduction p, String actor) { + if (p.getWorkorderId() == null) + throw new IllegalArgumentException("ERR-PROD-400: workorderId 필수"); + double good = p.getGoodQty() == null ? 0 : p.getGoodQty(); + double defect = p.getDefectQty() == null ? 0 : p.getDefectQty(); + if (good <= 0 && defect <= 0) + throw new IllegalArgumentException("ERR-PROD-400: 양품 또는 불량 수량이 필요합니다"); + + MesWorkOrder wo = woMapper.findById(p.getWorkorderId()); + if (wo == null) throw new RuntimeException("ERR-PROD-404: 작업지시 없음"); + if (!java.util.Set.of("RELEASED", "INPROGRESS").contains(wo.getStatus())) { + throw new RuntimeException("ERR-PROD-409: RELEASED/INPROGRESS 작업지시만 실적 보고 가능(현재 " + wo.getStatus() + ")"); + } + if ("RELEASED".equals(wo.getStatus())) { + woMapper.updateStatus(wo.getId(), "INPROGRESS"); + } + + p.setWoNo(wo.getWoNo()); + p.setItemCode(wo.getItemCode()); + String wh = p.getWarehouseCode() != null ? p.getWarehouseCode() : wo.getWarehouseCode(); + p.setWarehouseCode(wh); + if (p.getLotNo() == null || p.getLotNo().isBlank()) { + p.setLotNo(generateLot(wo.getItemCode())); + } + p.setCreatedBy(actor); + mapper.insert(p); + + double totalInput = good + defect; + // 1) BOM 자재 투입(재고 차감) — 소요량 = 산출수량 기준. 재고 부족 시 트랜잭션 롤백. + if (totalInput > 0) { + Map req = bomService.materialRequirement(wo.getItemCode(), totalInput, null); + for (Map.Entry e : req.entrySet()) { + inventory.outbound("CONSUME", e.getKey(), wh, null, null, e.getValue(), + "WORKORDER", wo.getWoNo(), actor); + } + } + // 2) 산출 양품 입고(재고 증가, 산출 LOT) + if (good > 0) { + inventory.inbound("PRODUCE", wo.getItemCode(), wh, null, p.getLotNo(), good, + "WORKORDER", wo.getWoNo(), actor); + } + // 3) 작업지시 누적 산출/불량 + woMapper.addProduced(wo.getId(), good, defect); + + audit.log("PRODUCTION_REPORT", wo.getWoNo(), + "good=" + good + " defect=" + defect + " lot=" + p.getLotNo()); + return mapper.findById(p.getId()); + } + + private String generateLot(String itemCode) { + String prefix = itemCode == null ? "LOT" : itemCode.replaceAll("[^A-Za-z0-9]", ""); + if (prefix.length() > 8) prefix = prefix.substring(0, 8); + return prefix + "-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + + "-" + (System.currentTimeMillis() % 100000); + } +} diff --git a/backend/src/main/java/com/zioinfo/mes/production/mapper/ProductionMapper.java b/backend/src/main/java/com/zioinfo/mes/production/mapper/ProductionMapper.java new file mode 100644 index 0000000..61c4100 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/production/mapper/ProductionMapper.java @@ -0,0 +1,23 @@ +package com.zioinfo.mes.production.mapper; + +import com.zioinfo.mes.production.MesProduction; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; +import java.util.Map; + +@Mapper +public interface ProductionMapper { + List findAll(@Param("woNo") String woNo, + @Param("itemCode") String itemCode, + @Param("limit") int limit); + MesProduction findById(@Param("id") Long id); + List findByWorkorder(@Param("workorderId") Long workorderId); + int insert(MesProduction p); + + /** 일자별 생산/불량 집계(대시보드). */ + List> dailyOutput(@Param("days") int days); + /** 불량코드별 집계(파레토). */ + List> defectPareto(@Param("days") int days); +} diff --git a/backend/src/main/java/com/zioinfo/mes/progress/ProgressController.java b/backend/src/main/java/com/zioinfo/mes/progress/ProgressController.java new file mode 100644 index 0000000..87878bf --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/progress/ProgressController.java @@ -0,0 +1,66 @@ +package com.zioinfo.mes.progress; + +import com.zioinfo.mes.common.ApiResponse; +import com.zioinfo.mes.progress.mapper.ProgressMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 진행관리 API — 작업지시/공정 진척률·계획대비실적·실시간 생산현황·지연 알림. + * + *

읽기 전용(조회 Viewer+). 기존 작업지시/공정/작업/계획 데이터를 집계한다. + */ +@RestController +@RequestMapping("/api/mes/progress") +@RequiredArgsConstructor +public class ProgressController { + + private final ProgressMapper mapper; + + /** 전체 진척 요약 KPI(활성/진행/지연 작업지시·가동 공정·평균 진척률). */ + @GetMapping("/summary") + public ApiResponse> summary() { + return ApiResponse.ok(mapper.progressSummary()); + } + + /** 작업지시별 진척률 목록(status 미지정 시 RELEASED/INPROGRESS/DONE). */ + @GetMapping("/workorders") + public ApiResponse>> workorders(@RequestParam(required = false) String status) { + return ApiResponse.ok(mapper.workorderProgress(status)); + } + + /** 단일 작업지시 진척 상세 + 공정 진행 목록. */ + @GetMapping("/workorders/{id}") + public ApiResponse> workorderDetail(@PathVariable Long id) { + Map wo = mapper.workorderProgressOne(id); + if (wo == null) throw new RuntimeException("ERR-PROG-404: 작업지시 없음"); + Map out = new LinkedHashMap<>(wo); + out.put("processes", mapper.processProgressByWo(id)); + return ApiResponse.ok(out); + } + + /** 실시간 생산현황(가동중 RUNNING 공정). */ + @GetMapping("/live") + public ApiResponse>> live() { + return ApiResponse.ok(mapper.liveProcess()); + } + + /** 계획 대비 실적(품목별). */ + @GetMapping("/plan-vs-actual") + public ApiResponse>> planVsActual(@RequestParam(defaultValue = "30") int days) { + return ApiResponse.ok(mapper.planVsActual(days)); + } + + /** 지연 알림(작업지시 + 작업). */ + @GetMapping("/delays") + public ApiResponse> delays() { + Map m = new LinkedHashMap<>(); + m.put("workorders", mapper.delayedWorkorders()); + m.put("jobs", mapper.delayedJobs()); + return ApiResponse.ok(m); + } +} diff --git a/backend/src/main/java/com/zioinfo/mes/progress/mapper/ProgressMapper.java b/backend/src/main/java/com/zioinfo/mes/progress/mapper/ProgressMapper.java new file mode 100644 index 0000000..10b5e81 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/progress/mapper/ProgressMapper.java @@ -0,0 +1,40 @@ +package com.zioinfo.mes.progress.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; +import java.util.Map; + +/** + * 진행관리 매퍼 — 작업지시/공정 진척률·계획대비실적·실시간 생산현황·지연 집계. + * + *

기존 mes_workorder / mes_process_progress / mes_job / mes_plan 을 조회·집계한다(읽기 전용). + */ +@Mapper +public interface ProgressMapper { + + /** 작업지시별 진척률(produced/plan) — 진행중·완료 위주. */ + List> workorderProgress(@Param("status") String status); + + /** 단일 작업지시 진척 상세(작업지시 + 공정 진행 목록). */ + Map workorderProgressOne(@Param("id") Long id); + + /** 작업지시별 공정 진행 목록. */ + List> processProgressByWo(@Param("workorderId") Long workorderId); + + /** 실시간 가동 공정(RUNNING) 현황. */ + List> liveProcess(); + + /** 계획 대비 실적(품목별: 계획수량 vs 생산수량 vs 진행작업지시). */ + List> planVsActual(@Param("days") int days); + + /** 지연 작업지시(due_date 경과 + 미완료). */ + List> delayedWorkorders(); + + /** 지연 작업(planned_end 경과 + 미완료 job). */ + List> delayedJobs(); + + /** 전체 진척 요약(KPI). */ + Map progressSummary(); +} diff --git a/backend/src/main/java/com/zioinfo/mes/receiving/MesReceiving.java b/backend/src/main/java/com/zioinfo/mes/receiving/MesReceiving.java new file mode 100644 index 0000000..878fc65 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/receiving/MesReceiving.java @@ -0,0 +1,27 @@ +package com.zioinfo.mes.receiving; + +import lombok.Data; +import java.time.LocalDateTime; + +/** + * 입고 (mes_receiving) — 공급사 자재/제품 입고·검수. + * + *

status: REQUESTED → INSPECTING → ACCEPTED(입고확정·재고증가) / REJECTED. + */ +@Data +public class MesReceiving { + private Long id; + private String receivingNo; + private String partnerCode; + private String itemCode; + private String lotNo; + private Double qty; + private String warehouseCode; + private String locationCode; + private String status; + private Boolean inspectionRequired; + private String inspectionResult; // PASS / FAIL + private String remark; + private String createdBy; + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/com/zioinfo/mes/receiving/ReceivingController.java b/backend/src/main/java/com/zioinfo/mes/receiving/ReceivingController.java new file mode 100644 index 0000000..9d6851b --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/receiving/ReceivingController.java @@ -0,0 +1,54 @@ +package com.zioinfo.mes.receiving; + +import com.zioinfo.mes.common.ApiResponse; +import com.zioinfo.mes.common.AuthSupport; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 입고관리 API (WMS). 조회 Viewer+, 등록/검수 Worker+, 입고확정(재고증가) Worker+. + */ +@RestController +@RequestMapping("/api/mes/receiving") +@RequiredArgsConstructor +public class ReceivingController { + + private final ReceivingService service; + + @GetMapping + public ApiResponse> list(@RequestParam(required = false) String status, + @RequestParam(required = false) String itemCode) { + return ApiResponse.ok(service.list(status, itemCode)); + } + + @GetMapping("/{id}") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.ok(service.get(id)); + } + + @PostMapping + public ApiResponse create(@RequestBody MesReceiving r, Authentication auth) { + return ApiResponse.ok(service.create(r, AuthSupport.actor(auth))); + } + + /** 입고확정 — 재고 증가. */ + @PostMapping("/{id}/accept") + public ApiResponse accept(@PathVariable Long id, @RequestBody(required = false) AcceptRequest req, + Authentication auth) { + String result = req == null ? null : req.inspectionResult(); + return ApiResponse.ok(service.accept(id, result, AuthSupport.actor(auth))); + } + + @PostMapping("/{id}/reject") + public ApiResponse reject(@PathVariable Long id, @RequestBody(required = false) RejectRequest req, + Authentication auth) { + String reason = req == null ? null : req.reason(); + return ApiResponse.ok(service.reject(id, reason, AuthSupport.actor(auth))); + } + + record AcceptRequest(String inspectionResult) {} + record RejectRequest(String reason) {} +} diff --git a/backend/src/main/java/com/zioinfo/mes/receiving/ReceivingMapper.java b/backend/src/main/java/com/zioinfo/mes/receiving/ReceivingMapper.java new file mode 100644 index 0000000..5477420 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/receiving/ReceivingMapper.java @@ -0,0 +1,15 @@ +package com.zioinfo.mes.receiving; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface ReceivingMapper { + List findAll(@Param("status") String status, @Param("itemCode") String itemCode); + MesReceiving findById(@Param("id") Long id); + int insert(MesReceiving r); + int updateStatus(@Param("id") Long id, @Param("status") String status, + @Param("inspectionResult") String inspectionResult); +} diff --git a/backend/src/main/java/com/zioinfo/mes/receiving/ReceivingService.java b/backend/src/main/java/com/zioinfo/mes/receiving/ReceivingService.java new file mode 100644 index 0000000..09254d3 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/receiving/ReceivingService.java @@ -0,0 +1,81 @@ +package com.zioinfo.mes.receiving; + +import com.zioinfo.mes.admin.AuditService; +import com.zioinfo.mes.inventory.InventoryService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; + +/** + * 입고 서비스 — 공급사 자재/제품 입고·검수. + * + *

흐름: REQUESTED → (INSPECTING) → ACCEPTED(입고확정·재고증가) / REJECTED. + * ACCEPTED 시 {@link InventoryService#inbound}로 RECEIVE 수불 + 잔고 증가(원장 일원화). + */ +@Service +@RequiredArgsConstructor +public class ReceivingService { + + private final ReceivingMapper mapper; + private final InventoryService inventory; + private final AuditService audit; + + public List list(String status, String itemCode) { + return mapper.findAll(status, itemCode); + } + + public MesReceiving get(Long id) { + MesReceiving r = mapper.findById(id); + if (r == null) throw new RuntimeException("ERR-RECV-404: 입고 없음"); + return r; + } + + @Transactional + public MesReceiving create(MesReceiving r, String actor) { + if (r.getItemCode() == null || r.getItemCode().isBlank()) + throw new IllegalArgumentException("ERR-RECV-400: itemCode 필수"); + if (r.getQty() == null || r.getQty() <= 0) + throw new IllegalArgumentException("ERR-RECV-400: qty 는 0보다 커야 합니다"); + if (r.getReceivingNo() == null || r.getReceivingNo().isBlank()) + r.setReceivingNo("RCV-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + + "-" + (System.currentTimeMillis() % 100000)); + boolean inspect = Boolean.TRUE.equals(r.getInspectionRequired()); + r.setStatus(inspect ? "INSPECTING" : "REQUESTED"); + r.setCreatedBy(actor); + mapper.insert(r); + audit.log("RECEIVING_CREATE", r.getReceivingNo(), "item=" + r.getItemCode() + " qty=" + r.getQty()); + return mapper.findById(r.getId()); + } + + /** + * 입고 확정(ACCEPT) — 검사 결과 반영 후 재고 증가. + * 검사 필요 입고는 inspectionResult=PASS 여야 확정 가능. + */ + @Transactional + public MesReceiving accept(Long id, String inspectionResult, String actor) { + MesReceiving r = get(id); + if ("ACCEPTED".equals(r.getStatus())) + throw new RuntimeException("ERR-RECV-409: 이미 입고확정됨"); + if (Boolean.TRUE.equals(r.getInspectionRequired()) && !"PASS".equalsIgnoreCase( + inspectionResult == null ? r.getInspectionResult() : inspectionResult)) { + throw new RuntimeException("ERR-RECV-409: 검사 합격(PASS) 후에만 입고확정 가능"); + } + mapper.updateStatus(id, "ACCEPTED", inspectionResult); + inventory.inbound("RECEIVE", r.getItemCode(), r.getWarehouseCode(), r.getLocationCode(), + r.getLotNo(), r.getQty(), "RECEIVING", r.getReceivingNo(), actor); + audit.log("RECEIVING_ACCEPT", r.getReceivingNo(), "qty=" + r.getQty() + " → 재고증가"); + return mapper.findById(id); + } + + @Transactional + public MesReceiving reject(Long id, String reason, String actor) { + MesReceiving r = get(id); + mapper.updateStatus(id, "REJECTED", "FAIL"); + audit.log("RECEIVING_REJECT", r.getReceivingNo(), reason == null ? "" : reason); + return mapper.findById(id); + } +} diff --git a/backend/src/main/java/com/zioinfo/mes/routing/MesRouting.java b/backend/src/main/java/com/zioinfo/mes/routing/MesRouting.java new file mode 100644 index 0000000..7b30e12 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/routing/MesRouting.java @@ -0,0 +1,23 @@ +package com.zioinfo.mes.routing; + +import lombok.Data; +import java.time.LocalDateTime; + +/** + * 라우팅 (mes_routing) — 품목별 공정 순서·표준시간·설비. + */ +@Data +public class MesRouting { + private Long id; + private String itemCode; + private Integer opSeq; // 공정 순번 + private String processCode; // 공정 코드 + private String processName; + private String equipmentCode; // 표준 설비 + private Double stdTimeMin; // 표준 작업시간(분/단위) + private Double setupTimeMin; // 셋업시간 + private String routingVersion; + private String status; + private String createdBy; + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/com/zioinfo/mes/routing/RoutingController.java b/backend/src/main/java/com/zioinfo/mes/routing/RoutingController.java new file mode 100644 index 0000000..bf1e485 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/routing/RoutingController.java @@ -0,0 +1,63 @@ +package com.zioinfo.mes.routing; + +import com.zioinfo.mes.admin.AuditService; +import com.zioinfo.mes.common.ApiResponse; +import com.zioinfo.mes.common.AuthSupport; +import com.zioinfo.mes.routing.mapper.RoutingMapper; +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/mes/routings") +@RequiredArgsConstructor +public class RoutingController { + + private final RoutingMapper mapper; + private final AuditService audit; + + @GetMapping + public ApiResponse> list(@RequestParam(required = false) String itemCode) { + return ApiResponse.ok(itemCode != null ? mapper.findByItem(itemCode) : mapper.findAll()); + } + + @GetMapping("/{id}") + public ApiResponse get(@PathVariable Long id) { + MesRouting r = mapper.findById(id); + if (r == null) throw new RuntimeException("ERR-ROUTING-404: 라우팅 없음"); + return ApiResponse.ok(r); + } + + @PostMapping + public ApiResponse create(@RequestBody MesRouting r, Authentication auth) { + AuthSupport.requireManager(auth); + if (r.getItemCode() == null) throw new IllegalArgumentException("ERR-ROUTING-400: itemCode 필수"); + if (r.getStatus() == null) r.setStatus("ACTIVE"); + r.setCreatedBy(AuthSupport.actor(auth)); + mapper.insert(r); + audit.log("ROUTING_CREATE", r.getItemCode(), "op=" + r.getOpSeq()); + return ApiResponse.ok(mapper.findById(r.getId())); + } + + @PutMapping("/{id}") + public ApiResponse update(@PathVariable Long id, @RequestBody MesRouting r, Authentication auth) { + AuthSupport.requireManager(auth); + r.setId(id); + mapper.update(r); + audit.log("ROUTING_UPDATE", r.getItemCode(), "id=" + id); + return ApiResponse.ok(mapper.findById(id)); + } + + @DeleteMapping("/{id}") + public ApiResponse delete(@PathVariable Long id, Authentication auth) { + AuthSupport.requireManager(auth); + mapper.delete(id); + audit.log("ROUTING_DELETE", String.valueOf(id), ""); + return ApiResponse.ok(null); + } +} diff --git a/backend/src/main/java/com/zioinfo/mes/routing/mapper/RoutingMapper.java b/backend/src/main/java/com/zioinfo/mes/routing/mapper/RoutingMapper.java new file mode 100644 index 0000000..2974798 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/routing/mapper/RoutingMapper.java @@ -0,0 +1,17 @@ +package com.zioinfo.mes.routing.mapper; + +import com.zioinfo.mes.routing.MesRouting; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface RoutingMapper { + List findByItem(@Param("itemCode") String itemCode); + List findAll(); + MesRouting findById(@Param("id") Long id); + int insert(MesRouting r); + int update(MesRouting r); + int delete(@Param("id") Long id); +} diff --git a/backend/src/main/java/com/zioinfo/mes/shipping/MesShipping.java b/backend/src/main/java/com/zioinfo/mes/shipping/MesShipping.java new file mode 100644 index 0000000..ef85b83 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/shipping/MesShipping.java @@ -0,0 +1,38 @@ +package com.zioinfo.mes.shipping; + +import lombok.Data; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 출하 (mes_shipping) — 출하지시·피킹/패킹·출하검사·출하실적·납품서/거래명세서. + * + *

상태: ORDERED → PICKING → PACKING → (INSPECTED) → SHIPPED (CANCELLED 가능). + * SHIPPED 시 재고 감소(ISSUE/SHIP 수불) + 납품서/거래명세서 번호 발번. + */ +@Data +public class MesShipping { + private Long id; + private String shippingNo; + private String partnerCode; // 고객 + private String orderRef; // 주문/수주 참조 + private String itemCode; + private String itemName; // 조인 표시(비영속) + private String lotNo; + private Double planQty; + private Double pickedQty; + private Double packedQty; + private Double shippedQty; + private String warehouseCode; + private String locationCode; + private String status; // ORDERED/PICKING/PACKING/INSPECTED/SHIPPED/CANCELLED + private Boolean inspectionRequired; + private String inspectionResult; // PASS/FAIL + private String deliveryNo; // 납품서 번호 + private String statementNo; // 거래명세서 번호 + private LocalDate shipDate; + private String remark; + private String createdBy; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/backend/src/main/java/com/zioinfo/mes/shipping/ShippingController.java b/backend/src/main/java/com/zioinfo/mes/shipping/ShippingController.java new file mode 100644 index 0000000..d480ad1 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/shipping/ShippingController.java @@ -0,0 +1,83 @@ +package com.zioinfo.mes.shipping; + +import com.zioinfo.mes.common.ApiResponse; +import com.zioinfo.mes.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 (WMS) — 출하지시·피킹/패킹·출하검사·출하실적·납품서/거래명세서. + * + *

RBAC: 조회 Viewer+, 등록/피킹/패킹/검사/출하 Worker+, 삭제 Manager+. + */ +@RestController +@RequestMapping("/api/mes/shipping") +@RequiredArgsConstructor +public class ShippingController { + + private final ShippingService 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("/board") + public ApiResponse>> board() { + return ApiResponse.ok(service.statusCounts()); + } + + @GetMapping("/{id}") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.ok(service.get(id)); + } + + /** 출하지시 생성. */ + @PostMapping + public ApiResponse create(@RequestBody MesShipping s, Authentication auth) { + return ApiResponse.ok(service.create(s, AuthSupport.actor(auth))); + } + + @PutMapping("/{id}") + public ApiResponse update(@PathVariable Long id, @RequestBody MesShipping s, Authentication auth) { + return ApiResponse.ok(service.update(id, s, AuthSupport.actor(auth))); + } + + /** 피킹. */ + @PostMapping("/{id}/pick") + public ApiResponse pick(@PathVariable Long id, @RequestBody QtyRequest req, Authentication auth) { + return ApiResponse.ok(service.pick(id, req.qty(), AuthSupport.actor(auth))); + } + + /** 패킹. */ + @PostMapping("/{id}/pack") + public ApiResponse pack(@PathVariable Long id, @RequestBody QtyRequest req, Authentication auth) { + return ApiResponse.ok(service.pack(id, req.qty(), AuthSupport.actor(auth))); + } + + /** 출하검사(OQC). */ + @PostMapping("/{id}/inspect") + public ApiResponse inspect(@PathVariable Long id, @RequestBody InspectRequest req, Authentication auth) { + return ApiResponse.ok(service.inspect(id, req.result(), AuthSupport.actor(auth))); + } + + /** 출하확정 — 재고 감소 + 납품서/거래명세서 발번. */ + @PostMapping("/{id}/ship") + public ApiResponse ship(@PathVariable Long id, Authentication auth) { + return ApiResponse.ok(service.ship(id, AuthSupport.actor(auth))); + } + + @PostMapping("/{id}/cancel") + public ApiResponse cancel(@PathVariable Long id, Authentication auth) { + return ApiResponse.ok(service.cancel(id, AuthSupport.actor(auth))); + } + + record QtyRequest(double qty) {} + record InspectRequest(String result) {} +} diff --git a/backend/src/main/java/com/zioinfo/mes/shipping/ShippingService.java b/backend/src/main/java/com/zioinfo/mes/shipping/ShippingService.java new file mode 100644 index 0000000..9bc910d --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/shipping/ShippingService.java @@ -0,0 +1,151 @@ +package com.zioinfo.mes.shipping; + +import com.zioinfo.mes.admin.AuditService; +import com.zioinfo.mes.inventory.InventoryService; +import com.zioinfo.mes.shipping.mapper.ShippingMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Map; + +/** + * 출하 서비스 — 출하지시·피킹·패킹·출하검사·출하확정(재고감소·납품서/거래명세서 발번). + * + *

상태 흐름: ORDERED → PICKING → PACKING → (INSPECTED) → SHIPPED. + *

    + *
  • pick: 피킹 수량 기록(picked_qty), 상태 PICKING
  • + *
  • pack: 패킹 수량 기록(packed_qty), 상태 PACKING
  • + *
  • inspect: 출하검사(OQC) 결과 기록, 상태 INSPECTED — inspectionRequired 시 FAIL 이면 ship 차단
  • + *
  • ship: 출하확정 — 재고 감소(ISSUE) + 납품서/거래명세서 발번, 상태 SHIPPED
  • + *
+ */ +@Service +@RequiredArgsConstructor +public class ShippingService { + + private final ShippingMapper mapper; + private final InventoryService inventory; + private final AuditService audit; + + public List list(String status, String partnerCode, String keyword) { + return mapper.findAll(status, partnerCode, keyword); + } + + public MesShipping get(Long id) { + MesShipping s = mapper.findById(id); + if (s == null) throw new RuntimeException("ERR-SHIP-404: 출하 없음"); + return s; + } + + public List> statusCounts() { + return mapper.countByStatus(); + } + + @Transactional + public MesShipping create(MesShipping s, String actor) { + if (s.getItemCode() == null || s.getItemCode().isBlank()) + throw new IllegalArgumentException("ERR-SHIP-400: itemCode 필수"); + if (s.getPlanQty() == null || s.getPlanQty() <= 0) + throw new IllegalArgumentException("ERR-SHIP-400: planQty 는 0보다 커야 합니다"); + if (s.getShippingNo() == null || s.getShippingNo().isBlank()) + s.setShippingNo("SHP-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + + "-" + (System.currentTimeMillis() % 100000)); + if (mapper.countByNo(s.getShippingNo()) > 0) + throw new RuntimeException("ERR-SHIP-409: 중복 출하지시번호"); + s.setStatus("ORDERED"); + if (s.getInspectionRequired() == null) s.setInspectionRequired(false); + s.setCreatedBy(actor); + mapper.insert(s); + audit.log("SHIPPING_CREATE", s.getShippingNo(), + "partner=" + s.getPartnerCode() + " item=" + s.getItemCode() + " qty=" + s.getPlanQty()); + return mapper.findById(s.getId()); + } + + @Transactional + public MesShipping update(Long id, MesShipping s, String actor) { + MesShipping cur = get(id); + if (!"ORDERED".equals(cur.getStatus())) + throw new RuntimeException("ERR-SHIP-409: ORDERED 상태에서만 수정 가능"); + s.setId(id); + mapper.update(s); + audit.log("SHIPPING_UPDATE", cur.getShippingNo(), "id=" + id); + return mapper.findById(id); + } + + /** 피킹 — 출고 대상 수량 확정. */ + @Transactional + public MesShipping pick(Long id, double qty, String actor) { + MesShipping s = get(id); + if (qty <= 0) throw new IllegalArgumentException("ERR-SHIP-400: 피킹 수량은 0보다 커야 합니다"); + if (qty > s.getPlanQty()) + throw new RuntimeException("ERR-SHIP-409: 피킹 수량이 지시 수량을 초과"); + mapper.updatePicked(id, qty); + audit.log("SHIPPING_PICK", s.getShippingNo(), "picked=" + qty); + return mapper.findById(id); + } + + /** 패킹. */ + @Transactional + public MesShipping pack(Long id, double qty, String actor) { + MesShipping s = get(id); + double picked = s.getPickedQty() == null ? 0 : s.getPickedQty(); + if (qty <= 0) throw new IllegalArgumentException("ERR-SHIP-400: 패킹 수량은 0보다 커야 합니다"); + if (qty > picked) + throw new RuntimeException("ERR-SHIP-409: 패킹 수량이 피킹 수량을 초과"); + mapper.updatePacked(id, qty); + audit.log("SHIPPING_PACK", s.getShippingNo(), "packed=" + qty); + return mapper.findById(id); + } + + /** 출하검사(OQC). */ + @Transactional + public MesShipping inspect(Long id, String result, String actor) { + MesShipping s = get(id); + String r = result == null ? "PASS" : result.toUpperCase(); + if (!r.equals("PASS") && !r.equals("FAIL")) + throw new IllegalArgumentException("ERR-SHIP-400: 검사결과는 PASS/FAIL"); + mapper.updateInspection(id, r); + audit.log("SHIPPING_INSPECT", s.getShippingNo(), "result=" + r); + return mapper.findById(id); + } + + /** 출하확정 — 재고 감소 + 납품서/거래명세서 발번. */ + @Transactional + public MesShipping ship(Long id, String actor) { + MesShipping s = get(id); + if ("SHIPPED".equals(s.getStatus())) + throw new RuntimeException("ERR-SHIP-409: 이미 출하확정됨"); + if ("CANCELLED".equals(s.getStatus())) + throw new RuntimeException("ERR-SHIP-409: 취소된 출하"); + if (Boolean.TRUE.equals(s.getInspectionRequired()) + && !"PASS".equalsIgnoreCase(s.getInspectionResult())) { + throw new RuntimeException("ERR-SHIP-409: 출하검사 합격(PASS) 후에만 출하확정 가능"); + } + double qty = s.getPackedQty() != null && s.getPackedQty() > 0 ? s.getPackedQty() + : s.getPickedQty() != null && s.getPickedQty() > 0 ? s.getPickedQty() + : s.getPlanQty(); + // 재고 감소(원장 일원화: ISSUE 수불) + inventory.outbound("ISSUE", s.getItemCode(), s.getWarehouseCode(), s.getLocationCode(), + s.getLotNo(), qty, "SHIPPING", s.getShippingNo(), actor); + String deliveryNo = "DLV-" + s.getShippingNo(); + String statementNo = "STM-" + s.getShippingNo(); + mapper.updateShip(id, qty, deliveryNo, statementNo); + audit.log("SHIPPING_SHIP", s.getShippingNo(), + "qty=" + qty + " 납품서=" + deliveryNo + " 거래명세서=" + statementNo); + return mapper.findById(id); + } + + @Transactional + public MesShipping cancel(Long id, String actor) { + MesShipping s = get(id); + if ("SHIPPED".equals(s.getStatus())) + throw new RuntimeException("ERR-SHIP-409: 출하확정 건은 취소 불가"); + mapper.updateStatus(id, "CANCELLED"); + audit.log("SHIPPING_CANCEL", s.getShippingNo(), ""); + return mapper.findById(id); + } +} diff --git a/backend/src/main/java/com/zioinfo/mes/shipping/mapper/ShippingMapper.java b/backend/src/main/java/com/zioinfo/mes/shipping/mapper/ShippingMapper.java new file mode 100644 index 0000000..c6ec15c --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/shipping/mapper/ShippingMapper.java @@ -0,0 +1,29 @@ +package com.zioinfo.mes.shipping.mapper; + +import com.zioinfo.mes.shipping.MesShipping; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; +import java.util.Map; + +@Mapper +public interface ShippingMapper { + List findAll(@Param("status") String status, + @Param("partnerCode") String partnerCode, + @Param("keyword") String keyword); + MesShipping findById(@Param("id") Long id); + int countByNo(@Param("shippingNo") String shippingNo); + int insert(MesShipping s); + int update(MesShipping s); + int updateStatus(@Param("id") Long id, @Param("status") String status); + int updatePicked(@Param("id") Long id, @Param("pickedQty") Double pickedQty); + int updatePacked(@Param("id") Long id, @Param("packedQty") Double packedQty); + int updateInspection(@Param("id") Long id, @Param("inspectionResult") String inspectionResult); + int updateShip(@Param("id") Long id, @Param("shippedQty") Double shippedQty, + @Param("deliveryNo") String deliveryNo, @Param("statementNo") String statementNo); + int delete(@Param("id") Long id); + + /** 상태별 카운트(대시보드). */ + List> countByStatus(); +} diff --git a/backend/src/main/java/com/zioinfo/mes/spc/MesSpcSample.java b/backend/src/main/java/com/zioinfo/mes/spc/MesSpcSample.java new file mode 100644 index 0000000..e8705de --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/spc/MesSpcSample.java @@ -0,0 +1,22 @@ +package com.zioinfo.mes.spc; + +import lombok.Data; +import java.time.LocalDateTime; + +/** + * SPC 부분군 샘플 (mes_spc_sample) — 관리도(Xbar-R) 부분군 측정값. + */ +@Data +public class MesSpcSample { + private Long id; + private String chartCode; + private String itemCode; + private String characteristic; + private Integer subgroupNo; + private String sampleValues; // JSONB 문자열(측정값 배열) + private Double sampleMean; + private Double sampleRange; + private LocalDateTime measuredAt; + private String createdBy; + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/com/zioinfo/mes/spc/SpcController.java b/backend/src/main/java/com/zioinfo/mes/spc/SpcController.java new file mode 100644 index 0000000..b613a4b --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/spc/SpcController.java @@ -0,0 +1,52 @@ +package com.zioinfo.mes.spc; + +import com.zioinfo.mes.common.ApiResponse; +import com.zioinfo.mes.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; + +/** + * SPC 관리도 API (QMS) — Xbar-R 관리한계·Cp/Cpk·이상감지. 조회 Viewer+, 샘플 적재 Worker+. + */ +@RestController +@RequestMapping("/api/mes/spc") +@RequiredArgsConstructor +public class SpcController { + + private final SpcService service; + + /** 관리도 목록(차트코드). */ + @GetMapping("/charts") + public ApiResponse> charts() { + return ApiResponse.ok(service.charts()); + } + + /** 부분군 샘플 목록. */ + @GetMapping("/samples") + public ApiResponse> samples(@RequestParam String chartCode, + @RequestParam(defaultValue = "100") int limit) { + return ApiResponse.ok(service.samples(chartCode, limit)); + } + + /** 관리도 계산 — Xbar/R 관리한계 + Cp/Cpk(lsl/usl 제공 시) + 이상감지. */ + @GetMapping("/chart") + public ApiResponse> chart(@RequestParam String chartCode, + @RequestParam(required = false) Double lsl, + @RequestParam(required = false) Double usl) { + return ApiResponse.ok(service.chart(chartCode, lsl, usl)); + } + + /** 부분군 측정값 적재(평균/범위 자동 계산). */ + @PostMapping("/samples") + public ApiResponse addSample(@RequestBody SampleRequest req, Authentication auth) { + return ApiResponse.ok(service.addSample(req.chartCode(), req.itemCode(), req.characteristic(), + req.subgroupNo(), req.values(), AuthSupport.actor(auth))); + } + + record SampleRequest(String chartCode, String itemCode, String characteristic, + Integer subgroupNo, List values) {} +} diff --git a/backend/src/main/java/com/zioinfo/mes/spc/SpcService.java b/backend/src/main/java/com/zioinfo/mes/spc/SpcService.java new file mode 100644 index 0000000..b562ae3 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/spc/SpcService.java @@ -0,0 +1,153 @@ +package com.zioinfo.mes.spc; + +import com.zioinfo.mes.admin.AuditService; +import com.zioinfo.mes.ai.AiService; +import com.zioinfo.mes.spc.mapper.SpcMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * SPC 서비스 — 관리도(Xbar-R) 부분군 적재 + 관리한계 산출 + 공정능력(Cp/Cpk) + AI 이상감지. + * + *

Xbar-R 상수(부분군 크기 n=2~10): A2, D3, D4, d2 표 내장. + * 공정능력: Cp = (USL-LSL)/(6σ), Cpk = min((USL-μ),(μ-LSL))/(3σ), σ ≈ Rbar/d2. + */ +@Service +@RequiredArgsConstructor +public class SpcService { + + // n: {A2, D3, D4, d2} (Xbar-R 관리도 상수표) + private static final Map CONST = Map.of( + 2, new double[]{1.880, 0.0, 3.267, 1.128}, + 3, new double[]{1.023, 0.0, 2.574, 1.693}, + 4, new double[]{0.729, 0.0, 2.282, 2.059}, + 5, new double[]{0.577, 0.0, 2.114, 2.326}, + 6, new double[]{0.483, 0.0, 2.004, 2.534}, + 7, new double[]{0.419, 0.076, 1.924, 2.704}, + 8, new double[]{0.373, 0.136, 1.864, 2.847}, + 9, new double[]{0.337, 0.184, 1.816, 2.970}, + 10, new double[]{0.308, 0.223, 1.777, 3.078} + ); + + private final SpcMapper mapper; + private final AiService ai; + private final AuditService audit; + + public List charts() { + return mapper.distinctCharts(); + } + + public List samples(String chartCode, int limit) { + return mapper.findByChart(chartCode, limit <= 0 ? 100 : limit); + } + + /** 부분군 측정값 적재 — 평균/범위 자동 계산. */ + @Transactional + public MesSpcSample addSample(String chartCode, String itemCode, String characteristic, + Integer subgroupNo, List values, String actor) { + if (values == null || values.isEmpty()) + throw new IllegalArgumentException("ERR-SPC-400: 측정값 필수"); + double mean = values.stream().mapToDouble(Double::doubleValue).average().orElse(0); + double max = values.stream().mapToDouble(Double::doubleValue).max().orElse(0); + double min = values.stream().mapToDouble(Double::doubleValue).min().orElse(0); + MesSpcSample s = new MesSpcSample(); + s.setChartCode(chartCode); + s.setItemCode(itemCode); + s.setCharacteristic(characteristic); + s.setSubgroupNo(subgroupNo); + s.setSampleValues(values.toString()); + s.setSampleMean(round3(mean)); + s.setSampleRange(round3(max - min)); + s.setCreatedBy(actor); + mapper.insert(s); + audit.log("SPC_SAMPLE", chartCode, "subgroup=" + subgroupNo + " mean=" + s.getSampleMean()); + return s; + } + + /** + * 관리도 계산 — Xbar/R 관리한계 + Cp/Cpk + AI 이상감지. + * + * @param lsl,usl 규격(공정능력 계산용, 없으면 Cp/Cpk 생략) + */ + public Map chart(String chartCode, Double lsl, Double usl) { + List samples = mapper.findByChart(chartCode, 200); + Map out = new LinkedHashMap<>(); + out.put("chartCode", chartCode); + out.put("subgroupCount", samples.size()); + if (samples.isEmpty()) { + out.put("message", "데이터 없음"); + return out; + } + int n = estimateSubgroupSize(samples); + double[] c = CONST.getOrDefault(n, CONST.get(5)); + double a2 = c[0], d3 = c[1], d4 = c[2], d2 = c[3]; + + List means = new ArrayList<>(); + List ranges = new ArrayList<>(); + for (MesSpcSample s : samples) { + if (s.getSampleMean() != null) means.add(s.getSampleMean()); + if (s.getSampleRange() != null) ranges.add(s.getSampleRange()); + } + double xbar = means.stream().mapToDouble(Double::doubleValue).average().orElse(0); + double rbar = ranges.stream().mapToDouble(Double::doubleValue).average().orElse(0); + + double xUcl = round3(xbar + a2 * rbar); + double xLcl = round3(xbar - a2 * rbar); + double rUcl = round3(d4 * rbar); + double rLcl = round3(d3 * rbar); + + out.put("subgroupSize", n); + out.put("xbar", round3(xbar)); + out.put("rbar", round3(rbar)); + out.put("xbarUcl", xUcl); + out.put("xbarLcl", xLcl); + out.put("rUcl", rUcl); + out.put("rLcl", rLcl); + out.put("means", means); + out.put("ranges", ranges); + + // 공정능력 Cp/Cpk (σ ≈ Rbar/d2) + if (lsl != null && usl != null && d2 > 0 && rbar > 0) { + double sigma = rbar / d2; + double cp = (usl - lsl) / (6 * sigma); + double cpkU = (usl - xbar) / (3 * sigma); + double cpkL = (xbar - lsl) / (3 * sigma); + double cpk = Math.min(cpkU, cpkL); + out.put("sigma", round3(sigma)); + out.put("cp", round3(cp)); + out.put("cpk", round3(cpk)); + out.put("capabilityGrade", cpk >= 1.33 ? "충분(≥1.33)" : cpk >= 1.0 ? "보통(1.0~1.33)" : "부족(<1.0)"); + } + + // AI 이상감지(Western Electric 규칙 — Ollama 폴백 동일 동작) + Map anomaly = ai.spcAnomaly(means, xUcl, xLcl, xbar); + out.put("anomaly", anomaly.get("anomaly")); + out.put("violations", anomaly.get("violations")); + return out; + } + + /** 부분군 크기 추정 — sample_values 배열 길이의 최빈치 근사(평균 길이). */ + private int estimateSubgroupSize(List samples) { + int sum = 0, cnt = 0; + for (MesSpcSample s : samples) { + String v = s.getSampleValues(); + if (v == null) continue; + int commas = 0; + for (int i = 0; i < v.length(); i++) if (v.charAt(i) == ',') commas++; + int size = commas + 1; + if (size >= 2) { sum += size; cnt++; } + } + int avg = cnt == 0 ? 5 : Math.round((float) sum / cnt); + return Math.min(10, Math.max(2, avg)); + } + + private double round3(double d) { + return Math.round(d * 1000.0) / 1000.0; + } +} diff --git a/backend/src/main/java/com/zioinfo/mes/spc/mapper/SpcMapper.java b/backend/src/main/java/com/zioinfo/mes/spc/mapper/SpcMapper.java new file mode 100644 index 0000000..17f24c3 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/spc/mapper/SpcMapper.java @@ -0,0 +1,14 @@ +package com.zioinfo.mes.spc.mapper; + +import com.zioinfo.mes.spc.MesSpcSample; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface SpcMapper { + List findByChart(@Param("chartCode") String chartCode, @Param("limit") int limit); + List distinctCharts(); + int insert(MesSpcSample s); +} diff --git a/backend/src/main/java/com/zioinfo/mes/spec/MesSpec.java b/backend/src/main/java/com/zioinfo/mes/spec/MesSpec.java new file mode 100644 index 0000000..fa05410 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/spec/MesSpec.java @@ -0,0 +1,25 @@ +package com.zioinfo.mes.spec; + +import lombok.Data; +import java.time.LocalDateTime; + +/** + * 검사기준/규격 (mes_spec) — 품목·검사유형별 규격(LSL/USL/Target/AQL) + 다중 검사항목(JSONB). + */ +@Data +public class MesSpec { + private Long id; + private String specCode; + private String itemCode; + private String inspectionType; // IQC/IPQC/OQC + private String characteristic; // 검사 특성 + private Double lsl; + private Double usl; + private Double target; + private String unit; + private Double aql; + private String items; // JSONB 문자열(다중 검사항목) + private String status; + private String createdBy; + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/com/zioinfo/mes/spec/SpecController.java b/backend/src/main/java/com/zioinfo/mes/spec/SpecController.java new file mode 100644 index 0000000..d14c5a2 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/spec/SpecController.java @@ -0,0 +1,73 @@ +package com.zioinfo.mes.spec; + +import com.zioinfo.mes.admin.AuditService; +import com.zioinfo.mes.common.ApiResponse; +import com.zioinfo.mes.common.AuthSupport; +import com.zioinfo.mes.spec.mapper.SpecMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 검사기준/규격 API (QMS 기준정보). 조회 Viewer+, 등록/수정/삭제 Manager+(기준정보). + */ +@RestController +@RequestMapping("/api/mes/specs") +@RequiredArgsConstructor +public class SpecController { + + private final SpecMapper mapper; + private final AuditService audit; + + @GetMapping + public ApiResponse> list(@RequestParam(required = false) String itemCode, + @RequestParam(required = false) String inspectionType) { + return ApiResponse.ok(mapper.findAll(itemCode, inspectionType)); + } + + @GetMapping("/{id}") + public ApiResponse get(@PathVariable Long id) { + MesSpec s = mapper.findById(id); + if (s == null) throw new RuntimeException("ERR-SPEC-404: 규격 없음"); + return ApiResponse.ok(s); + } + + @PostMapping + public ApiResponse create(@RequestBody MesSpec s, Authentication auth) { + AuthSupport.requireManager(auth); + if (s.getSpecCode() == null || s.getSpecCode().isBlank()) + throw new IllegalArgumentException("ERR-SPEC-400: specCode 필수"); + if (mapper.countByCode(s.getSpecCode(), null) > 0) + throw new RuntimeException("ERR-SPEC-409: 중복 규격코드"); + if (s.getStatus() == null) s.setStatus("ACTIVE"); + s.setCreatedBy(AuthSupport.actor(auth)); + mapper.insert(s); + audit.log("SPEC_CREATE", s.getSpecCode(), "item=" + s.getItemCode()); + return ApiResponse.ok(mapper.findById(s.getId())); + } + + @PutMapping("/{id}") + public ApiResponse update(@PathVariable Long id, @RequestBody MesSpec s, Authentication auth) { + AuthSupport.requireManager(auth); + MesSpec cur = mapper.findById(id); + if (cur == null) throw new RuntimeException("ERR-SPEC-404: 규격 없음"); + s.setId(id); + s.setSpecCode(cur.getSpecCode()); + if (s.getStatus() == null) s.setStatus(cur.getStatus()); + mapper.update(s); + audit.log("SPEC_UPDATE", cur.getSpecCode(), "id=" + id); + return ApiResponse.ok(mapper.findById(id)); + } + + @DeleteMapping("/{id}") + public ApiResponse delete(@PathVariable Long id, Authentication auth) { + AuthSupport.requireManager(auth); + MesSpec cur = mapper.findById(id); + if (cur == null) throw new RuntimeException("ERR-SPEC-404: 규격 없음"); + mapper.delete(id); + audit.log("SPEC_DELETE", cur.getSpecCode(), ""); + return ApiResponse.ok(null); + } +} diff --git a/backend/src/main/java/com/zioinfo/mes/spec/mapper/SpecMapper.java b/backend/src/main/java/com/zioinfo/mes/spec/mapper/SpecMapper.java new file mode 100644 index 0000000..12a1651 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/spec/mapper/SpecMapper.java @@ -0,0 +1,19 @@ +package com.zioinfo.mes.spec.mapper; + +import com.zioinfo.mes.spec.MesSpec; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface SpecMapper { + List findAll(@Param("itemCode") String itemCode, + @Param("inspectionType") String inspectionType); + MesSpec findById(@Param("id") Long id); + MesSpec findByCode(@Param("specCode") String specCode); + int countByCode(@Param("specCode") String specCode, @Param("excludeId") Long excludeId); + int insert(MesSpec s); + int update(MesSpec s); + int delete(@Param("id") Long id); +} diff --git a/backend/src/main/java/com/zioinfo/mes/stocktake/MesStocktake.java b/backend/src/main/java/com/zioinfo/mes/stocktake/MesStocktake.java new file mode 100644 index 0000000..6e66320 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/stocktake/MesStocktake.java @@ -0,0 +1,26 @@ +package com.zioinfo.mes.stocktake; + +import lombok.Data; +import java.time.LocalDateTime; + +/** + * 재고실사 (mes_stocktake) — 실사 카운트 vs 시스템 재고 차이 + 조정. + * + *

status: COUNTED(실사) → ADJUSTED(재고 조정 반영). + */ +@Data +public class MesStocktake { + private Long id; + private String stocktakeNo; + private String itemCode; + private String warehouseCode; + private String locationCode; + private String lotNo; + private Double systemQty; + private Double countedQty; + private Double diffQty; + private String status; // COUNTED/ADJUSTED + private String remark; + private String createdBy; + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/com/zioinfo/mes/stocktake/StocktakeController.java b/backend/src/main/java/com/zioinfo/mes/stocktake/StocktakeController.java new file mode 100644 index 0000000..6fae834 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/stocktake/StocktakeController.java @@ -0,0 +1,44 @@ +package com.zioinfo.mes.stocktake; + +import com.zioinfo.mes.common.ApiResponse; +import com.zioinfo.mes.common.AuthSupport; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 재고실사 API (WMS). 조회 Viewer+, 실사 카운트 Worker+, 차이 조정(재고보정) Manager+. + */ +@RestController +@RequestMapping("/api/mes/stocktake") +@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("/count") + public ApiResponse count(@RequestBody MesStocktake s, Authentication auth) { + return ApiResponse.ok(service.count(s, AuthSupport.actor(auth))); + } + + /** 차이 조정 — 재고 보정(Manager+). */ + @PostMapping("/{id}/adjust") + public ApiResponse adjust(@PathVariable Long id, Authentication auth) { + AuthSupport.requireManager(auth); + return ApiResponse.ok(service.adjust(id, AuthSupport.actor(auth))); + } +} diff --git a/backend/src/main/java/com/zioinfo/mes/stocktake/StocktakeService.java b/backend/src/main/java/com/zioinfo/mes/stocktake/StocktakeService.java new file mode 100644 index 0000000..99ad8a7 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/stocktake/StocktakeService.java @@ -0,0 +1,70 @@ +package com.zioinfo.mes.stocktake; + +import com.zioinfo.mes.admin.AuditService; +import com.zioinfo.mes.inventory.InventoryService; +import com.zioinfo.mes.stocktake.mapper.StocktakeMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; + +/** + * 재고실사 서비스 — 실사 카운트 기록(차이 계산) + 차이 조정(재고 보정). + * + *

count: 시스템 재고 대비 실사 수량 차이 기록(COUNTED). + * adjust: 차이를 재고에 반영({@link InventoryService#adjust} ADJUST 수불) → ADJUSTED. + */ +@Service +@RequiredArgsConstructor +public class StocktakeService { + + private final StocktakeMapper mapper; + private final InventoryService inventory; + private final AuditService audit; + + public List list(String stocktakeNo, String status) { + return mapper.findAll(stocktakeNo, status); + } + + public MesStocktake get(Long id) { + MesStocktake s = mapper.findById(id); + if (s == null) throw new RuntimeException("ERR-STK-404: 실사 없음"); + return s; + } + + /** 실사 카운트 기록 — 시스템 재고 조회 후 차이 계산. */ + @Transactional + public MesStocktake count(MesStocktake s, String actor) { + if (s.getItemCode() == null || s.getItemCode().isBlank()) + throw new IllegalArgumentException("ERR-STK-400: itemCode 필수"); + if (s.getCountedQty() == null) + throw new IllegalArgumentException("ERR-STK-400: countedQty 필수"); + double systemQty = inventory.available(s.getItemCode()); + s.setSystemQty(systemQty); + s.setDiffQty(s.getCountedQty() - systemQty); + if (s.getStocktakeNo() == null || s.getStocktakeNo().isBlank()) + s.setStocktakeNo("STK-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE)); + s.setStatus("COUNTED"); + s.setCreatedBy(actor); + mapper.insert(s); + audit.log("STOCKTAKE_COUNT", s.getStocktakeNo(), + "item=" + s.getItemCode() + " diff=" + s.getDiffQty()); + return mapper.findById(s.getId()); + } + + /** 차이 조정 — 재고를 실사 수량으로 보정. */ + @Transactional + public MesStocktake adjust(Long id, String actor) { + MesStocktake s = get(id); + if ("ADJUSTED".equals(s.getStatus())) + throw new RuntimeException("ERR-STK-409: 이미 조정 반영됨"); + inventory.adjust(s.getItemCode(), s.getWarehouseCode(), s.getLocationCode(), s.getLotNo(), + s.getCountedQty(), s.getStocktakeNo(), actor); + mapper.updateStatus(id, "ADJUSTED"); + audit.log("STOCKTAKE_ADJUST", s.getStocktakeNo(), "item=" + s.getItemCode() + " → 재고보정"); + return mapper.findById(id); + } +} diff --git a/backend/src/main/java/com/zioinfo/mes/stocktake/mapper/StocktakeMapper.java b/backend/src/main/java/com/zioinfo/mes/stocktake/mapper/StocktakeMapper.java new file mode 100644 index 0000000..03cdb3b --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/stocktake/mapper/StocktakeMapper.java @@ -0,0 +1,16 @@ +package com.zioinfo.mes.stocktake.mapper; + +import com.zioinfo.mes.stocktake.MesStocktake; +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); + MesStocktake findById(@Param("id") Long id); + int insert(MesStocktake s); + int updateStatus(@Param("id") Long id, @Param("status") String status); +} diff --git a/backend/src/main/java/com/zioinfo/mes/tracking/TrackingController.java b/backend/src/main/java/com/zioinfo/mes/tracking/TrackingController.java new file mode 100644 index 0000000..9dc1ce6 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/tracking/TrackingController.java @@ -0,0 +1,37 @@ +package com.zioinfo.mes.tracking; + +import com.zioinfo.mes.common.ApiResponse; +import com.zioinfo.mes.inventory.MesInventoryTxn; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** + * LOT 추적 API — 정/역 추적 + 이력. 조회 Viewer+. + */ +@RestController +@RequestMapping("/api/mes/tracking") +@RequiredArgsConstructor +public class TrackingController { + + private final TrackingService service; + + @GetMapping("/lot/{lotNo}/history") + public ApiResponse> history(@PathVariable String lotNo) { + return ApiResponse.ok(service.lotHistory(lotNo)); + } + + /** 역추적 — 산출 LOT 에 투입된 자재. */ + @GetMapping("/lot/{lotNo}/backward") + public ApiResponse> backward(@PathVariable String lotNo) { + return ApiResponse.ok(service.backward(lotNo)); + } + + /** 정추적 — 자재 LOT 가 사용된 작업지시·산출 LOT. */ + @GetMapping("/lot/{lotNo}/forward") + public ApiResponse> forward(@PathVariable String lotNo) { + return ApiResponse.ok(service.forward(lotNo)); + } +} diff --git a/backend/src/main/java/com/zioinfo/mes/tracking/TrackingMapper.java b/backend/src/main/java/com/zioinfo/mes/tracking/TrackingMapper.java new file mode 100644 index 0000000..62778e7 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/tracking/TrackingMapper.java @@ -0,0 +1,22 @@ +package com.zioinfo.mes.tracking; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; +import java.util.Map; + +@Mapper +public interface TrackingMapper { + /** 산출 LOT → 생산실적(작업지시/품목). */ + Map findProductionByLot(@Param("lotNo") String lotNo); + + /** 작업지시에서 투입(CONSUME)된 자재 트랜잭션. */ + List> findConsumedByWo(@Param("woNo") String woNo); + + /** 자재 LOT 가 CONSUME 된 작업지시(ref_no) 목록. */ + List> findWoConsumingLot(@Param("lotNo") String lotNo); + + /** 작업지시가 산출(PRODUCE)한 LOT 목록. */ + List> findProducedLotsByWo(@Param("woNo") String woNo); +} diff --git a/backend/src/main/java/com/zioinfo/mes/tracking/TrackingService.java b/backend/src/main/java/com/zioinfo/mes/tracking/TrackingService.java new file mode 100644 index 0000000..e0d06d4 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/tracking/TrackingService.java @@ -0,0 +1,72 @@ +package com.zioinfo.mes.tracking; + +import com.zioinfo.mes.inventory.MesInventoryTxn; +import com.zioinfo.mes.inventory.mapper.InventoryMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * LOT 추적 서비스 — 정/역 추적. + * + *

정추적(forward): 해당 LOT가 어디로 소비/출고됐는지(생산실적 산출 LOT 기준). + * 역추적(backward): 해당 LOT 산출에 어떤 자재 LOT가 투입됐는지(작업지시 CONSUME 이력). + * mes_inventory_txn + mes_production 을 그래프로 구성한다. + */ +@Service +@RequiredArgsConstructor +public class TrackingService { + + private final InventoryMapper invMapper; + private final TrackingMapper trackingMapper; + + /** LOT 이동 이력(원시 트랜잭션). */ + public List lotHistory(String lotNo) { + return invMapper.findByLot(lotNo); + } + + /** + * 역추적 — 산출 LOT 의 작업지시 → 그 작업지시에서 투입(CONSUME)된 자재 트랜잭션. + */ + public Map backward(String lotNo) { + Map result = new LinkedHashMap<>(); + result.put("lotNo", lotNo); + Map prod = trackingMapper.findProductionByLot(lotNo); + if (prod == null || prod.isEmpty()) { + result.put("found", false); + result.put("inputs", List.of()); + return result; + } + result.put("found", true); + result.put("workOrder", prod.get("wo_no")); + result.put("producedItem", prod.get("item_code")); + String woNo = String.valueOf(prod.get("wo_no")); + List> inputs = trackingMapper.findConsumedByWo(woNo); + result.put("inputs", inputs); + return result; + } + + /** + * 정추적 — 자재 LOT 가 투입된 작업지시 → 그 작업지시들이 산출한 LOT. + */ + public Map forward(String lotNo) { + Map result = new LinkedHashMap<>(); + result.put("lotNo", lotNo); + List> consumedIn = trackingMapper.findWoConsumingLot(lotNo); + List> outputs = new ArrayList<>(); + for (Map c : consumedIn) { + String woNo = String.valueOf(c.get("ref_no")); + List> producedLots = trackingMapper.findProducedLotsByWo(woNo); + Map node = new LinkedHashMap<>(); + node.put("workOrder", woNo); + node.put("producedLots", producedLots); + outputs.add(node); + } + result.put("usedIn", outputs); + return result; + } +} diff --git a/backend/src/main/java/com/zioinfo/mes/warehouse/MesLocation.java b/backend/src/main/java/com/zioinfo/mes/warehouse/MesLocation.java new file mode 100644 index 0000000..f7f3c2a --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/warehouse/MesLocation.java @@ -0,0 +1,18 @@ +package com.zioinfo.mes.warehouse; + +import lombok.Data; +import java.time.LocalDateTime; + +/** 로케이션/Bin (mes_location) — 창고 내 보관 위치. */ +@Data +public class MesLocation { + private Long id; + private String warehouseCode; + private String locationCode; // 예: A-01-03 + private String zone; + private String aisle; + private String rack; + private String bin; + private String status; + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/com/zioinfo/mes/warehouse/MesWarehouse.java b/backend/src/main/java/com/zioinfo/mes/warehouse/MesWarehouse.java new file mode 100644 index 0000000..54ae4ad --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/warehouse/MesWarehouse.java @@ -0,0 +1,17 @@ +package com.zioinfo.mes.warehouse; + +import lombok.Data; +import java.time.LocalDateTime; + +/** 창고 마스터 (mes_warehouse). */ +@Data +public class MesWarehouse { + private Long id; + private String warehouseCode; + private String warehouseName; + private String warehouseType; // RAW / WIP / FG / SHIP + private String address; + private String status; + private String createdBy; + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/com/zioinfo/mes/warehouse/WarehouseController.java b/backend/src/main/java/com/zioinfo/mes/warehouse/WarehouseController.java new file mode 100644 index 0000000..e3889b8 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/warehouse/WarehouseController.java @@ -0,0 +1,94 @@ +package com.zioinfo.mes.warehouse; + +import com.zioinfo.mes.admin.AuditService; +import com.zioinfo.mes.common.ApiResponse; +import com.zioinfo.mes.common.AuthSupport; +import com.zioinfo.mes.warehouse.mapper.WarehouseMapper; +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/mes/warehouses") +@RequiredArgsConstructor +public class WarehouseController { + + private final WarehouseMapper mapper; + private final AuditService audit; + + @GetMapping + public ApiResponse> list(@RequestParam(required = false) String status) { + return ApiResponse.ok(mapper.findAll(status)); + } + + @GetMapping("/{id}") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.ok(require(id)); + } + + @PostMapping + public ApiResponse create(@RequestBody MesWarehouse w, Authentication auth) { + AuthSupport.requireManager(auth); + if (w.getWarehouseCode() == null) throw new IllegalArgumentException("ERR-WH-400: warehouseCode 필수"); + if (mapper.countByCode(w.getWarehouseCode(), null) > 0) + throw new RuntimeException("ERR-WH-409: 중복 창고코드"); + if (w.getStatus() == null) w.setStatus("ACTIVE"); + w.setCreatedBy(AuthSupport.actor(auth)); + mapper.insert(w); + audit.log("WAREHOUSE_CREATE", w.getWarehouseCode(), ""); + return ApiResponse.ok(mapper.findById(w.getId())); + } + + @PutMapping("/{id}") + public ApiResponse update(@PathVariable Long id, @RequestBody MesWarehouse w, Authentication auth) { + AuthSupport.requireManager(auth); + require(id); + w.setId(id); + mapper.update(w); + audit.log("WAREHOUSE_UPDATE", w.getWarehouseCode(), "id=" + id); + return ApiResponse.ok(mapper.findById(id)); + } + + @DeleteMapping("/{id}") + public ApiResponse delete(@PathVariable Long id, Authentication auth) { + AuthSupport.requireManager(auth); + mapper.delete(id); + audit.log("WAREHOUSE_DELETE", String.valueOf(id), ""); + return ApiResponse.ok(null); + } + + // ── locations / bins ── + @GetMapping("/{warehouseCode}/locations") + public ApiResponse> locations(@PathVariable String warehouseCode) { + return ApiResponse.ok(mapper.findLocations(warehouseCode)); + } + + @PostMapping("/locations") + public ApiResponse addLocation(@RequestBody MesLocation l, Authentication auth) { + AuthSupport.requireManager(auth); + if (l.getWarehouseCode() == null || l.getLocationCode() == null) + throw new IllegalArgumentException("ERR-WH-400: warehouseCode/locationCode 필수"); + if (l.getStatus() == null) l.setStatus("ACTIVE"); + mapper.insertLocation(l); + audit.log("LOCATION_CREATE", l.getWarehouseCode(), "loc=" + l.getLocationCode()); + return ApiResponse.ok(l); + } + + @DeleteMapping("/locations/{id}") + public ApiResponse deleteLocation(@PathVariable Long id, Authentication auth) { + AuthSupport.requireManager(auth); + mapper.deleteLocation(id); + return ApiResponse.ok(null); + } + + private MesWarehouse require(Long id) { + MesWarehouse w = mapper.findById(id); + if (w == null) throw new RuntimeException("ERR-WH-404: 창고 없음"); + return w; + } +} diff --git a/backend/src/main/java/com/zioinfo/mes/warehouse/mapper/WarehouseMapper.java b/backend/src/main/java/com/zioinfo/mes/warehouse/mapper/WarehouseMapper.java new file mode 100644 index 0000000..2ca76be --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/warehouse/mapper/WarehouseMapper.java @@ -0,0 +1,23 @@ +package com.zioinfo.mes.warehouse.mapper; + +import com.zioinfo.mes.warehouse.MesLocation; +import com.zioinfo.mes.warehouse.MesWarehouse; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface WarehouseMapper { + List findAll(@Param("status") String status); + MesWarehouse findById(@Param("id") Long id); + int countByCode(@Param("warehouseCode") String warehouseCode, @Param("excludeId") Long excludeId); + int insert(MesWarehouse w); + int update(MesWarehouse w); + int delete(@Param("id") Long id); + + // locations + List findLocations(@Param("warehouseCode") String warehouseCode); + int insertLocation(MesLocation l); + int deleteLocation(@Param("id") Long id); +} diff --git a/backend/src/main/java/com/zioinfo/mes/workorder/MesWorkOrder.java b/backend/src/main/java/com/zioinfo/mes/workorder/MesWorkOrder.java new file mode 100644 index 0000000..cbbb789 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/workorder/MesWorkOrder.java @@ -0,0 +1,30 @@ +package com.zioinfo.mes.workorder; + +import lombok.Data; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 작업지시 (mes_workorder). + * + *

상태: CREATED → RELEASED → INPROGRESS → DONE → CLOSED (CANCELLED 가능). + */ +@Data +public class MesWorkOrder { + private Long id; + private String woNo; + private String itemCode; + private String itemName; // 조인 표시(비영속) + private Double planQty; + private Double producedQty; // 누적 산출(생산실적 합) + private Double defectQty; // 누적 불량 + private String unit; + private LocalDate dueDate; + private String warehouseCode; // 산출 입고 창고 + private String status; // CREATED/RELEASED/INPROGRESS/DONE/CLOSED/CANCELLED + private String priority; // LOW/MEDIUM/HIGH + private String remark; + private String createdBy; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/backend/src/main/java/com/zioinfo/mes/workorder/WorkOrderController.java b/backend/src/main/java/com/zioinfo/mes/workorder/WorkOrderController.java new file mode 100644 index 0000000..96c6093 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/workorder/WorkOrderController.java @@ -0,0 +1,67 @@ +package com.zioinfo.mes.workorder; + +import com.zioinfo.mes.common.ApiResponse; +import com.zioinfo.mes.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+, RELEASE 이상 전이 Manager+. + */ +@RestController +@RequestMapping("/api/mes/workorders") +@RequiredArgsConstructor +public class WorkOrderController { + + private final WorkOrderService service; + + @GetMapping + public ApiResponse> list(@RequestParam(required = false) String status, + @RequestParam(required = false) String itemCode, + @RequestParam(required = false) String keyword) { + return ApiResponse.ok(service.list(status, itemCode, keyword)); + } + + @GetMapping("/kanban") + public ApiResponse>> kanban() { + return ApiResponse.ok(service.kanbanCounts()); + } + + @GetMapping("/{id}") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.ok(service.get(id)); + } + + @PostMapping + public ApiResponse create(@RequestBody MesWorkOrder wo, Authentication auth) { + return ApiResponse.ok(service.create(wo, AuthSupport.actor(auth))); + } + + @PutMapping("/{id}") + public ApiResponse update(@PathVariable Long id, @RequestBody MesWorkOrder wo, Authentication auth) { + return ApiResponse.ok(service.update(id, wo, AuthSupport.actor(auth))); + } + + /** 상태 전이 — action: RELEASE/START/COMPLETE/CLOSE/CANCEL. RELEASE/CLOSE/CANCEL 은 Manager+. */ + @PostMapping("/{id}/transition") + public ApiResponse transition(@PathVariable Long id, @RequestBody TransitionRequest req, Authentication auth) { + String a = req.action() == null ? "" : req.action().toUpperCase(); + if (a.equals("RELEASE") || a.equals("CLOSE") || a.equals("CANCEL")) { + AuthSupport.requireManager(auth); + } + return ApiResponse.ok(service.transition(id, req.action(), AuthSupport.actor(auth))); + } + + @DeleteMapping("/{id}") + public ApiResponse delete(@PathVariable Long id, Authentication auth) { + AuthSupport.requireManager(auth); + service.delete(id); + return ApiResponse.ok(null); + } + + record TransitionRequest(String action) {} +} diff --git a/backend/src/main/java/com/zioinfo/mes/workorder/WorkOrderService.java b/backend/src/main/java/com/zioinfo/mes/workorder/WorkOrderService.java new file mode 100644 index 0000000..444cae9 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/workorder/WorkOrderService.java @@ -0,0 +1,117 @@ +package com.zioinfo.mes.workorder; + +import com.zioinfo.mes.admin.AuditService; +import com.zioinfo.mes.workorder.mapper.WorkOrderMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 작업지시 서비스 — 발번·상태 전이(CREATED→RELEASED→INPROGRESS→DONE→CLOSED)·실적 누적. + * + *

상태 전이는 허용 경로만 가능. RELEASE 이상은 Manager+ 가드(컨트롤러). + */ +@Service +@RequiredArgsConstructor +public class WorkOrderService { + + private static final Map> TRANSITIONS = Map.of( + "CREATED", Set.of("RELEASED", "CANCELLED"), + "RELEASED", Set.of("INPROGRESS", "CANCELLED"), + "INPROGRESS", Set.of("DONE"), + "DONE", Set.of("CLOSED", "INPROGRESS"), + "CLOSED", Set.of(), + "CANCELLED", Set.of() + ); + + private final WorkOrderMapper mapper; + private final AuditService audit; + + public List list(String status, String itemCode, String keyword) { + return mapper.findAll(status, itemCode, keyword); + } + + public MesWorkOrder get(Long id) { + MesWorkOrder wo = mapper.findById(id); + if (wo == null) throw new RuntimeException("ERR-WO-404: 작업지시 없음"); + return wo; + } + + public List> kanbanCounts() { + return mapper.countByStatus(); + } + + @Transactional + public MesWorkOrder create(MesWorkOrder wo, String actor) { + if (wo.getItemCode() == null || wo.getItemCode().isBlank()) + throw new IllegalArgumentException("ERR-WO-400: itemCode 필수"); + if (wo.getPlanQty() == null || wo.getPlanQty() <= 0) + throw new IllegalArgumentException("ERR-WO-400: planQty 는 0보다 커야 합니다"); + if (wo.getWoNo() == null || wo.getWoNo().isBlank()) { + wo.setWoNo(generateNo()); + } + if (mapper.countByNo(wo.getWoNo()) > 0) + throw new RuntimeException("ERR-WO-409: 중복 작업지시번호"); + wo.setStatus("CREATED"); + wo.setProducedQty(0.0); + wo.setDefectQty(0.0); + if (wo.getPriority() == null) wo.setPriority("MEDIUM"); + wo.setCreatedBy(actor); + mapper.insert(wo); + audit.log("WORKORDER_CREATE", wo.getWoNo(), "item=" + wo.getItemCode() + " qty=" + wo.getPlanQty()); + return mapper.findById(wo.getId()); + } + + @Transactional + public MesWorkOrder transition(Long id, String action, String actor) { + MesWorkOrder wo = get(id); + String from = wo.getStatus(); + String to = switch (action == null ? "" : action.toUpperCase()) { + case "RELEASE" -> "RELEASED"; + case "START" -> "INPROGRESS"; + case "COMPLETE" -> "DONE"; + case "CLOSE" -> "CLOSED"; + case "CANCEL" -> "CANCELLED"; + default -> throw new IllegalArgumentException("ERR-WO-400: 알 수 없는 action(RELEASE/START/COMPLETE/CLOSE/CANCEL)"); + }; + if (!TRANSITIONS.getOrDefault(from, Set.of()).contains(to)) { + throw new RuntimeException("ERR-WO-409: " + from + " → " + to + " 전이 불가"); + } + mapper.updateStatus(id, to); + audit.log("WORKORDER_" + action.toUpperCase(), wo.getWoNo(), from + " -> " + to); + return mapper.findById(id); + } + + public MesWorkOrder update(Long id, MesWorkOrder wo, String actor) { + MesWorkOrder cur = get(id); + if (!"CREATED".equals(cur.getStatus())) { + throw new RuntimeException("ERR-WO-409: CREATED 상태에서만 수정 가능"); + } + wo.setId(id); + wo.setWoNo(cur.getWoNo()); + wo.setStatus(cur.getStatus()); + mapper.update(wo); + audit.log("WORKORDER_UPDATE", cur.getWoNo(), "id=" + id); + return mapper.findById(id); + } + + public void delete(Long id) { + MesWorkOrder cur = get(id); + if (!Set.of("CREATED", "CANCELLED").contains(cur.getStatus())) { + throw new RuntimeException("ERR-WO-409: 진행/완료 작업지시는 삭제 불가"); + } + mapper.delete(id); + audit.log("WORKORDER_DELETE", cur.getWoNo(), ""); + } + + private String generateNo() { + return "WO-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + + "-" + (System.currentTimeMillis() % 100000); + } +} diff --git a/backend/src/main/java/com/zioinfo/mes/workorder/mapper/WorkOrderMapper.java b/backend/src/main/java/com/zioinfo/mes/workorder/mapper/WorkOrderMapper.java new file mode 100644 index 0000000..40ff334 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mes/workorder/mapper/WorkOrderMapper.java @@ -0,0 +1,26 @@ +package com.zioinfo.mes.workorder.mapper; + +import com.zioinfo.mes.workorder.MesWorkOrder; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; +import java.util.Map; + +@Mapper +public interface WorkOrderMapper { + List findAll(@Param("status") String status, + @Param("itemCode") String itemCode, + @Param("keyword") String keyword); + MesWorkOrder findById(@Param("id") Long id); + MesWorkOrder findByNo(@Param("woNo") String woNo); + int countByNo(@Param("woNo") String woNo); + int insert(MesWorkOrder wo); + int update(MesWorkOrder wo); + int updateStatus(@Param("id") Long id, @Param("status") String status); + int addProduced(@Param("id") Long id, @Param("qty") Double qty, @Param("defect") Double defect); + int delete(@Param("id") Long id); + + /** 상태별 카운트(대시보드/칸반). */ + List> countByStatus(); +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml new file mode 100644 index 0000000..6ac6c0d --- /dev/null +++ b/backend/src/main/resources/application.yml @@ -0,0 +1,55 @@ +server: + port: 8013 + +spring: + application: + name: guardia-mes + datasource: + url: ${DB_URL:jdbc:postgresql://localhost:5432/mes_db} + username: ${DB_USER:mes_user} + password: ${DB_PASS:mes_pass2026} + driver-class-name: org.postgresql.Driver + hikari: + # 운영 함정 준수: 다중 솔루션 동시 기동 시 PG max_connections 보호 — 풀 3개 캡. + maximum-pool-size: ${DB_POOL_MAX:3} + minimum-idle: 1 + sql: + init: + mode: ${SQL_INIT_MODE:never} + schema-locations: classpath:db/schema.sql + 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/mes/docs + swagger-ui: + path: /api/mes/swagger + +guardia: + erp-url: ${ERP_URL:http://localhost:8003} + itsm-url: ${ITSM_URL:http://localhost:9001} + ocr-url: ${OCR_URL:http://localhost:8005} + bi-url: ${BI_URL:http://localhost:8006} + # 보안 불변 규칙: Ollama 온프레미스만 허용. 외부 AI API 절대 금지. + ollama-url: ${OLLAMA_URL:http://localhost:11434} + ollama-text-model: ${OLLAMA_TEXT_MODEL:llama3} + ollama-vision-model: ${OLLAMA_VISION_MODEL:llava} + crypto: + secret: ${MES_CRYPTO_SECRET:guardia-mes-aes-256-gcm-master-key-2026-zioinfo} + jwt: + secret: ${JWT_SECRET:guardia-mes-jwt-secret-2026-minimum-256bit-key-zioinfo} + expiration: 86400000 + +logging: + level: + com.zioinfo.mes: DEBUG + org.mybatis: WARN diff --git a/backend/src/main/resources/db/schema.sql b/backend/src/main/resources/db/schema.sql new file mode 100644 index 0000000..77d7a4d --- /dev/null +++ b/backend/src/main/resources/db/schema.sql @@ -0,0 +1,568 @@ +-- ============================================================================ +-- GUARDiA MES — PostgreSQL 스키마 (mes_db) +-- AI 기반 제조실행시스템 (WMS + MES + QMS 통합) +-- 보안 불변: 거래처/인사 PII *_enc AES-256-GCM 저장·응답 마스킹, 스택트레이스 미노출 +-- 모든 테이블 IF NOT EXISTS / 시드 admin(SuperAdmin, bcrypt admin123) + 데모 데이터 +-- ============================================================================ + +-- ── 운영자 계정 (auth/admin) — RBAC: SUPERADMIN/MANAGER/WORKER/VIEWER ────────── +CREATE TABLE IF NOT EXISTS mes_user ( + id BIGSERIAL PRIMARY KEY, + username VARCHAR(200) UNIQUE NOT NULL, + password_hash VARCHAR(500) NOT NULL, + display_name VARCHAR(200), + role VARCHAR(50) DEFAULT 'VIEWER', + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT NOW() +); +-- 기본 관리자 (비밀번호: admin123 / BCrypt $2a). 운영 배포 시 즉시 변경. +INSERT INTO mes_user (username, password_hash, display_name, role) +VALUES ('admin', '$2a$10$Uo4GFiJ2TZy9KlGRALfDt.gcE0e7..57rXBRaEv1mJG2XqANG3OI2', '최고관리자', 'SUPERADMIN') +ON CONFLICT (username) DO NOTHING; +INSERT INTO mes_user (username, password_hash, display_name, role) +VALUES ('manager', '$2a$10$Uo4GFiJ2TZy9KlGRALfDt.gcE0e7..57rXBRaEv1mJG2XqANG3OI2', '생산관리자', 'MANAGER') +ON CONFLICT (username) DO NOTHING; +INSERT INTO mes_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 mes_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_mes_audit_action ON mes_audit_log(action); +CREATE INDEX IF NOT EXISTS idx_mes_audit_actor ON mes_audit_log(actor); + +CREATE TABLE IF NOT EXISTS mes_setting ( + key VARCHAR(200) PRIMARY KEY, + value TEXT, + updated_at TIMESTAMP DEFAULT NOW() +); +INSERT INTO mes_setting (key, value) VALUES +('ollama_text_model', 'llama3'), +('ollama_vision_model', 'llava'), +('default_warehouse', 'WH-MAIN'), +('oee_target', '0.85'), +('itsm_equipment_fault_to_sr', 'true') +ON CONFLICT (key) DO NOTHING; + +-- ============================================================================ +-- 기준정보 (master) +-- ============================================================================ +CREATE TABLE IF NOT EXISTS mes_item ( + id BIGSERIAL PRIMARY KEY, + item_code VARCHAR(100) UNIQUE NOT NULL, + item_name VARCHAR(300), + item_type VARCHAR(50) DEFAULT 'PRODUCT', -- RAW(자재)/SEMI(반제품)/PRODUCT(제품)/SUBSIDIARY(부자재) + unit VARCHAR(30) DEFAULT 'EA', + barcode VARCHAR(200), + spec VARCHAR(500), + safety_stock DOUBLE PRECISION DEFAULT 0, + warehouse_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_mes_item_type ON mes_item(item_type); + +CREATE TABLE IF NOT EXISTS mes_bom ( + id BIGSERIAL PRIMARY KEY, + parent_item_code VARCHAR(100) NOT NULL, + child_item_code VARCHAR(100) NOT NULL, + qty_per DOUBLE PRECISION DEFAULT 1, + unit VARCHAR(30) DEFAULT 'EA', + seq INTEGER DEFAULT 1, + bom_version VARCHAR(50) DEFAULT 'V1', + loss_rate DOUBLE PRECISION DEFAULT 0, + status VARCHAR(30) DEFAULT 'ACTIVE', + created_by VARCHAR(200), + created_at TIMESTAMP DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_mes_bom_parent ON mes_bom(parent_item_code); + +CREATE TABLE IF NOT EXISTS mes_routing ( + id BIGSERIAL PRIMARY KEY, + item_code VARCHAR(100) NOT NULL, + op_seq INTEGER DEFAULT 10, + process_code VARCHAR(100), + process_name VARCHAR(200), + equipment_code VARCHAR(100), + std_time_min DOUBLE PRECISION DEFAULT 0, + setup_time_min DOUBLE PRECISION DEFAULT 0, + routing_version VARCHAR(50) DEFAULT 'V1', + status VARCHAR(30) DEFAULT 'ACTIVE', + created_by VARCHAR(200), + created_at TIMESTAMP DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_mes_routing_item ON mes_routing(item_code); + +CREATE TABLE IF NOT EXISTS mes_equipment ( + id BIGSERIAL PRIMARY KEY, + equipment_code VARCHAR(100) UNIQUE NOT NULL, + equipment_name VARCHAR(300), + process_code VARCHAR(100), + warehouse_code VARCHAR(100), + check_cycle_days INTEGER DEFAULT 30, + last_checked_at TIMESTAMP, + parameters JSONB, + run_status VARCHAR(30) DEFAULT 'IDLE', -- RUN/IDLE/DOWN/MAINT + status VARCHAR(30) DEFAULT 'ACTIVE', + created_by VARCHAR(200), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS mes_partner ( + id BIGSERIAL PRIMARY KEY, + partner_code VARCHAR(100) UNIQUE NOT NULL, + partner_name VARCHAR(300), + partner_type VARCHAR(50) DEFAULT 'SUPPLIER', -- SUPPLIER/CUSTOMER/BOTH + biz_no VARCHAR(50), + contact_name VARCHAR(200), + contact_phone_enc VARCHAR(500), -- AES-256-GCM 암호화 + contact_email_enc VARCHAR(500), -- AES-256-GCM 암호화 + address VARCHAR(500), + status VARCHAR(30) DEFAULT 'ACTIVE', + created_by VARCHAR(200), + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS mes_warehouse ( + id BIGSERIAL PRIMARY KEY, + warehouse_code VARCHAR(100) UNIQUE NOT NULL, + warehouse_name VARCHAR(300), + warehouse_type VARCHAR(50) DEFAULT 'GENERAL', -- RAW/WIP/FG/GENERAL + address VARCHAR(500), + status VARCHAR(30) DEFAULT 'ACTIVE', + created_by VARCHAR(200), + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS mes_location ( + id BIGSERIAL PRIMARY KEY, + warehouse_code VARCHAR(100) NOT NULL, + location_code VARCHAR(100) NOT NULL, + zone VARCHAR(50), + aisle VARCHAR(50), + rack VARCHAR(50), + bin VARCHAR(50), + status VARCHAR(30) DEFAULT 'ACTIVE', + created_at TIMESTAMP DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_mes_location_wh ON mes_location(warehouse_code); + +-- ============================================================================ +-- MES (제조실행) +-- ============================================================================ +CREATE TABLE IF NOT EXISTS mes_workorder ( + id BIGSERIAL PRIMARY KEY, + wo_no VARCHAR(100) UNIQUE NOT NULL, + item_code VARCHAR(100) NOT NULL, + plan_qty DOUBLE PRECISION NOT NULL, + produced_qty DOUBLE PRECISION DEFAULT 0, + defect_qty DOUBLE PRECISION DEFAULT 0, + unit VARCHAR(30) DEFAULT 'EA', + due_date DATE, + warehouse_code VARCHAR(100), + status VARCHAR(30) DEFAULT 'CREATED', -- CREATED/RELEASED/INPROGRESS/DONE/CLOSED/CANCELLED + priority VARCHAR(20) DEFAULT 'MEDIUM', + remark TEXT, + created_by VARCHAR(200), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_mes_wo_status ON mes_workorder(status); + +-- 작업관리(job) — 작업/작업자/설비 배정·할당·이력 ★사용자 강조 +CREATE TABLE IF NOT EXISTS mes_job ( + id BIGSERIAL PRIMARY KEY, + job_no VARCHAR(100) UNIQUE NOT NULL, + workorder_id BIGINT, + wo_no VARCHAR(100), + item_code VARCHAR(100), + op_seq INTEGER, + process_code VARCHAR(100), + process_name VARCHAR(200), + equipment_code VARCHAR(100), + worker_id VARCHAR(100), -- 배정 작업자 + assigned_qty DOUBLE PRECISION DEFAULT 0, + done_qty DOUBLE PRECISION DEFAULT 0, + status VARCHAR(30) DEFAULT 'ASSIGNED', -- ASSIGNED/STARTED/PAUSED/DONE/CANCELLED + planned_start TIMESTAMP, + planned_end TIMESTAMP, + started_at TIMESTAMP, + ended_at TIMESTAMP, + remark TEXT, + created_by VARCHAR(200), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_mes_job_wo ON mes_job(workorder_id); +CREATE INDEX IF NOT EXISTS idx_mes_job_worker ON mes_job(worker_id); +CREATE INDEX IF NOT EXISTS idx_mes_job_status ON mes_job(status); + +CREATE TABLE IF NOT EXISTS mes_production ( + id BIGSERIAL PRIMARY KEY, + workorder_id BIGINT, + wo_no VARCHAR(100), + item_code VARCHAR(100), + lot_no VARCHAR(100), + good_qty DOUBLE PRECISION DEFAULT 0, + defect_qty DOUBLE PRECISION DEFAULT 0, + defect_code VARCHAR(100), + process_code VARCHAR(100), + equipment_code VARCHAR(100), + worker_id VARCHAR(100), + warehouse_code VARCHAR(100), + reported_at TIMESTAMP DEFAULT NOW(), + created_by VARCHAR(200) +); +CREATE INDEX IF NOT EXISTS idx_mes_prod_wo ON mes_production(wo_no); +CREATE INDEX IF NOT EXISTS idx_mes_prod_lot ON mes_production(lot_no); + +CREATE TABLE IF NOT EXISTS mes_process_progress ( + id BIGSERIAL PRIMARY KEY, + workorder_id BIGINT, + wo_no VARCHAR(100), + op_seq INTEGER, + process_code VARCHAR(100), + process_name VARCHAR(200), + equipment_code VARCHAR(100), + worker_id VARCHAR(100), + status VARCHAR(30) DEFAULT 'WAIT', -- WAIT/RUNNING/DONE + input_qty DOUBLE PRECISION DEFAULT 0, + output_qty DOUBLE PRECISION DEFAULT 0, + started_at TIMESTAMP, + ended_at TIMESTAMP, + created_at TIMESTAMP DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_mes_proc_wo ON mes_process_progress(workorder_id); + +CREATE TABLE IF NOT EXISTS mes_oee ( + id BIGSERIAL PRIMARY KEY, + equipment_code VARCHAR(100) NOT NULL, + record_date DATE DEFAULT CURRENT_DATE, + planned_time_min DOUBLE PRECISION DEFAULT 0, + run_time_min DOUBLE PRECISION DEFAULT 0, + downtime_min DOUBLE PRECISION DEFAULT 0, + downtime_code VARCHAR(100), + ideal_cycle_min DOUBLE PRECISION DEFAULT 0, + total_count DOUBLE PRECISION DEFAULT 0, + good_count DOUBLE PRECISION DEFAULT 0, + created_by VARCHAR(200), + created_at TIMESTAMP DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_mes_oee_eqp ON mes_oee(equipment_code, record_date); + +CREATE TABLE IF NOT EXISTS mes_plan ( + id BIGSERIAL PRIMARY KEY, + plan_no VARCHAR(100) UNIQUE NOT NULL, + item_code VARCHAR(100), + plan_qty DOUBLE PRECISION DEFAULT 0, + start_date DATE, + end_date DATE, + status VARCHAR(30) DEFAULT 'PLANNED', -- PLANNED/CONFIRMED/RELEASED/DONE + remark TEXT, + created_by VARCHAR(200), + created_at TIMESTAMP DEFAULT NOW() +); + +-- ============================================================================ +-- WMS (창고관리) +-- ============================================================================ +CREATE TABLE IF NOT EXISTS mes_inventory ( + id BIGSERIAL PRIMARY KEY, + item_code VARCHAR(100) NOT NULL, + warehouse_code VARCHAR(100), + location_code VARCHAR(100), + lot_no VARCHAR(100), + qty DOUBLE PRECISION DEFAULT 0, + unit VARCHAR(30) DEFAULT 'EA', + updated_at TIMESTAMP DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_mes_inv_item ON mes_inventory(item_code); +CREATE INDEX IF NOT EXISTS idx_mes_inv_wh ON mes_inventory(warehouse_code); + +CREATE TABLE IF NOT EXISTS mes_inventory_txn ( + id BIGSERIAL PRIMARY KEY, + txn_type VARCHAR(30) NOT NULL, -- RECEIVE/ISSUE/PRODUCE/CONSUME/TRANSFER/ADJUST/SHIP + item_code VARCHAR(100), + from_warehouse VARCHAR(100), + from_location VARCHAR(100), + to_warehouse VARCHAR(100), + to_location VARCHAR(100), + lot_no VARCHAR(100), + qty DOUBLE PRECISION DEFAULT 0, + ref_type VARCHAR(50), -- WORKORDER/RECEIVING/SHIPPING/STOCKTAKE/TRANSFER + ref_no VARCHAR(100), + created_by VARCHAR(200), + created_at TIMESTAMP DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_mes_txn_item ON mes_inventory_txn(item_code); +CREATE INDEX IF NOT EXISTS idx_mes_txn_ref ON mes_inventory_txn(ref_type, ref_no); +CREATE INDEX IF NOT EXISTS idx_mes_txn_lot ON mes_inventory_txn(lot_no); + +CREATE TABLE IF NOT EXISTS mes_receiving ( + id BIGSERIAL PRIMARY KEY, + receiving_no VARCHAR(100) UNIQUE NOT NULL, + partner_code VARCHAR(100), + item_code VARCHAR(100), + lot_no VARCHAR(100), + qty DOUBLE PRECISION DEFAULT 0, + warehouse_code VARCHAR(100), + location_code VARCHAR(100), + status VARCHAR(30) DEFAULT 'REQUESTED', -- REQUESTED/INSPECTING/ACCEPTED/REJECTED + inspection_required BOOLEAN DEFAULT FALSE, + inspection_result VARCHAR(20), -- PASS/FAIL + remark TEXT, + created_by VARCHAR(200), + created_at TIMESTAMP DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_mes_recv_status ON mes_receiving(status); + +-- 출하관리(shipping) — 출하지시·피킹/패킹·출하검사·출하실적·납품서/거래명세서 ★사용자 강조 +CREATE TABLE IF NOT EXISTS mes_shipping ( + id BIGSERIAL PRIMARY KEY, + shipping_no VARCHAR(100) UNIQUE NOT NULL, + partner_code VARCHAR(100), -- 고객 + order_ref VARCHAR(100), -- 주문/수주 참조 + item_code VARCHAR(100), + lot_no VARCHAR(100), + plan_qty DOUBLE PRECISION DEFAULT 0, + picked_qty DOUBLE PRECISION DEFAULT 0, + packed_qty DOUBLE PRECISION DEFAULT 0, + shipped_qty DOUBLE PRECISION DEFAULT 0, + warehouse_code VARCHAR(100), + location_code VARCHAR(100), + status VARCHAR(30) DEFAULT 'ORDERED', -- ORDERED/PICKING/PACKING/INSPECTED/SHIPPED/CANCELLED + inspection_required BOOLEAN DEFAULT FALSE, + inspection_result VARCHAR(20), -- PASS/FAIL + delivery_no VARCHAR(100), -- 납품서 번호 + statement_no VARCHAR(100), -- 거래명세서 번호 + ship_date DATE, + remark TEXT, + created_by VARCHAR(200), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_mes_ship_status ON mes_shipping(status); +CREATE INDEX IF NOT EXISTS idx_mes_ship_partner ON mes_shipping(partner_code); + +CREATE TABLE IF NOT EXISTS mes_stocktake ( + id BIGSERIAL PRIMARY KEY, + stocktake_no VARCHAR(100) NOT NULL, + item_code VARCHAR(100), + warehouse_code VARCHAR(100), + location_code VARCHAR(100), + lot_no VARCHAR(100), + system_qty DOUBLE PRECISION DEFAULT 0, + counted_qty DOUBLE PRECISION DEFAULT 0, + diff_qty DOUBLE PRECISION DEFAULT 0, + status VARCHAR(30) DEFAULT 'COUNTED', -- COUNTED/ADJUSTED + remark TEXT, + created_by VARCHAR(200), + created_at TIMESTAMP DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_mes_stk_no ON mes_stocktake(stocktake_no); + +CREATE TABLE IF NOT EXISTS mes_lotserial ( + id BIGSERIAL PRIMARY KEY, + lot_no VARCHAR(100) NOT NULL, + serial_no VARCHAR(150), + item_code VARCHAR(100), + qty DOUBLE PRECISION DEFAULT 0, + source_type VARCHAR(50), -- PRODUCTION/RECEIVING + source_ref VARCHAR(100), + mfg_date DATE, + expiry_date DATE, + status VARCHAR(30) DEFAULT 'ACTIVE', -- ACTIVE/CONSUMED/SHIPPED/EXPIRED + created_by VARCHAR(200), + created_at TIMESTAMP DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_mes_lot_no ON mes_lotserial(lot_no); +CREATE INDEX IF NOT EXISTS idx_mes_lot_item ON mes_lotserial(item_code); + +-- ============================================================================ +-- QMS (품질관리) +-- ============================================================================ +CREATE TABLE IF NOT EXISTS mes_inspection ( + id BIGSERIAL PRIMARY KEY, + inspection_no VARCHAR(100) UNIQUE NOT NULL, + inspection_type VARCHAR(20) DEFAULT 'IQC', -- IQC(수입)/IPQC(공정)/OQC(출하) + item_code VARCHAR(100), + lot_no VARCHAR(100), + ref_type VARCHAR(50), -- RECEIVING/WORKORDER/SHIPPING + ref_no VARCHAR(100), + spec_code VARCHAR(100), + sample_size INTEGER DEFAULT 1, + measurements JSONB, -- [{item, measured, lsl, usl, judge}] 가변구조 + result VARCHAR(20) DEFAULT 'PENDING', -- PENDING/PASS/FAIL + defect_qty DOUBLE PRECISION DEFAULT 0, + inspector VARCHAR(100), + remark TEXT, + created_by VARCHAR(200), + created_at TIMESTAMP DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_mes_insp_type ON mes_inspection(inspection_type); +CREATE INDEX IF NOT EXISTS idx_mes_insp_result ON mes_inspection(result); + +CREATE TABLE IF NOT EXISTS mes_spec ( + id BIGSERIAL PRIMARY KEY, + spec_code VARCHAR(100) UNIQUE NOT NULL, + item_code VARCHAR(100), + inspection_type VARCHAR(20) DEFAULT 'IQC', + characteristic VARCHAR(200), -- 검사 특성(치수/외관 등) + lsl DOUBLE PRECISION, + usl DOUBLE PRECISION, + target DOUBLE PRECISION, + unit VARCHAR(30), + aql DOUBLE PRECISION, -- 합격품질수준 + items JSONB, -- 다중 검사항목 정의 + status VARCHAR(30) DEFAULT 'ACTIVE', + created_by VARCHAR(200), + created_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS mes_ncr ( + id BIGSERIAL PRIMARY KEY, + ncr_no VARCHAR(100) UNIQUE NOT NULL, + item_code VARCHAR(100), + lot_no VARCHAR(100), + ref_type VARCHAR(50), + ref_no VARCHAR(100), + defect_code VARCHAR(100), + defect_qty DOUBLE PRECISION DEFAULT 0, + description TEXT, + disposition VARCHAR(50), -- USE_AS_IS/REWORK/SCRAP/RETURN + severity VARCHAR(20) DEFAULT 'MINOR', -- MINOR/MAJOR/CRITICAL + status VARCHAR(30) DEFAULT 'OPEN', -- OPEN/REVIEW/DISPOSITIONED/CLOSED + capa_id BIGINT, + created_by VARCHAR(200), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_mes_ncr_status ON mes_ncr(status); + +CREATE TABLE IF NOT EXISTS mes_capa ( + id BIGSERIAL PRIMARY KEY, + capa_no VARCHAR(100) UNIQUE NOT NULL, + ncr_no VARCHAR(100), + capa_type VARCHAR(20) DEFAULT 'CORRECTIVE', -- CORRECTIVE/PREVENTIVE + title VARCHAR(300), + root_cause TEXT, + action_plan TEXT, -- 8D/시정조치 계획 + owner VARCHAR(100), + due_date DATE, + effectiveness VARCHAR(50), -- 효과성 검증 결과 + status VARCHAR(30) DEFAULT 'OPEN', -- OPEN/INPROGRESS/VERIFY/CLOSED + created_by VARCHAR(200), + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_mes_capa_status ON mes_capa(status); + +CREATE TABLE IF NOT EXISTS mes_spc_sample ( + id BIGSERIAL PRIMARY KEY, + chart_code VARCHAR(100) NOT NULL, -- 관리도 식별(품목+특성) + item_code VARCHAR(100), + characteristic VARCHAR(200), + subgroup_no INTEGER, + sample_values JSONB, -- 부분군 측정값 배열 + sample_mean DOUBLE PRECISION, + sample_range DOUBLE PRECISION, + measured_at TIMESTAMP DEFAULT NOW(), + created_by VARCHAR(200), + created_at TIMESTAMP DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_mes_spc_chart ON mes_spc_sample(chart_code); + +CREATE TABLE IF NOT EXISTS mes_certificate ( + id BIGSERIAL PRIMARY KEY, + certificate_no VARCHAR(100) UNIQUE NOT NULL, + cert_type VARCHAR(30) DEFAULT 'COA', -- COA(성적서)/MSDS/TEST_REPORT + item_code VARCHAR(100), + lot_no VARCHAR(100), + partner_code VARCHAR(100), + ref_type VARCHAR(50), + ref_no VARCHAR(100), + issued_date DATE DEFAULT CURRENT_DATE, + content JSONB, -- 시험 항목/결과 + file_path VARCHAR(500), + status VARCHAR(30) DEFAULT 'ISSUED', + created_by VARCHAR(200), + created_at TIMESTAMP DEFAULT NOW() +); + +-- ============================================================================ +-- 데모 데이터 (시연·테스트용 — ON CONFLICT 무시) +-- ============================================================================ +INSERT INTO mes_warehouse (warehouse_code, warehouse_name, warehouse_type, created_by) VALUES +('WH-MAIN', '메인창고', 'GENERAL', 'system'), +('WH-RAW', '자재창고', 'RAW', 'system'), +('WH-FG', '완제품창고', 'FG', 'system') +ON CONFLICT (warehouse_code) DO NOTHING; + +INSERT INTO mes_location (warehouse_code, location_code, zone, aisle, rack, bin) VALUES +('WH-MAIN', 'A-01-01', 'A', '01', '01', '01'), +('WH-RAW', 'R-01-01', 'R', '01', '01', '01'), +('WH-FG', 'F-01-01', 'F', '01', '01', '01') +ON CONFLICT DO NOTHING; + +INSERT INTO mes_partner (partner_code, partner_name, partner_type, biz_no, contact_name, created_by) VALUES +('SUP-001', '대한정밀소재', 'SUPPLIER', '123-45-67890', '김자재', 'system'), +('CUS-001', '현대모비스', 'CUSTOMER', '220-81-00001', '이고객', 'system') +ON CONFLICT (partner_code) DO NOTHING; + +INSERT INTO mes_item (item_code, item_name, item_type, unit, safety_stock, warehouse_code, created_by) VALUES +('FG-1001', '완제품 컨트롤러 모듈', 'PRODUCT', 'EA', 50, 'WH-FG', 'system'), +('SEMI-2001', '반제품 PCB 어셈블리', 'SEMI', 'EA', 100, 'WH-MAIN', 'system'), +('RAW-3001', '원자재 알루미늄 하우징', 'RAW', 'EA', 200, 'WH-RAW', 'system'), +('RAW-3002', '원자재 커넥터', 'RAW', 'EA', 500, 'WH-RAW', 'system') +ON CONFLICT (item_code) DO NOTHING; + +INSERT INTO mes_bom (parent_item_code, child_item_code, qty_per, unit, seq, created_by) VALUES +('FG-1001', 'SEMI-2001', 1, 'EA', 1, 'system'), +('FG-1001', 'RAW-3001', 1, 'EA', 2, 'system'), +('SEMI-2001', 'RAW-3002', 4, 'EA', 1, 'system') +ON CONFLICT DO NOTHING; + +INSERT INTO mes_routing (item_code, op_seq, process_code, process_name, equipment_code, std_time_min, created_by) VALUES +('FG-1001', 10, 'P-ASSY', '조립', 'EQ-ASSY-01', 5.0, 'system'), +('FG-1001', 20, 'P-TEST', '검사', 'EQ-TEST-01', 2.0, 'system') +ON CONFLICT DO NOTHING; + +INSERT INTO mes_equipment (equipment_code, equipment_name, process_code, warehouse_code, check_cycle_days, run_status, created_by) VALUES +('EQ-ASSY-01', '조립설비 1호기', 'P-ASSY', 'WH-MAIN', 30, 'IDLE', 'system'), +('EQ-TEST-01', '검사설비 1호기', 'P-TEST', 'WH-MAIN', 60, 'IDLE', 'system') +ON CONFLICT (equipment_code) DO NOTHING; + +INSERT INTO mes_inventory (item_code, warehouse_code, location_code, lot_no, qty, unit) VALUES +('RAW-3001', 'WH-RAW', 'R-01-01', 'LOT-RAW3001-001', 150, 'EA'), +('RAW-3002', 'WH-RAW', 'R-01-01', 'LOT-RAW3002-001', 600, 'EA'), +('SEMI-2001', 'WH-MAIN', 'A-01-01', 'LOT-SEMI2001-001', 80, 'EA') +ON CONFLICT DO NOTHING; + +INSERT INTO mes_workorder (wo_no, item_code, plan_qty, unit, due_date, warehouse_code, status, priority, created_by) VALUES +('WO-DEMO-0001', 'FG-1001', 100, 'EA', CURRENT_DATE + 7, 'WH-FG', 'RELEASED', 'HIGH', 'system'), +('WO-DEMO-0002', 'SEMI-2001', 200, 'EA', CURRENT_DATE + 3, 'WH-MAIN', 'CREATED', 'MEDIUM', 'system') +ON CONFLICT (wo_no) DO NOTHING; + +INSERT INTO mes_job (job_no, workorder_id, wo_no, item_code, op_seq, process_code, process_name, equipment_code, worker_id, assigned_qty, status, created_by) +SELECT 'JOB-DEMO-0001', wo.id, 'WO-DEMO-0001', 'FG-1001', 10, 'P-ASSY', '조립', 'EQ-ASSY-01', 'worker', 100, 'ASSIGNED', 'system' +FROM mes_workorder wo WHERE wo.wo_no = 'WO-DEMO-0001' +ON CONFLICT (job_no) DO NOTHING; + +INSERT INTO mes_spec (spec_code, item_code, inspection_type, characteristic, lsl, usl, target, unit, aql, created_by) VALUES +('SPEC-FG1001-DIM', 'FG-1001', 'OQC', '외형 치수', 9.8, 10.2, 10.0, 'mm', 1.0, 'system'), +('SPEC-RAW3001-DIM', 'RAW-3001', 'IQC', '하우징 두께', 1.9, 2.1, 2.0, 'mm', 1.5, 'system') +ON CONFLICT (spec_code) DO NOTHING; + +INSERT INTO mes_inspection (inspection_no, inspection_type, item_code, lot_no, spec_code, sample_size, result, inspector, created_by) VALUES +('INS-DEMO-0001', 'IQC', 'RAW-3001', 'LOT-RAW3001-001', 'SPEC-RAW3001-DIM', 5, 'PASS', 'worker', 'system') +ON CONFLICT (inspection_no) DO NOTHING; diff --git a/backend/src/main/resources/mapper/AdminUserMapper.xml b/backend/src/main/resources/mapper/AdminUserMapper.xml new file mode 100644 index 0000000..0f363f1 --- /dev/null +++ b/backend/src/main/resources/mapper/AdminUserMapper.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + INSERT INTO mes_user (username, password_hash, display_name, role, is_active) + VALUES (#{username}, #{passwordHash}, #{displayName}, #{role}, #{active}) + + + + UPDATE mes_user SET role = #{role} WHERE id = #{id} + + + + UPDATE mes_user SET is_active = #{active} WHERE id = #{id} + + + + UPDATE mes_user SET password_hash = #{passwordHash} WHERE id = #{id} + + + + DELETE FROM mes_user WHERE id = #{id} + + + + + + + diff --git a/backend/src/main/resources/mapper/AnalyticsMapper.xml b/backend/src/main/resources/mapper/AnalyticsMapper.xml new file mode 100644 index 0000000..a244b25 --- /dev/null +++ b/backend/src/main/resources/mapper/AnalyticsMapper.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + diff --git a/backend/src/main/resources/mapper/AuditLogMapper.xml b/backend/src/main/resources/mapper/AuditLogMapper.xml new file mode 100644 index 0000000..ed7885e --- /dev/null +++ b/backend/src/main/resources/mapper/AuditLogMapper.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + INSERT INTO mes_audit_log (actor, action, target, detail) + VALUES (#{actor}, #{action}, #{target}, #{detail}) + + + + + diff --git a/backend/src/main/resources/mapper/BomMapper.xml b/backend/src/main/resources/mapper/BomMapper.xml new file mode 100644 index 0000000..934b8f5 --- /dev/null +++ b/backend/src/main/resources/mapper/BomMapper.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO mes_bom (parent_item_code, child_item_code, qty_per, unit, seq, bom_version, loss_rate, status, created_by) + VALUES (#{parentItemCode}, #{childItemCode}, #{qtyPer}, #{unit}, #{seq}, #{bomVersion}, #{lossRate}, #{status}, #{createdBy}) + + + + UPDATE mes_bom SET parent_item_code = #{parentItemCode}, child_item_code = #{childItemCode}, + qty_per = #{qtyPer}, unit = #{unit}, seq = #{seq}, bom_version = #{bomVersion}, + loss_rate = #{lossRate}, status = #{status} + WHERE id = #{id} + + + DELETE FROM mes_bom WHERE id = #{id} + + diff --git a/backend/src/main/resources/mapper/CapaMapper.xml b/backend/src/main/resources/mapper/CapaMapper.xml new file mode 100644 index 0000000..40d45c2 --- /dev/null +++ b/backend/src/main/resources/mapper/CapaMapper.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO mes_capa (capa_no, ncr_no, capa_type, title, root_cause, action_plan, owner, + due_date, effectiveness, status, created_by) + VALUES (#{capaNo}, #{ncrNo}, #{capaType}, #{title}, #{rootCause}, #{actionPlan}, #{owner}, + #{dueDate}, #{effectiveness}, #{status}, #{createdBy}) + + + + UPDATE mes_capa SET capa_type = #{capaType}, title = #{title}, root_cause = #{rootCause}, + action_plan = #{actionPlan}, owner = #{owner}, due_date = #{dueDate}, updated_at = NOW() + WHERE id = #{id} + + + + UPDATE mes_capa SET status = #{status}, + effectiveness = COALESCE(#{effectiveness}, effectiveness), updated_at = NOW() + WHERE id = #{id} + + + diff --git a/backend/src/main/resources/mapper/CertificateMapper.xml b/backend/src/main/resources/mapper/CertificateMapper.xml new file mode 100644 index 0000000..9e59a4b --- /dev/null +++ b/backend/src/main/resources/mapper/CertificateMapper.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO mes_certificate (certificate_no, cert_type, item_code, lot_no, partner_code, + ref_type, ref_no, issued_date, content, file_path, status, created_by) + VALUES (#{certificateNo}, #{certType}, #{itemCode}, #{lotNo}, #{partnerCode}, + #{refType}, #{refNo}, #{issuedDate}, #{content}::jsonb, #{filePath}, #{status}, #{createdBy}) + + + diff --git a/backend/src/main/resources/mapper/DashboardMapper.xml b/backend/src/main/resources/mapper/DashboardMapper.xml new file mode 100644 index 0000000..4fbe649 --- /dev/null +++ b/backend/src/main/resources/mapper/DashboardMapper.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + diff --git a/backend/src/main/resources/mapper/EquipmentMapper.xml b/backend/src/main/resources/mapper/EquipmentMapper.xml new file mode 100644 index 0000000..5d06147 --- /dev/null +++ b/backend/src/main/resources/mapper/EquipmentMapper.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO mes_equipment (equipment_code, equipment_name, process_code, warehouse_code, + check_cycle_days, last_checked_at, parameters, run_status, status, created_by) + VALUES (#{equipmentCode}, #{equipmentName}, #{processCode}, #{warehouseCode}, + #{checkCycleDays}, #{lastCheckedAt}, #{parameters}::jsonb, #{runStatus}, #{status}, #{createdBy}) + + + + UPDATE mes_equipment SET equipment_code = #{equipmentCode}, equipment_name = #{equipmentName}, + process_code = #{processCode}, warehouse_code = #{warehouseCode}, check_cycle_days = #{checkCycleDays}, + last_checked_at = #{lastCheckedAt}, parameters = #{parameters}::jsonb, run_status = #{runStatus}, + status = #{status}, updated_at = NOW() + WHERE id = #{id} + + + + UPDATE mes_equipment SET run_status = #{runStatus}, updated_at = NOW() WHERE id = #{id} + + + DELETE FROM mes_equipment WHERE id = #{id} + + diff --git a/backend/src/main/resources/mapper/InspectionMapper.xml b/backend/src/main/resources/mapper/InspectionMapper.xml new file mode 100644 index 0000000..e7a960c --- /dev/null +++ b/backend/src/main/resources/mapper/InspectionMapper.xml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO mes_inspection (inspection_no, inspection_type, item_code, lot_no, ref_type, ref_no, + spec_code, sample_size, measurements, result, defect_qty, inspector, remark, created_by) + VALUES (#{inspectionNo}, #{inspectionType}, #{itemCode}, #{lotNo}, #{refType}, #{refNo}, + #{specCode}, #{sampleSize}, #{measurements}::jsonb, #{result}, #{defectQty}, #{inspector}, #{remark}, #{createdBy}) + + + + UPDATE mes_inspection + SET result = #{result}, + measurements = COALESCE(#{measurements}::jsonb, measurements), + defect_qty = COALESCE(#{defectQty}, defect_qty) + 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..11606ec --- /dev/null +++ b/backend/src/main/resources/mapper/InventoryMapper.xml @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO mes_inventory (item_code, warehouse_code, location_code, lot_no, qty, unit, updated_at) + VALUES (#{itemCode}, #{warehouseCode}, #{locationCode}, #{lotNo}, #{qty}, #{unit}, NOW()) + + + + UPDATE mes_inventory SET qty = #{qty}, updated_at = NOW() WHERE id = #{id} + + + + INSERT INTO mes_inventory_txn (txn_type, item_code, from_warehouse, from_location, + to_warehouse, to_location, lot_no, qty, ref_type, ref_no, created_by) + VALUES (#{txnType}, #{itemCode}, #{fromWarehouse}, #{fromLocation}, #{toWarehouse}, + #{toLocation}, #{lotNo}, #{qty}, #{refType}, #{refNo}, #{createdBy}) + + + + + + + + + diff --git a/backend/src/main/resources/mapper/ItemMapper.xml b/backend/src/main/resources/mapper/ItemMapper.xml new file mode 100644 index 0000000..1b5bc00 --- /dev/null +++ b/backend/src/main/resources/mapper/ItemMapper.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO mes_item (item_code, item_name, item_type, unit, barcode, spec, safety_stock, + warehouse_code, attributes, status, created_by) + VALUES (#{itemCode}, #{itemName}, #{itemType}, #{unit}, #{barcode}, #{spec}, #{safetyStock}, + #{warehouseCode}, #{attributes}::jsonb, #{status}, #{createdBy}) + + + + UPDATE mes_item SET item_code = #{itemCode}, item_name = #{itemName}, item_type = #{itemType}, + unit = #{unit}, barcode = #{barcode}, spec = #{spec}, safety_stock = #{safetyStock}, + warehouse_code = #{warehouseCode}, attributes = #{attributes}::jsonb, status = #{status}, + updated_at = NOW() + WHERE id = #{id} + + + DELETE FROM mes_item WHERE id = #{id} + + diff --git a/backend/src/main/resources/mapper/JobMapper.xml b/backend/src/main/resources/mapper/JobMapper.xml new file mode 100644 index 0000000..dec1273 --- /dev/null +++ b/backend/src/main/resources/mapper/JobMapper.xml @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO mes_job (job_no, workorder_id, wo_no, item_code, op_seq, process_code, process_name, + equipment_code, worker_id, assigned_qty, done_qty, status, planned_start, planned_end, remark, created_by) + VALUES (#{jobNo}, #{workorderId}, #{woNo}, #{itemCode}, #{opSeq}, #{processCode}, #{processName}, + #{equipmentCode}, #{workerId}, #{assignedQty}, 0, #{status}, #{plannedStart}, #{plannedEnd}, #{remark}, #{createdBy}) + + + + UPDATE mes_job SET op_seq = #{opSeq}, process_code = #{processCode}, process_name = #{processName}, + assigned_qty = #{assignedQty}, planned_start = #{plannedStart}, planned_end = #{plannedEnd}, + remark = #{remark}, updated_at = NOW() + WHERE id = #{id} + + + + UPDATE mes_job SET worker_id = #{workerId}, equipment_code = #{equipmentCode}, updated_at = NOW() + WHERE id = #{id} + + + + UPDATE mes_job + SET status = #{status}, + started_at = CASE WHEN #{status} = 'STARTED' AND started_at IS NULL THEN NOW() ELSE started_at END, + ended_at = CASE WHEN #{status} = 'DONE' THEN NOW() ELSE ended_at END, + updated_at = NOW() + WHERE id = #{id} + + + + UPDATE mes_job SET done_qty = COALESCE(done_qty,0) + #{qty}, updated_at = NOW() WHERE id = #{id} + + + DELETE FROM mes_job WHERE id = #{id} + + + + + + diff --git a/backend/src/main/resources/mapper/LotSerialMapper.xml b/backend/src/main/resources/mapper/LotSerialMapper.xml new file mode 100644 index 0000000..b9f7ba1 --- /dev/null +++ b/backend/src/main/resources/mapper/LotSerialMapper.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO mes_lotserial (lot_no, serial_no, item_code, qty, source_type, source_ref, + mfg_date, expiry_date, status, created_by) + VALUES (#{lotNo}, #{serialNo}, #{itemCode}, #{qty}, #{sourceType}, #{sourceRef}, + #{mfgDate}, #{expiryDate}, #{status}, #{createdBy}) + + + UPDATE mes_lotserial SET status = #{status} WHERE id = #{id} + + + + diff --git a/backend/src/main/resources/mapper/NcrMapper.xml b/backend/src/main/resources/mapper/NcrMapper.xml new file mode 100644 index 0000000..73d5181 --- /dev/null +++ b/backend/src/main/resources/mapper/NcrMapper.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO mes_ncr (ncr_no, item_code, lot_no, ref_type, ref_no, defect_code, defect_qty, + description, disposition, severity, status, created_by) + VALUES (#{ncrNo}, #{itemCode}, #{lotNo}, #{refType}, #{refNo}, #{defectCode}, #{defectQty}, + #{description}, #{disposition}, #{severity}, #{status}, #{createdBy}) + + + + UPDATE mes_ncr SET disposition = #{disposition}, status = #{status}, updated_at = NOW() WHERE id = #{id} + + + + UPDATE mes_ncr SET status = #{status}, updated_at = NOW() WHERE id = #{id} + + + + UPDATE mes_ncr SET capa_id = #{capaId}, updated_at = NOW() WHERE id = #{id} + + + + + diff --git a/backend/src/main/resources/mapper/OeeMapper.xml b/backend/src/main/resources/mapper/OeeMapper.xml new file mode 100644 index 0000000..5f9d9fb --- /dev/null +++ b/backend/src/main/resources/mapper/OeeMapper.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO mes_oee (equipment_code, record_date, planned_time_min, run_time_min, downtime_min, + downtime_code, ideal_cycle_min, total_count, good_count, created_by) + VALUES (#{equipmentCode}, #{recordDate}, #{plannedTimeMin}, #{runTimeMin}, #{downtimeMin}, + #{downtimeCode}, #{idealCycleMin}, #{totalCount}, #{goodCount}, #{createdBy}) + + + + + diff --git a/backend/src/main/resources/mapper/PartnerMapper.xml b/backend/src/main/resources/mapper/PartnerMapper.xml new file mode 100644 index 0000000..9b0d8de --- /dev/null +++ b/backend/src/main/resources/mapper/PartnerMapper.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO mes_partner (partner_code, partner_name, partner_type, biz_no, contact_name, + contact_phone_enc, contact_email_enc, address, status, created_by) + VALUES (#{partnerCode}, #{partnerName}, #{partnerType}, #{bizNo}, #{contactName}, + #{contactPhoneEnc}, #{contactEmailEnc}, #{address}, #{status}, #{createdBy}) + + + + UPDATE mes_partner SET partner_code = #{partnerCode}, partner_name = #{partnerName}, + partner_type = #{partnerType}, biz_no = #{bizNo}, contact_name = #{contactName}, + contact_phone_enc = #{contactPhoneEnc}, + contact_email_enc = #{contactEmailEnc}, + address = #{address}, status = #{status} + WHERE id = #{id} + + + DELETE FROM mes_partner WHERE id = #{id} + + diff --git a/backend/src/main/resources/mapper/PlanMapper.xml b/backend/src/main/resources/mapper/PlanMapper.xml new file mode 100644 index 0000000..40a3802 --- /dev/null +++ b/backend/src/main/resources/mapper/PlanMapper.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO mes_plan (plan_no, item_code, plan_qty, start_date, end_date, status, remark, created_by) + VALUES (#{planNo}, #{itemCode}, #{planQty}, #{startDate}, #{endDate}, #{status}, #{remark}, #{createdBy}) + + + + UPDATE mes_plan SET item_code = #{itemCode}, plan_qty = #{planQty}, start_date = #{startDate}, + end_date = #{endDate}, remark = #{remark} WHERE id = #{id} + + + UPDATE mes_plan SET status = #{status} WHERE id = #{id} + DELETE FROM mes_plan WHERE id = #{id} + + diff --git a/backend/src/main/resources/mapper/ProcessMapper.xml b/backend/src/main/resources/mapper/ProcessMapper.xml new file mode 100644 index 0000000..7f9d057 --- /dev/null +++ b/backend/src/main/resources/mapper/ProcessMapper.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO mes_process_progress (workorder_id, wo_no, op_seq, process_code, process_name, + equipment_code, worker_id, status, input_qty, output_qty) + VALUES (#{workorderId}, #{woNo}, #{opSeq}, #{processCode}, #{processName}, + #{equipmentCode}, #{workerId}, #{status}, #{inputQty}, #{outputQty}) + + + + UPDATE mes_process_progress SET equipment_code = #{equipmentCode}, worker_id = #{workerId}, + input_qty = #{inputQty}, output_qty = #{outputQty} WHERE id = #{id} + + + + UPDATE mes_process_progress + SET status = #{status}, + started_at = CASE WHEN #{status} = 'RUNNING' AND started_at IS NULL THEN NOW() ELSE started_at END, + ended_at = CASE WHEN #{status} = 'DONE' THEN NOW() ELSE ended_at END + WHERE id = #{id} + + + diff --git a/backend/src/main/resources/mapper/ProductionMapper.xml b/backend/src/main/resources/mapper/ProductionMapper.xml new file mode 100644 index 0000000..c0a280d --- /dev/null +++ b/backend/src/main/resources/mapper/ProductionMapper.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO mes_production (workorder_id, wo_no, item_code, lot_no, good_qty, defect_qty, + defect_code, process_code, equipment_code, worker_id, warehouse_code, reported_at, created_by) + VALUES (#{workorderId}, #{woNo}, #{itemCode}, #{lotNo}, #{goodQty}, #{defectQty}, + #{defectCode}, #{processCode}, #{equipmentCode}, #{workerId}, #{warehouseCode}, NOW(), #{createdBy}) + + + + + + + diff --git a/backend/src/main/resources/mapper/ProgressMapper.xml b/backend/src/main/resources/mapper/ProgressMapper.xml new file mode 100644 index 0000000..19a4db1 --- /dev/null +++ b/backend/src/main/resources/mapper/ProgressMapper.xml @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/src/main/resources/mapper/ReceivingMapper.xml b/backend/src/main/resources/mapper/ReceivingMapper.xml new file mode 100644 index 0000000..699fbda --- /dev/null +++ b/backend/src/main/resources/mapper/ReceivingMapper.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO mes_receiving (receiving_no, partner_code, item_code, lot_no, qty, warehouse_code, + location_code, status, inspection_required, inspection_result, remark, created_by) + VALUES (#{receivingNo}, #{partnerCode}, #{itemCode}, #{lotNo}, #{qty}, #{warehouseCode}, + #{locationCode}, #{status}, #{inspectionRequired}, #{inspectionResult}, #{remark}, #{createdBy}) + + + + UPDATE mes_receiving + SET status = #{status}, + inspection_result = COALESCE(#{inspectionResult}, inspection_result) + WHERE id = #{id} + + + diff --git a/backend/src/main/resources/mapper/RoutingMapper.xml b/backend/src/main/resources/mapper/RoutingMapper.xml new file mode 100644 index 0000000..e2cf19f --- /dev/null +++ b/backend/src/main/resources/mapper/RoutingMapper.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO mes_routing (item_code, op_seq, process_code, process_name, equipment_code, + std_time_min, setup_time_min, routing_version, status, created_by) + VALUES (#{itemCode}, #{opSeq}, #{processCode}, #{processName}, #{equipmentCode}, + #{stdTimeMin}, #{setupTimeMin}, #{routingVersion}, #{status}, #{createdBy}) + + + + UPDATE mes_routing SET item_code = #{itemCode}, op_seq = #{opSeq}, process_code = #{processCode}, + process_name = #{processName}, equipment_code = #{equipmentCode}, std_time_min = #{stdTimeMin}, + setup_time_min = #{setupTimeMin}, routing_version = #{routingVersion}, status = #{status} + WHERE id = #{id} + + + DELETE FROM mes_routing WHERE id = #{id} + + diff --git a/backend/src/main/resources/mapper/SettingMapper.xml b/backend/src/main/resources/mapper/SettingMapper.xml new file mode 100644 index 0000000..049ab01 --- /dev/null +++ b/backend/src/main/resources/mapper/SettingMapper.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + INSERT INTO mes_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/ShippingMapper.xml b/backend/src/main/resources/mapper/ShippingMapper.xml new file mode 100644 index 0000000..89a1157 --- /dev/null +++ b/backend/src/main/resources/mapper/ShippingMapper.xml @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO mes_shipping (shipping_no, partner_code, order_ref, item_code, lot_no, plan_qty, + picked_qty, packed_qty, shipped_qty, warehouse_code, location_code, status, + inspection_required, remark, created_by) + VALUES (#{shippingNo}, #{partnerCode}, #{orderRef}, #{itemCode}, #{lotNo}, #{planQty}, + 0, 0, 0, #{warehouseCode}, #{locationCode}, #{status}, + #{inspectionRequired}, #{remark}, #{createdBy}) + + + + UPDATE mes_shipping SET partner_code = #{partnerCode}, order_ref = #{orderRef}, + item_code = #{itemCode}, lot_no = #{lotNo}, plan_qty = #{planQty}, + warehouse_code = #{warehouseCode}, location_code = #{locationCode}, + inspection_required = #{inspectionRequired}, remark = #{remark}, updated_at = NOW() + WHERE id = #{id} + + + + UPDATE mes_shipping SET status = #{status}, updated_at = NOW() WHERE id = #{id} + + + + UPDATE mes_shipping SET picked_qty = #{pickedQty}, status = 'PICKING', updated_at = NOW() WHERE id = #{id} + + + + UPDATE mes_shipping SET packed_qty = #{packedQty}, status = 'PACKING', updated_at = NOW() WHERE id = #{id} + + + + UPDATE mes_shipping SET inspection_result = #{inspectionResult}, status = 'INSPECTED', updated_at = NOW() WHERE id = #{id} + + + + UPDATE mes_shipping + SET shipped_qty = #{shippedQty}, delivery_no = #{deliveryNo}, statement_no = #{statementNo}, + status = 'SHIPPED', ship_date = CURRENT_DATE, updated_at = NOW() + WHERE id = #{id} + + + DELETE FROM mes_shipping WHERE id = #{id} + + + + diff --git a/backend/src/main/resources/mapper/SpcMapper.xml b/backend/src/main/resources/mapper/SpcMapper.xml new file mode 100644 index 0000000..026e7da --- /dev/null +++ b/backend/src/main/resources/mapper/SpcMapper.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO mes_spc_sample (chart_code, item_code, characteristic, subgroup_no, sample_values, + sample_mean, sample_range, measured_at, created_by) + VALUES (#{chartCode}, #{itemCode}, #{characteristic}, #{subgroupNo}, #{sampleValues}::jsonb, + #{sampleMean}, #{sampleRange}, NOW(), #{createdBy}) + + + diff --git a/backend/src/main/resources/mapper/SpecMapper.xml b/backend/src/main/resources/mapper/SpecMapper.xml new file mode 100644 index 0000000..ff2cafb --- /dev/null +++ b/backend/src/main/resources/mapper/SpecMapper.xml @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO mes_spec (spec_code, item_code, inspection_type, characteristic, lsl, usl, target, + unit, aql, items, status, created_by) + VALUES (#{specCode}, #{itemCode}, #{inspectionType}, #{characteristic}, #{lsl}, #{usl}, #{target}, + #{unit}, #{aql}, #{items}::jsonb, #{status}, #{createdBy}) + + + + UPDATE mes_spec SET item_code = #{itemCode}, inspection_type = #{inspectionType}, + characteristic = #{characteristic}, lsl = #{lsl}, usl = #{usl}, target = #{target}, + unit = #{unit}, aql = #{aql}, items = #{items}::jsonb, status = #{status} + WHERE id = #{id} + + + DELETE FROM mes_spec WHERE id = #{id} + + diff --git a/backend/src/main/resources/mapper/StocktakeMapper.xml b/backend/src/main/resources/mapper/StocktakeMapper.xml new file mode 100644 index 0000000..1e75496 --- /dev/null +++ b/backend/src/main/resources/mapper/StocktakeMapper.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO mes_stocktake (stocktake_no, item_code, warehouse_code, location_code, lot_no, + system_qty, counted_qty, diff_qty, status, remark, created_by) + VALUES (#{stocktakeNo}, #{itemCode}, #{warehouseCode}, #{locationCode}, #{lotNo}, + #{systemQty}, #{countedQty}, #{diffQty}, #{status}, #{remark}, #{createdBy}) + + + UPDATE mes_stocktake SET status = #{status} WHERE id = #{id} + + diff --git a/backend/src/main/resources/mapper/TrackingMapper.xml b/backend/src/main/resources/mapper/TrackingMapper.xml new file mode 100644 index 0000000..1628439 --- /dev/null +++ b/backend/src/main/resources/mapper/TrackingMapper.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + diff --git a/backend/src/main/resources/mapper/UserMapper.xml b/backend/src/main/resources/mapper/UserMapper.xml new file mode 100644 index 0000000..5203a0c --- /dev/null +++ b/backend/src/main/resources/mapper/UserMapper.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + INSERT INTO mes_user (username, password_hash, display_name, role, is_active) + VALUES (#{username}, #{passwordHash}, #{displayName}, #{role}, #{active}) + + + diff --git a/backend/src/main/resources/mapper/WarehouseMapper.xml b/backend/src/main/resources/mapper/WarehouseMapper.xml new file mode 100644 index 0000000..bb27702 --- /dev/null +++ b/backend/src/main/resources/mapper/WarehouseMapper.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO mes_warehouse (warehouse_code, warehouse_name, warehouse_type, address, status, created_by) + VALUES (#{warehouseCode}, #{warehouseName}, #{warehouseType}, #{address}, #{status}, #{createdBy}) + + + UPDATE mes_warehouse SET warehouse_code = #{warehouseCode}, warehouse_name = #{warehouseName}, + warehouse_type = #{warehouseType}, address = #{address}, status = #{status} + WHERE id = #{id} + + DELETE FROM mes_warehouse WHERE id = #{id} + + + + INSERT INTO mes_location (warehouse_code, location_code, zone, aisle, rack, bin, status) + VALUES (#{warehouseCode}, #{locationCode}, #{zone}, #{aisle}, #{rack}, #{bin}, #{status}) + + DELETE FROM mes_location WHERE id = #{id} + + diff --git a/backend/src/main/resources/mapper/WorkOrderMapper.xml b/backend/src/main/resources/mapper/WorkOrderMapper.xml new file mode 100644 index 0000000..c99b6e2 --- /dev/null +++ b/backend/src/main/resources/mapper/WorkOrderMapper.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO mes_workorder (wo_no, item_code, plan_qty, produced_qty, defect_qty, unit, due_date, + warehouse_code, status, priority, remark, created_by) + VALUES (#{woNo}, #{itemCode}, #{planQty}, #{producedQty}, #{defectQty}, #{unit}, #{dueDate}, + #{warehouseCode}, #{status}, #{priority}, #{remark}, #{createdBy}) + + + + UPDATE mes_workorder SET item_code = #{itemCode}, plan_qty = #{planQty}, unit = #{unit}, + due_date = #{dueDate}, warehouse_code = #{warehouseCode}, priority = #{priority}, + remark = #{remark}, updated_at = NOW() + WHERE id = #{id} + + + + UPDATE mes_workorder SET status = #{status}, updated_at = NOW() WHERE id = #{id} + + + + UPDATE mes_workorder + SET produced_qty = COALESCE(produced_qty,0) + #{qty}, + defect_qty = COALESCE(defect_qty,0) + COALESCE(#{defect},0), + updated_at = NOW() + WHERE id = #{id} + + + DELETE FROM mes_workorder WHERE id = #{id} + + + + diff --git a/backend/src/main/resources/static/assets/index-BLHiicbF.js b/backend/src/main/resources/static/assets/index-BLHiicbF.js new file mode 100644 index 0000000..1b532fb --- /dev/null +++ b/backend/src/main/resources/static/assets/index-BLHiicbF.js @@ -0,0 +1,480 @@ +var D1=e=>{throw TypeError(e)};var Qm=(e,t,n)=>t.has(e)||D1("Cannot "+n);var I=(e,t,n)=>(Qm(e,t,"read from private field"),n?n.call(e):t.get(e)),be=(e,t,n)=>t.has(e)?D1("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(e):t.set(e,n),fe=(e,t,n,r)=>(Qm(e,t,"write to private field"),r?r.call(e,n):t.set(e,n),n),Dt=(e,t,n)=>(Qm(e,t,"access private method"),n);var Af=(e,t,n,r)=>({set _(a){fe(e,t,a,n)},get _(){return I(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 Nf=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 hE={exports:{}},Sp={};/** + * @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 R3=Symbol.for("react.transitional.element"),k3=Symbol.for("react.fragment");function pE(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:R3,type:e,key:r,ref:t!==void 0?t:null,props:n}}Sp.Fragment=k3;Sp.jsx=pE;Sp.jsxs=pE;hE.exports=Sp;var o=hE.exports,mE={exports:{}},he={};/** + * @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 ib=Symbol.for("react.transitional.element"),D3=Symbol.for("react.portal"),L3=Symbol.for("react.fragment"),z3=Symbol.for("react.strict_mode"),B3=Symbol.for("react.profiler"),I3=Symbol.for("react.consumer"),U3=Symbol.for("react.context"),q3=Symbol.for("react.forward_ref"),H3=Symbol.for("react.suspense"),F3=Symbol.for("react.memo"),yE=Symbol.for("react.lazy"),G3=Symbol.for("react.activity"),L1=Symbol.iterator;function K3(e){return e===null||typeof e!="object"?null:(e=L1&&e[L1]||e["@@iterator"],typeof e=="function"?e:null)}var vE={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},gE=Object.assign,xE={};function ws(e,t,n){this.props=e,this.context=t,this.refs=xE,this.updater=n||vE}ws.prototype.isReactComponent={};ws.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")};ws.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,"forceUpdate")};function bE(){}bE.prototype=ws.prototype;function lb(e,t,n){this.props=e,this.context=t,this.refs=xE,this.updater=n||vE}var ob=lb.prototype=new bE;ob.constructor=lb;gE(ob,ws.prototype);ob.isPureReactComponent=!0;var z1=Array.isArray;function xv(){}var We={H:null,A:null,T:null,S:null},jE=Object.prototype.hasOwnProperty;function sb(e,t,n){var r=n.ref;return{$$typeof:ib,type:e,key:t,ref:r!==void 0?r:null,props:n}}function V3(e,t){return sb(e.type,t,e.props)}function cb(e){return typeof e=="object"&&e!==null&&e.$$typeof===ib}function Y3(e){var t={"=":"=0",":":"=2"};return"$"+e.replace(/[=:]/g,function(n){return t[n]})}var B1=/\/+/g;function Wm(e,t){return typeof e=="object"&&e!==null&&e.key!=null?Y3(""+e.key):t.toString(36)}function X3(e){switch(e.status){case"fulfilled":return e.value;case"rejected":throw e.reason;default:switch(typeof e.status=="string"?e.then(xv,xv):(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 Gl(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 ib:case D3:l=!0;break;case yE:return l=e._init,Gl(l(e._payload),t,n,r,a)}}if(l)return a=a(e),l=r===""?"."+Wm(e,0):r,z1(a)?(n="",l!=null&&(n=l.replace(B1,"$&/")+"/"),Gl(a,t,n,"",function(u){return u})):a!=null&&(cb(a)&&(a=V3(a,n+(a.key==null||e&&e.key===a.key?"":(""+a.key).replace(B1,"$&/")+"/")+l)),t.push(a)),1;l=0;var s=r===""?".":r+":";if(z1(e))for(var c=0;c>>1,X=$[V];if(0>>1;Va(ie,q))aea(Se,ie)?($[V]=Se,$[ae]=q,V=ae):($[V]=ie,$[Q]=q,V=Q);else if(aea(Se,q))$[V]=Se,$[ae]=q,V=ae;else break e}}return L}function a($,L){var q=$.sortIndex-L.sortIndex;return q!==0?q:$.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 c=[],u=[],f=1,d=null,h=3,m=!1,j=!1,v=!1,p=!1,y=typeof setTimeout=="function"?setTimeout:null,g=typeof clearTimeout=="function"?clearTimeout:null,b=typeof setImmediate<"u"?setImmediate:null;function x($){for(var L=n(u);L!==null;){if(L.callback===null)r(u);else if(L.startTime<=$)r(u),L.sortIndex=L.expirationTime,t(c,L);else break;L=n(u)}}function w($){if(v=!1,x($),!j)if(n(c)!==null)j=!0,S||(S=!0,P());else{var L=n(u);L!==null&&E(w,L.startTime-$)}}var S=!1,A=-1,N=5,C=-1;function M(){return p?!0:!(e.unstable_now()-C$&&M());){var V=d.callback;if(typeof V=="function"){d.callback=null,h=d.priorityLevel;var X=V(d.expirationTime<=$);if($=e.unstable_now(),typeof X=="function"){d.callback=X,x($),L=!0;break t}d===n(c)&&r(c),x($)}else r(c);d=n(c)}if(d!==null)L=!0;else{var J=n(u);J!==null&&E(w,J.startTime-$),L=!1}}break e}finally{d=null,h=q,m=!1}L=void 0}}finally{L?P():S=!1}}}var P;if(typeof b=="function")P=function(){b(R)};else if(typeof MessageChannel<"u"){var B=new MessageChannel,_=B.port2;B.port1.onmessage=R,P=function(){_.postMessage(null)}}else P=function(){y(R,0)};function E($,L){A=y(function(){$(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($){$.callback=null},e.unstable_forceFrameRate=function($){0>$||125<$?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):N=0<$?Math.floor(1e3/$):5},e.unstable_getCurrentPriorityLevel=function(){return h},e.unstable_next=function($){switch(h){case 1:case 2:case 3:var L=3;break;default:L=h}var q=h;h=L;try{return $()}finally{h=q}},e.unstable_requestPaint=function(){p=!0},e.unstable_runWithPriority=function($,L){switch($){case 1:case 2:case 3:case 4:case 5:break;default:$=3}var q=h;h=$;try{return L()}finally{h=q}},e.unstable_scheduleCallback=function($,L,q){var V=e.unstable_now();switch(typeof q=="object"&&q!==null?(q=q.delay,q=typeof q=="number"&&0V?($.sortIndex=q,t(u,$),n(c)===null&&$===n(u)&&(v?(g(A),A=-1):v=!0,E(w,q-V))):($.sortIndex=X,t(c,$),j||m||(j=!0,S||(S=!0,P()))),$},e.unstable_shouldYield=M,e.unstable_wrapCallback=function($){var L=h;return function(){var q=h;h=L;try{return $.apply(this,arguments)}finally{h=q}}}})(OE);wE.exports=OE;var Z3=wE.exports,AE={exports:{}},ln={};/** + * @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 J3=O;function NE(e){var t="https://react.dev/errors/"+e;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(EE)}catch(e){console.error(e)}}EE(),AE.exports=ln;var nR=AE.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 Et=Z3,CE=O,rR=nR;function F(e){var t="https://react.dev/errors/"+e;if(1Xl||(e.current=Av[Xl],Av[Xl]=null,Xl--)}function qe(e,t){Xl++,Av[Xl]=e.current,e.current=t}var Lr=Ur(null),Gc=Ur(null),ri=Ur(null),Dd=Ur(null);function Ld(e,t){switch(qe(ri,t),qe(Gc,e),qe(Lr,null),t.nodeType){case 9:case 11:e=(e=t.documentElement)&&(e=e.namespaceURI)?Yj(e):0;break;default:if(e=t.tagName,t=t.namespaceURI)t=Yj(t),e=e_(t,e);else switch(e){case"svg":e=1;break;case"math":e=2;break;default:e=0}}Rt(Lr),qe(Lr,e)}function Ro(){Rt(Lr),Rt(Gc),Rt(ri)}function Nv(e){e.memoizedState!==null&&qe(Dd,e);var t=Lr.current,n=e_(t,e.type);t!==n&&(qe(Gc,e),qe(Lr,n))}function zd(e){Gc.current===e&&(Rt(Lr),Rt(Gc)),Dd.current===e&&(Rt(Dd),nu._currentValue=al)}var Zm,H1;function zi(e){if(Zm===void 0)try{throw Error()}catch(n){var t=n.stack.trim().match(/\n( *(at )?)/);Zm=t&&t[1]||"",H1=-1)":-1a||c[r]!==u[a]){var f=` +`+c[r].replace(" at new "," at ");return e.displayName&&f.includes("")&&(f=f.replace("",e.displayName)),f}while(1<=r&&0<=a);break}}}finally{Jm=!1,Error.prepareStackTrace=n}return(n=e?e.displayName||e.name:"")?zi(n):""}function sR(e,t){switch(e.tag){case 26:case 27:case 5:return zi(e.type);case 16:return zi("Lazy");case 13:return e.child!==t&&t!==null?zi("Suspense Fallback"):zi("Suspense");case 19:return zi("SuspenseList");case 0:case 15:return ey(e.type,!1);case 11:return ey(e.type.render,!1);case 1:return ey(e.type,!0);case 31:return zi("Activity");default:return""}}function F1(e){try{var t="",n=null;do t+=sR(e,n),n=e,e=e.return;while(e);return t}catch(r){return` +Error generating stack: `+r.message+` +`+r.stack}}var Ev=Object.prototype.hasOwnProperty,db=Et.unstable_scheduleCallback,ty=Et.unstable_cancelCallback,cR=Et.unstable_shouldYield,uR=Et.unstable_requestPaint,En=Et.unstable_now,fR=Et.unstable_getCurrentPriorityLevel,kE=Et.unstable_ImmediatePriority,DE=Et.unstable_UserBlockingPriority,Bd=Et.unstable_NormalPriority,dR=Et.unstable_LowPriority,LE=Et.unstable_IdlePriority,hR=Et.log,pR=Et.unstable_setDisableYieldValue,Yu=null,Cn=null;function Xa(e){if(typeof hR=="function"&&pR(e),Cn&&typeof Cn.setStrictMode=="function")try{Cn.setStrictMode(Yu,e)}catch{}}var _n=Math.clz32?Math.clz32:vR,mR=Math.log,yR=Math.LN2;function vR(e){return e>>>=0,e===0?32:31-(mR(e)/yR|0)|0}var _f=256,Tf=262144,Pf=4194304;function Bi(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 Ap(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=Bi(r):(l&=s,l!==0?a=Bi(l):n||(n=s&~e,n!==0&&(a=Bi(n))))):(s=r&~i,s!==0?a=Bi(s):l!==0?a=Bi(l):n||(n=r&~e,n!==0&&(a=Bi(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 Xu(e,t){return(e.pendingLanes&~(e.suspendedLanes&~e.pingedLanes)&t)===0}function gR(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 zE(){var e=Pf;return Pf<<=1,!(Pf&62914560)&&(Pf=4194304),e}function ny(e){for(var t=[],n=0;31>n;n++)t.push(e);return t}function Qu(e,t){e.pendingLanes|=t,t!==268435456&&(e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0)}function xR(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,c=e.expirationTimes,u=e.hiddenUpdates;for(n=l&~n;0"u")return null;try{return e.activeElement||e.body}catch{return e.body}}var AR=/[\n"\\]/g;function Kn(e){return e.replace(AR,function(t){return"\\"+t.charCodeAt(0).toString(16)+" "})}function Tv(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=""+Hn(t)):e.value!==""+Hn(t)&&(e.value=""+Hn(t)):l!=="submit"&&l!=="reset"||e.removeAttribute("value"),t!=null?Pv(e,l,Hn(t)):n!=null?Pv(e,l,Hn(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=""+Hn(s):e.removeAttribute("name")}function VE(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)){_v(e);return}n=n!=null?""+Hn(n):"",t=t!=null?""+Hn(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),_v(e)}function Pv(e,t,n){t==="number"&&Id(e.ownerDocument)===e||e.defaultValue===""+n||(e.defaultValue=""+n)}function mo(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"),$v=!1;if(ma)try{var Zs={};Object.defineProperty(Zs,"passive",{get:function(){$v=!0}}),window.addEventListener("test",Zs,Zs),window.removeEventListener("test",Zs,Zs)}catch{$v=!1}var Qa=null,gb=null,vd=null;function ZE(){if(vd)return vd;var e,t=gb,n=t.length,r,a="value"in Qa?Qa.value:Qa.textContent,i=a.length;for(e=0;e=wc),tj=" ",nj=!1;function e2(e,t){switch(e){case"keyup":return JR.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function t2(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var Zl=!1;function tk(e,t){switch(e){case"compositionend":return t2(t);case"keypress":return t.which!==32?null:(nj=!0,tj);case"textInput":return e=t.data,e===tj&&nj?null:e;default:return null}}function nk(e,t){if(Zl)return e==="compositionend"||!bb&&e2(e,t)?(e=ZE(),vd=gb=Qa=null,Zl=!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=oj(n)}}function i2(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?i2(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function l2(e){e=e!=null&&e.ownerDocument!=null&&e.ownerDocument.defaultView!=null?e.ownerDocument.defaultView:window;for(var t=Id(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=Id(e.document)}return t}function jb(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 uk=ma&&"documentMode"in document&&11>=document.documentMode,Jl=null,Rv=null,Ac=null,kv=!1;function cj(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;kv||Jl==null||Jl!==Id(r)||(r=Jl,"selectionStart"in r&&jb(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}),Ac&&Yc(Ac,r)||(Ac=r,r=ah(Rv,"onSelect"),0>=l,a-=l,Mr=1<<32-_n(t)+a|n<N?(C=A,A=null):C=A.sibling;var M=h(y,A,b[N],x);if(M===null){A===null&&(A=C);break}e&&A&&M.alternate===null&&t(y,A),g=i(M,g,N),S===null?w=M:S.sibling=M,S=M,A=C}if(N===b.length)return n(y,A),we&&ea(y,N),w;if(A===null){for(;NN?(C=A,A=null):C=A.sibling;var R=h(y,A,M.value,x);if(R===null){A===null&&(A=C);break}e&&A&&R.alternate===null&&t(y,A),g=i(R,g,N),S===null?w=R:S.sibling=R,S=R,A=C}if(M.done)return n(y,A),we&&ea(y,N),w;if(A===null){for(;!M.done;N++,M=b.next())M=d(y,M.value,x),M!==null&&(g=i(M,g,N),S===null?w=M:S.sibling=M,S=M);return we&&ea(y,N),w}for(A=r(A);!M.done;N++,M=b.next())M=m(A,y,N,M.value,x),M!==null&&(e&&M.alternate!==null&&A.delete(M.key===null?N:M.key),g=i(M,g,N),S===null?w=M:S.sibling=M,S=M);return e&&A.forEach(function(P){return t(y,P)}),we&&ea(y,N),w}function p(y,g,b,x){if(typeof b=="object"&&b!==null&&b.type===Yl&&b.key===null&&(b=b.props.children),typeof b=="object"&&b!==null){switch(b.$$typeof){case Cf:e:{for(var w=b.key;g!==null;){if(g.key===w){if(w=b.type,w===Yl){if(g.tag===7){n(y,g.sibling),x=a(g,b.props.children),x.return=y,y=x;break e}}else if(g.elementType===w||typeof w=="object"&&w!==null&&w.$$typeof===ka&&Ii(w)===g.type){n(y,g.sibling),x=a(g,b.props),ec(x,b),x.return=y,y=x;break e}n(y,g);break}else t(y,g);g=g.sibling}b.type===Yl?(x=il(b.props.children,y.mode,x,b.key),x.return=y,y=x):(x=xd(b.type,b.key,b.props,null,y.mode,x),ec(x,b),x.return=y,y=x)}return l(y);case yc:e:{for(w=b.key;g!==null;){if(g.key===w)if(g.tag===4&&g.stateNode.containerInfo===b.containerInfo&&g.stateNode.implementation===b.implementation){n(y,g.sibling),x=a(g,b.children||[]),x.return=y,y=x;break e}else{n(y,g);break}else t(y,g);g=g.sibling}x=fy(b,y.mode,x),x.return=y,y=x}return l(y);case ka:return b=Ii(b),p(y,g,b,x)}if(vc(b))return j(y,g,b,x);if(Ws(b)){if(w=Ws(b),typeof w!="function")throw Error(F(150));return b=w.call(b),v(y,g,b,x)}if(typeof b.then=="function")return p(y,g,kf(b),x);if(b.$$typeof===aa)return p(y,g,Rf(y,b),x);Df(y,b)}return typeof b=="string"&&b!==""||typeof b=="number"||typeof b=="bigint"?(b=""+b,g!==null&&g.tag===6?(n(y,g.sibling),x=a(g,b),x.return=y,y=x):(n(y,g),x=uy(b,y.mode,x),x.return=y,y=x),l(y)):n(y,g)}return function(y,g,b,x){try{Wc=0;var w=p(y,g,b,x);return go=null,w}catch(A){if(A===Es||A===Pp)throw A;var S=On(29,A,null,y.mode);return S.lanes=x,S.return=y,S}finally{}}}var yl=j2(!0),S2=j2(!1),Da=!1;function Tb(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function qv(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 ii(e){return{lane:e,tag:0,payload:null,callback:null,next:null}}function li(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,Ne&2){var a=r.pending;return a===null?t.next=t:(t.next=a.next,a.next=t),r.pending=t,t=qd(e),h2(e,null,n),t}return Tp(e,r,t,n),qd(e)}function Ec(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,IE(e,n)}}function hy(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 Hv=!1;function Cc(){if(Hv){var e=vo;if(e!==null)throw e}}function _c(e,t,n,r){Hv=!1;var a=e.updateQueue;Da=!1;var i=a.firstBaseUpdate,l=a.lastBaseUpdate,s=a.shared.pending;if(s!==null){a.shared.pending=null;var c=s,u=c.next;c.next=null,l===null?i=u:l.next=u,l=c;var f=e.alternate;f!==null&&(f=f.updateQueue,s=f.lastBaseUpdate,s!==l&&(s===null?f.firstBaseUpdate=u:s.next=u,f.lastBaseUpdate=c))}if(i!==null){var d=a.baseState;l=0,f=u=c=null,s=i;do{var h=s.lane&-536870913,m=h!==s.lane;if(m?(je&h)===h:(r&h)===h){h!==0&&h===Lo&&(Hv=!0),f!==null&&(f=f.next={lane:0,tag:s.tag,payload:s.payload,callback:null,next:null});e:{var j=e,v=s;h=t;var p=n;switch(v.tag){case 1:if(j=v.payload,typeof j=="function"){d=j.call(p,d,h);break e}d=j;break e;case 3:j.flags=j.flags&-65537|128;case 0:if(j=v.payload,h=typeof j=="function"?j.call(p,d,h):j,h==null)break e;d=Ze({},d,h);break e;case 2:Da=!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?(u=f=m,c=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&&(c=d),a.baseState=c,a.firstBaseUpdate=u,a.lastBaseUpdate=f,i===null&&(a.shared.lanes=0),vi|=l,e.lanes=l,e.memoizedState=d}}function w2(e,t){if(typeof e!="function")throw Error(F(191,e));e.call(t)}function O2(e,t){var n=e.callbacks;if(n!==null)for(e.callbacks=null,e=0;ei?i:8;var l=ue.T,s={};ue.T=s,Hb(e,!1,t,n);try{var c=a(),u=ue.S;if(u!==null&&u(s,c),c!==null&&typeof c=="object"&&typeof c.then=="function"){var f=xk(c,r);Tc(e,t,f,Tn(e))}else Tc(e,t,r,Tn(e))}catch(d){Tc(e,t,{then:function(){},status:"rejected",reason:d},Tn())}finally{Ee.p=i,l!==null&&s.types!==null&&(l.types=s.types),ue.T=l}}function Ak(){}function Yv(e,t,n,r){if(e.tag!==5)throw Error(F(476));var a=X2(e).queue;Y2(e,a,t,al,n===null?Ak:function(){return Q2(e),n(r)})}function X2(e){var t=e.memoizedState;if(t!==null)return t;t={memoizedState:al,baseState:al,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:va,lastRenderedState:al},next:null};var n={};return t.next={memoizedState:n,baseState:n,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:va,lastRenderedState:n},next:null},e.memoizedState=t,e=e.alternate,e!==null&&(e.memoizedState=t),t}function Q2(e){var t=X2(e);t.next===null&&(t=e.alternate.memoizedState),Tc(e,t.next.queue,{},Tn())}function qb(){return qt(nu)}function W2(){return pt().memoizedState}function Z2(){return pt().memoizedState}function Nk(e){for(var t=e.return;t!==null;){switch(t.tag){case 24:case 3:var n=Tn();e=ii(n);var r=li(t,e,n);r!==null&&(hn(r,t,n),Ec(r,t,n)),t={cache:Eb()},e.payload=t;return}t=t.return}}function Ek(e,t,n){var r=Tn();n={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null},kp(e)?eC(t,n):(n=wb(e,t,n,r),n!==null&&(hn(n,e,r),tC(n,t,r)))}function J2(e,t,n){var r=Tn();Tc(e,t,n,r)}function Tc(e,t,n,r){var a={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null};if(kp(e))eC(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,Mn(s,l))return Tp(e,t,a,0),ze===null&&_p(),!1}catch{}finally{}if(n=wb(e,t,a,r),n!==null)return hn(n,e,r),tC(n,t,r),!0}return!1}function Hb(e,t,n,r){if(r={lane:2,revertLane:Zb(),gesture:null,action:r,hasEagerState:!1,eagerState:null,next:null},kp(e)){if(t)throw Error(F(479))}else t=wb(e,n,r,2),t!==null&&hn(t,e,2)}function kp(e){var t=e.alternate;return e===pe||t!==null&&t===pe}function eC(e,t){xo=Yd=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function tC(e,t,n){if(n&4194048){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,IE(e,n)}}var Jc={readContext:qt,use:$p,useCallback:st,useContext:st,useEffect:st,useImperativeHandle:st,useLayoutEffect:st,useInsertionEffect:st,useMemo:st,useReducer:st,useRef:st,useState:st,useDebugValue:st,useDeferredValue:st,useTransition:st,useSyncExternalStore:st,useId:st,useHostTransitionStatus:st,useFormState:st,useActionState:st,useOptimistic:st,useMemoCache:st,useCacheRefresh:st};Jc.useEffectEvent=st;var nC={readContext:qt,use:$p,useCallback:function(e,t){return Qt().memoizedState=[e,t===void 0?null:t],e},useContext:qt,useEffect:Oj,useImperativeHandle:function(e,t,n){n=n!=null?n.concat([e]):null,Sd(4194308,4,H2.bind(null,t,e),n)},useLayoutEffect:function(e,t){return Sd(4194308,4,e,t)},useInsertionEffect:function(e,t){Sd(4,2,e,t)},useMemo:function(e,t){var n=Qt();t=t===void 0?null:t;var r=e();if(vl){Xa(!0);try{e()}finally{Xa(!1)}}return n.memoizedState=[r,t],r},useReducer:function(e,t,n){var r=Qt();if(n!==void 0){var a=n(t);if(vl){Xa(!0);try{n(t)}finally{Xa(!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=Ek.bind(null,pe,e),[r.memoizedState,e]},useRef:function(e){var t=Qt();return e={current:e},t.memoizedState=e},useState:function(e){e=Kv(e);var t=e.queue,n=J2.bind(null,pe,t);return t.dispatch=n,[e.memoizedState,n]},useDebugValue:Ib,useDeferredValue:function(e,t){var n=Qt();return Ub(n,e,t)},useTransition:function(){var e=Kv(!1);return e=Y2.bind(null,pe,e.queue,!0,!1),Qt().memoizedState=e,[!1,e]},useSyncExternalStore:function(e,t,n){var r=pe,a=Qt();if(we){if(n===void 0)throw Error(F(407));n=n()}else{if(n=t(),ze===null)throw Error(F(349));je&127||_2(r,t,n)}a.memoizedState=n;var i={value:n,getSnapshot:t};return a.queue=i,Oj(P2.bind(null,r,i,e),[e]),r.flags|=2048,Bo(9,{destroy:void 0},T2.bind(null,r,i,n,t),null),n},useId:function(){var e=Qt(),t=ze.identifierPrefix;if(we){var n=$r,r=Mr;n=(r&~(1<<32-_n(r)-1)).toString(32)+n,t="_"+t+"R_"+n,n=Xd++,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[Bt]=t,i[mn]=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&&Vr(t)}}return Ke(t),jy(t,t.type,e===null?null:e.memoizedProps,t.pendingProps,n),null;case 6:if(e&&t.stateNode!=null)e.memoizedProps!==r&&Vr(t);else{if(typeof r!="string"&&t.stateNode===null)throw Error(F(166));if(e=ri.current,Ll(t)){if(e=t.stateNode,n=t.memoizedProps,r=null,a=It,a!==null)switch(a.tag){case 27:case 5:r=a.memoizedProps}e[Bt]=t,e=!!(e.nodeValue===n||r!==null&&r.suppressHydrationWarning===!0||JC(e.nodeValue,n)),e||mi(t,!0)}else e=ih(e).createTextNode(r),e[Bt]=t,t.stateNode=e}return Ke(t),null;case 31:if(n=t.memoizedState,e===null||e.memoizedState!==null){if(r=Ll(t),n!==null){if(e===null){if(!r)throw Error(F(318));if(e=t.memoizedState,e=e!==null?e.dehydrated:null,!e)throw Error(F(557));e[Bt]=t}else pl(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;Ke(t),e=!1}else n=dy(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=n),e=!0;if(!e)return t.flags&256?(wn(t),t):(wn(t),null);if(t.flags&128)throw Error(F(558))}return Ke(t),null;case 13:if(r=t.memoizedState,e===null||e.memoizedState!==null&&e.memoizedState.dehydrated!==null){if(a=Ll(t),r!==null&&r.dehydrated!==null){if(e===null){if(!a)throw Error(F(318));if(a=t.memoizedState,a=a!==null?a.dehydrated:null,!a)throw Error(F(317));a[Bt]=t}else pl(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;Ke(t),a=!1}else a=dy(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=a),a=!0;if(!a)return t.flags&256?(wn(t),t):(wn(t),null)}return wn(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),Lf(t,t.updateQueue),Ke(t),null);case 4:return Ro(),e===null&&Jb(t.stateNode.containerInfo),Ke(t),null;case 10:return fa(t.type),Ke(t),null;case 19:if(Rt(ht),r=t.memoizedState,r===null)return Ke(t),null;if(a=(t.flags&128)!==0,i=r.rendering,i===null)if(a)tc(r,!1);else{if(ft!==0||e!==null&&e.flags&128)for(e=t.child;e!==null;){if(i=Vd(e),i!==null){for(t.flags|=128,tc(r,!1),e=i.updateQueue,t.updateQueue=e,Lf(t,e),t.subtreeFlags=0,e=n,n=t.child;n!==null;)p2(n,e),n=n.sibling;return qe(ht,ht.current&1|2),we&&ea(t,r.treeForkCount),t.child}e=e.sibling}r.tail!==null&&En()>Jd&&(t.flags|=128,a=!0,tc(r,!1),t.lanes=4194304)}else{if(!a)if(e=Vd(i),e!==null){if(t.flags|=128,a=!0,e=e.updateQueue,t.updateQueue=e,Lf(t,e),tc(r,!0),r.tail===null&&r.tailMode==="hidden"&&!i.alternate&&!we)return Ke(t),null}else 2*En()-r.renderingStartTime>Jd&&n!==536870912&&(t.flags|=128,a=!0,tc(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=En(),e.sibling=null,n=ht.current,qe(ht,a?n&1|2:n&1),we&&ea(t,r.treeForkCount),e):(Ke(t),null);case 22:case 23:return wn(t),Pb(),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&&Lf(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&&Rt(ll),null;case 24:return n=null,e!==null&&(n=e.memoizedState.cache),t.memoizedState.cache!==n&&(t.flags|=2048),fa(bt),Ke(t),null;case 25:return null;case 30:return null}throw Error(F(156,t.tag))}function Mk(e,t){switch(Nb(t),t.tag){case 1:return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return fa(bt),Ro(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 26:case 27:case 5:return zd(t),null;case 31:if(t.memoizedState!==null){if(wn(t),t.alternate===null)throw Error(F(340));pl()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 13:if(wn(t),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(F(340));pl()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return Rt(ht),null;case 4:return Ro(),null;case 10:return fa(t.type),null;case 22:case 23:return wn(t),Pb(),e!==null&&Rt(ll),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 24:return fa(bt),null;case 25:return null;default:return null}}function pC(e,t){switch(Nb(t),t.tag){case 3:fa(bt),Ro();break;case 26:case 27:case 5:zd(t);break;case 4:Ro();break;case 31:t.memoizedState!==null&&wn(t);break;case 13:wn(t);break;case 19:Rt(ht);break;case 10:fa(t.type);break;case 22:case 23:wn(t),Pb(),e!==null&&Rt(ll);break;case 24:fa(bt)}}function tf(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){Me(t,t.return,s)}}function yi(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 c=n,u=s;try{u()}catch(f){Me(a,c,f)}}}r=r.next}while(r!==i)}}catch(f){Me(t,t.return,f)}}function mC(e){var t=e.updateQueue;if(t!==null){var n=e.stateNode;try{O2(t,n)}catch(r){Me(e,e.return,r)}}}function yC(e,t,n){n.props=gl(e.type,e.memoizedProps),n.state=e.memoizedState;try{n.componentWillUnmount()}catch(r){Me(e,t,r)}}function Pc(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){Me(e,t,a)}}function Rr(e,t){var n=e.ref,r=e.refCleanup;if(n!==null)if(typeof r=="function")try{r()}catch(a){Me(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){Me(e,t,a)}else n.current=null}function vC(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){Me(e,e.return,a)}}function Sy(e,t,n){try{var r=e.stateNode;eD(r,e.type,n,t),r[mn]=t}catch(a){Me(e,e.return,a)}}function gC(e){return e.tag===5||e.tag===3||e.tag===26||e.tag===27&&Oi(e.type)||e.tag===4}function wy(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||gC(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&&Oi(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 Jv(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=ia));else if(r!==4&&(r===27&&Oi(e.type)&&(n=e.stateNode,t=null),e=e.child,e!==null))for(Jv(e,t,n),e=e.sibling;e!==null;)Jv(e,t,n),e=e.sibling}function Zd(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&&Oi(e.type)&&(n=e.stateNode),e=e.child,e!==null))for(Zd(e,t,n),e=e.sibling;e!==null;)Zd(e,t,n),e=e.sibling}function xC(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[Bt]=e,t[mn]=n}catch(i){Me(e,e.return,i)}}var ra=!1,xt=!1,Oy=!1,Lj=typeof WeakSet=="function"?WeakSet:Set,Pt=null;function $k(e,t){if(e=e.containerInfo,lg=ch,e=l2(e),jb(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,c=-1,u=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||(c=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&&++u===a&&(s=l),h===i&&++f===r&&(c=l),(m=d.nextSibling)!==null)break;d=h,h=d.parentNode}d=m}n=s===-1||c===-1?null:{start:s,end:c}}else n=null}n=n||{start:0,end:0}}else n=null;for(og={focusedElem:e,selectionRange:n},ch=!1,Pt=t;Pt!==null;)if(t=Pt,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,Pt=e;else for(;Pt!==null;){switch(t=Pt,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[Bt]=e,Mt(i),r=i;break e;case"link":var l=rS("link","href",a).get(r+(n.href||""));if(l){for(var s=0;sp&&(l=p,p=v,v=l);var y=sj(s,v),g=sj(s,p);if(y&&g&&(m.rangeCount!==1||m.anchorNode!==y.node||m.anchorOffset!==y.offset||m.focusNode!==g.node||m.focusOffset!==g.offset)){var b=d.createRange();b.setStart(y.node,y.offset),m.removeAllRanges(),v>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,ue.T=null,n=ng,ng=null;var i=si,l=da;if(At=0,Uo=si=null,da=0,Ne&6)throw Error(F(331));var s=Ne;if(Ne|=4,TC(i.current),EC(i,i.current,l,n),Ne=s,nf(0,!1),Cn&&typeof Cn.onPostCommitFiberRoot=="function")try{Cn.onPostCommitFiberRoot(Yu,i)}catch{}return!0}finally{Ee.p=a,ue.T=r,GC(e,t)}}function Uj(e,t,n){t=Vn(n,t),t=Qv(e.stateNode,t,2),e=li(e,t,2),e!==null&&(Qu(e,2),qr(e))}function Me(e,t,n){if(e.tag===3)Uj(e,e,n);else for(;t!==null;){if(t.tag===3){Uj(t,e,n);break}else if(t.tag===1){var r=t.stateNode;if(typeof t.type.getDerivedStateFromError=="function"||typeof r.componentDidCatch=="function"&&(oi===null||!oi.has(r))){e=Vn(n,e),n=oC(2),r=li(t,n,2),r!==null&&(sC(n,r,t,e),Qu(r,2),qr(r));break}}t=t.return}}function Ny(e,t,n){var r=e.pingCache;if(r===null){r=e.pingCache=new Dk;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)||(Xb=!0,a.add(n),e=Uk.bind(null,e,t,n),t.then(e,e))}function Uk(e,t,n){var r=e.pingCache;r!==null&&r.delete(t),e.pingedLanes|=e.suspendedLanes&n,e.warmLanes&=~n,ze===e&&(je&n)===n&&(ft===4||ft===3&&(je&62914560)===je&&300>En()-Dp?!(Ne&2)&&qo(e,0):Qb|=n,Io===je&&(Io=0)),qr(e)}function VC(e,t){t===0&&(t=zE()),e=_l(e,t),e!==null&&(Qu(e,t),qr(e))}function qk(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),VC(e,n)}function Hk(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(F(314))}r!==null&&r.delete(t),VC(e,n)}function Fk(e,t){return db(e,t)}var nh=null,Vl=null,ag=!1,rh=!1,Ey=!1,Ja=0;function qr(e){e!==Vl&&e.next===null&&(Vl===null?nh=Vl=e:Vl=Vl.next=e),rh=!0,ag||(ag=!0,Kk())}function nf(e,t){if(!Ey&&rh){Ey=!0;do for(var n=!1,r=nh;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-_n(42|e)+1)-1,i&=a&~(l&~s),i=i&201326741?i&201326741|1:i?i|2:0}i!==0&&(n=!0,qj(r,i))}else i=je,i=Ap(r,r===ze?i:0,r.cancelPendingCommit!==null||r.timeoutHandle!==-1),!(i&3)||Xu(r,i)||(n=!0,qj(r,i));r=r.next}while(n);Ey=!1}}function Gk(){YC()}function YC(){rh=ag=!1;var e=0;Ja!==0&&nD()&&(e=Ja);for(var t=En(),n=null,r=nh;r!==null;){var a=r.next,i=XC(r,t);i===0?(r.next=null,n===null?nh=a:n.next=a,a===null&&(Vl=n)):(n=r,(e!==0||i&3)&&(rh=!0)),r=a}At!==0&&At!==5||nf(e),Ja!==0&&(Ja=0)}function XC(e,t){for(var n=e.suspendedLanes,r=e.pingedLanes,a=e.expirationTimes,i=e.pendingLanes&-62914561;0s)break;var f=c.transferSize,d=c.initiatorType;f&&Vj(d)&&(c=c.responseEnd,l+=f*(c"u"?null:document;function a_(e,t,n){var r=_s;if(r&&typeof t=="string"&&t){var a=Kn(t);a='link[rel="'+e+'"][href="'+a+'"]',typeof n=="string"&&(a+='[crossorigin="'+n+'"]'),eS.has(a)||(eS.add(a),e={rel:e,crossOrigin:n,href:t},r.querySelector(a)===null&&(t=r.createElement("link"),Ht(t,"link",e),Mt(t),r.head.appendChild(t)))}}function fD(e){Oa.D(e),a_("dns-prefetch",e,null)}function dD(e,t){Oa.C(e,t),a_("preconnect",e,t)}function hD(e,t,n){Oa.L(e,t,n);var r=_s;if(r&&e&&t){var a='link[rel="preload"][as="'+Kn(t)+'"]';t==="image"&&n&&n.imageSrcSet?(a+='[imagesrcset="'+Kn(n.imageSrcSet)+'"]',typeof n.imageSizes=="string"&&(a+='[imagesizes="'+Kn(n.imageSizes)+'"]')):a+='[href="'+Kn(e)+'"]';var i=a;switch(t){case"style":i=Ho(e);break;case"script":i=Ts(e)}er.has(i)||(e=Ze({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(rf(i))||t==="script"&&r.querySelector(af(i))||(t=r.createElement("link"),Ht(t,"link",e),Mt(t),r.head.appendChild(t)))}}function pD(e,t){Oa.m(e,t);var n=_s;if(n&&e){var r=t&&typeof t.as=="string"?t.as:"script",a='link[rel="modulepreload"][as="'+Kn(r)+'"][href="'+Kn(e)+'"]',i=a;switch(r){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":i=Ts(e)}if(!er.has(i)&&(e=Ze({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(af(i)))return}r=n.createElement("link"),Ht(r,"link",e),Mt(r),n.head.appendChild(r)}}}function mD(e,t,n){Oa.S(e,t,n);var r=_s;if(r&&e){var a=po(r).hoistableStyles,i=Ho(e);t=t||"default";var l=a.get(i);if(!l){var s={loading:0,preload:null};if(l=r.querySelector(rf(i)))s.loading=5;else{e=Ze({rel:"stylesheet",href:e,"data-precedence":t},n),(n=er.get(i))&&e0(e,n);var c=l=r.createElement("link");Mt(c),Ht(c,"link",e),c._p=new Promise(function(u,f){c.onload=u,c.onerror=f}),c.addEventListener("load",function(){s.loading|=1}),c.addEventListener("error",function(){s.loading|=2}),s.loading|=4,Nd(l,t,r)}l={type:"stylesheet",instance:l,count:1,state:s},a.set(i,l)}}}function yD(e,t){Oa.X(e,t);var n=_s;if(n&&e){var r=po(n).hoistableScripts,a=Ts(e),i=r.get(a);i||(i=n.querySelector(af(a)),i||(e=Ze({src:e,async:!0},t),(t=er.get(a))&&t0(e,t),i=n.createElement("script"),Mt(i),Ht(i,"link",e),n.head.appendChild(i)),i={type:"script",instance:i,count:1,state:null},r.set(a,i))}}function vD(e,t){Oa.M(e,t);var n=_s;if(n&&e){var r=po(n).hoistableScripts,a=Ts(e),i=r.get(a);i||(i=n.querySelector(af(a)),i||(e=Ze({src:e,async:!0,type:"module"},t),(t=er.get(a))&&t0(e,t),i=n.createElement("script"),Mt(i),Ht(i,"link",e),n.head.appendChild(i)),i={type:"script",instance:i,count:1,state:null},r.set(a,i))}}function tS(e,t,n,r){var a=(a=ri.current)?lh(a):null;if(!a)throw Error(F(446));switch(e){case"meta":case"title":return null;case"style":return typeof n.precedence=="string"&&typeof n.href=="string"?(t=Ho(n.href),n=po(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=Ho(n.href);var i=po(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(rf(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||gD(a,e,n,l.state))),t&&r===null)throw Error(F(528,""));return l}if(t&&r!==null)throw Error(F(529,""));return null;case"script":return t=n.async,n=n.src,typeof n=="string"&&t&&typeof t!="function"&&typeof t!="symbol"?(t=Ts(n),n=po(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(F(444,e))}}function Ho(e){return'href="'+Kn(e)+'"'}function rf(e){return'link[rel="stylesheet"]['+e+"]"}function i_(e){return Ze({},e,{"data-precedence":e.precedence,precedence:null})}function gD(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),Mt(t),e.head.appendChild(t))}function Ts(e){return'[src="'+Kn(e)+'"]'}function af(e){return"script[async]"+e}function nS(e,t,n){if(t.count++,t.instance===null)switch(t.type){case"style":var r=e.querySelector('style[data-href~="'+Kn(n.href)+'"]');if(r)return t.instance=r,Mt(r),r;var a=Ze({},n,{"data-href":n.href,"data-precedence":n.precedence,href:null,precedence:null});return r=(e.ownerDocument||e).createElement("style"),Mt(r),Ht(r,"style",a),Nd(r,n.precedence,e),t.instance=r;case"stylesheet":a=Ho(n.href);var i=e.querySelector(rf(a));if(i)return t.state.loading|=4,t.instance=i,Mt(i),i;r=i_(n),(a=er.get(a))&&e0(r,a),i=(e.ownerDocument||e).createElement("link"),Mt(i);var l=i;return l._p=new Promise(function(s,c){l.onload=s,l.onerror=c}),Ht(i,"link",r),t.state.loading|=4,Nd(i,n.precedence,e),t.instance=i;case"script":return i=Ts(n.src),(a=e.querySelector(af(i)))?(t.instance=a,Mt(a),a):(r=n,(a=er.get(i))&&(r=Ze({},n),t0(r,a)),e=e.ownerDocument||e,a=e.createElement("script"),Mt(a),Ht(a,"link",r),e.head.appendChild(a),t.instance=a);case"void":return null;default:throw Error(F(443,t.type))}else t.type==="stylesheet"&&!(t.state.loading&4)&&(r=t.instance,t.state.loading|=4,Nd(r,n.precedence,e));return t.instance}function Nd(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 xD(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 l_(e){return!(e.type==="stylesheet"&&!(e.state.loading&3))}function bD(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=Ho(r.href),i=t.querySelector(rf(a));if(i){t=i._p,t!==null&&typeof t=="object"&&typeof t.then=="function"&&(e.count++,e=oh.bind(e),t.then(e,e)),n.state.loading|=4,n.instance=i,Mt(i);return}i=t.ownerDocument||t,r=i_(r),(a=er.get(a))&&e0(r,a),i=i.createElement("link"),Mt(i);var l=i;l._p=new Promise(function(s,c){l.onload=s,l.onerror=c}),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=oh.bind(e),t.addEventListener("load",n),t.addEventListener("error",n))}}var $y=0;function jD(e,t){return e.stylesheets&&e.count===0&&Cd(e,e.stylesheets),0$y?50:800)+t);return e.unsuspend=n,function(){e.unsuspend=null,clearTimeout(r),clearTimeout(a)}}:null}function oh(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)Cd(this,this.stylesheets);else if(this.unsuspend){var e=this.unsuspend;this.unsuspend=null,e()}}}var sh=null;function Cd(e,t){e.stylesheets=null,e.unsuspend!==null&&(e.count++,sh=new Map,t.forEach(SD,e),sh=null,oh.call(e))}function SD(e,t){if(!(t.state.loading&4)){var n=sh.get(e);if(n)var r=n.get(null);else{n=new Map,sh.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(p_)}catch(e){console.error(e)}}p_(),SE.exports=wp;var TD=SE.exports;const PD=_e(TD);/** + * react-router v7.17.0 + * + * 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 fS="popstate";function dS(e){return typeof e=="object"&&e!=null&&"pathname"in e&&"search"in e&&"hash"in e&&"state"in e&&"key"in e}function MD(e={}){function t(r,a){var u;let i=(u=a.state)==null?void 0:u.masked,{pathname:l,search:s,hash:c}=i||r.location;return mg("",{pathname:l,search:s,hash:c},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:iu(a)}return RD(t,n,null,e)}function tt(e,t){if(e===!1||e===null||typeof e>"u")throw new Error(t)}function br(e,t){if(!e){typeof console<"u"&&console.warn(t);try{throw new Error(t)}catch{}}}function $D(){return Math.random().toString(36).substring(2,10)}function hS(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 mg(e,t,n=null,r,a){return{pathname:typeof e=="string"?e:e.pathname,search:"",hash:"",...typeof t=="string"?Ps(t):t,state:n,key:t&&t.key||r||$D(),mask:a}}function iu({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 Ps(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 RD(e,t,n,r={}){let{window:a=document.defaultView,v5Compat:i=!1}=r,l=a.history,s="POP",c=null,u=f();u==null&&(u=0,l.replaceState({...l.state,idx:u},""));function f(){return(l.state||{idx:null}).idx}function d(){s="POP";let p=f(),y=p==null?null:p-u;u=p,c&&c({action:s,location:v.location,delta:y})}function h(p,y){s="PUSH";let g=dS(p)?p:mg(v.location,p,y);u=f()+1;let b=hS(g,u),x=v.createHref(g.mask||g);try{l.pushState(b,"",x)}catch(w){if(w instanceof DOMException&&w.name==="DataCloneError")throw w;a.location.assign(x)}i&&c&&c({action:s,location:v.location,delta:1})}function m(p,y){s="REPLACE";let g=dS(p)?p:mg(v.location,p,y);u=f();let b=hS(g,u),x=v.createHref(g.mask||g);l.replaceState(b,"",x),i&&c&&c({action:s,location:v.location,delta:0})}function j(p){return kD(a,p)}let v={get action(){return s},get location(){return e(a,l)},listen(p){if(c)throw new Error("A history only accepts one active listener");return a.addEventListener(fS,d),c=p,()=>{a.removeEventListener(fS,d),c=null}},createHref(p){return t(a,p)},createURL:j,encodeLocation(p){let y=j(p);return{pathname:y.pathname,search:y.search,hash:y.hash}},push:h,replace:m,go(p){return l.go(p)}};return v}function kD(e,t,n=!1){let r="http://localhost";e&&(r=e.location.origin!=="null"?e.location.origin:e.location.href),tt(r,"No window.location.(origin|href) available to create URL");let a=typeof t=="string"?t:iu(t);return a=a.replace(/ $/,"%20"),!n&&a.startsWith("//")&&(a=r+a),new URL(a,r)}function m_(e,t,n="/"){return DD(e,t,n,!1)}function DD(e,t,n,r,a){let i=typeof t=="string"?Ps(t):t,l=ba(i.pathname||"/",n);if(l==null)return null;let s=LD(e),c=null,u=XD(l);for(let f=0;c==null&&f{let f={relativePath:u===void 0?l.path||"":u,caseSensitive:l.caseSensitive===!0,childrenIndex:s,route:l};if(f.relativePath.startsWith("/")){if(!f.relativePath.startsWith(r)&&c)return;tt(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=mr([r,f.relativePath]),h=n.concat(f);l.children&&l.children.length>0&&(tt(l.index!==!0,`Index routes must not have child routes. Please remove all child routes from route path "${d}".`),y_(l.children,t,h,d,c)),!(l.path==null&&!l.index)&&t.push({path:d,score:GD(d,l.index),routesMeta:h})};return e.forEach((l,s)=>{var c;if(l.path===""||!((c=l.path)!=null&&c.includes("?")))i(l,s);else for(let u of v_(l.path))i(l,s,!0,u)}),t}function v_(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=v_(r.join("/")),s=[];return s.push(...l.map(c=>c===""?i:[i,c].join("/"))),a&&s.push(...l),s.map(c=>e.startsWith("/")&&c===""?"/":c)}function zD(e){e.sort((t,n)=>t.score!==n.score?n.score-t.score:KD(t.routesMeta.map(r=>r.childrenIndex),n.routesMeta.map(r=>r.childrenIndex)))}var BD=/^:[\w-]+$/,ID=3,UD=2,qD=1,HD=10,FD=-2,pS=e=>e==="*";function GD(e,t){let n=e.split("/"),r=n.length;return n.some(pS)&&(r+=FD),t&&(r+=UD),n.filter(a=>!pS(a)).reduce((a,i)=>a+(BD.test(i)?ID:i===""?qD:HD),r)}function KD(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 VD(e,t,n=!1){let{routesMeta:r}=e,a={},i="/",l=[];for(let s=0;s{if(f==="*"){let j=s[h]||"";l=i.slice(0,i.length-j.length).replace(/(.)\/+$/,"$1")}const m=s[h];return d&&!m?u[f]=void 0:u[f]=(m||"").replace(/%2F/g,"/"),u},{}),pathname:i,pathnameBase:l,pattern:e}}function YD(e,t=!1,n=!0){br(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,c,u,f)=>{if(r.push({paramName:s,isOptional:c!=null}),c){let d=f.charAt(u+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 XD(e){try{return e.split("/").map(t=>decodeURIComponent(t).replace(/\//g,"%2F")).join("/")}catch(t){return br(!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 ba(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)||"/"}var QD=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;function WD(e,t="/"){let{pathname:n,search:r="",hash:a=""}=typeof e=="string"?Ps(e):e,i;return n?(n=g_(n),n.startsWith("/")?i=mS(n.substring(1),"/"):i=mS(n,t)):i=t,{pathname:i,search:e4(r),hash:t4(a)}}function mS(e,t){let n=dh(t).split("/");return e.split("/").forEach(a=>{a===".."?n.length>1&&n.pop():a!=="."&&n.push(a)}),n.length>1?n.join("/"):"/"}function Ry(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 ZD(e){return e.filter((t,n)=>n===0||t.route.path&&t.route.path.length>0)}function l0(e){let t=ZD(e);return t.map((n,r)=>r===t.length-1?n.pathname:n.pathnameBase)}function Up(e,t,n,r=!1){let a;typeof e=="string"?a=Ps(e):(a={...e},tt(!a.pathname||!a.pathname.includes("?"),Ry("?","pathname","search",a)),tt(!a.pathname||!a.pathname.includes("#"),Ry("#","pathname","hash",a)),tt(!a.search||!a.search.includes("#"),Ry("#","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 c=WD(a,s),u=l&&l!=="/"&&l.endsWith("/"),f=(i||l===".")&&n.endsWith("/");return!c.pathname.endsWith("/")&&(u||f)&&(c.pathname+="/"),c}var g_=e=>e.replace(/\/\/+/g,"/"),mr=e=>g_(e.join("/")),dh=e=>e.replace(/\/+$/,""),JD=e=>dh(e).replace(/^\/*/,"/"),e4=e=>!e||e==="?"?"":e.startsWith("?")?e:"?"+e,t4=e=>!e||e==="#"?"":e.startsWith("#")?e:"#"+e,n4=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 r4(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 mr(t)||"/"}var x_=typeof window<"u"&&typeof window.document<"u"&&typeof window.document.createElement<"u";function b_(e,t){let n=e;if(typeof n!="string"||!QD.test(n))return{absoluteURL:void 0,isExternal:!1,to:n};let r=n,a=!1;if(x_)try{let i=new URL(window.location.href),l=n.startsWith("//")?new URL(i.protocol+n):new URL(n),s=ba(l.pathname,t);l.origin===i.origin&&s!=null?n=s+l.search+l.hash:a=!0}catch{br(!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 j_=["POST","PUT","PATCH","DELETE"];new Set(j_);var i4=["GET",...j_];new Set(i4);var Ms=O.createContext(null);Ms.displayName="DataRouter";var qp=O.createContext(null);qp.displayName="DataRouterState";var S_=O.createContext(!1);function l4(){return O.useContext(S_)}var w_=O.createContext({isTransitioning:!1});w_.displayName="ViewTransition";var o4=O.createContext(new Map);o4.displayName="Fetchers";var s4=O.createContext(null);s4.displayName="Await";var kn=O.createContext(null);kn.displayName="Navigation";var lf=O.createContext(null);lf.displayName="Location";var Sr=O.createContext({outlet:null,matches:[],isDataRoute:!1});Sr.displayName="Route";var o0=O.createContext(null);o0.displayName="RouteError";var O_="REACT_ROUTER_ERROR",c4="REDIRECT",u4="ROUTE_ERROR_RESPONSE";function f4(e){if(e.startsWith(`${O_}:${c4}:{`))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(`${O_}:${u4}:{`))try{let t=JSON.parse(e.slice(40));if(typeof t=="object"&&t&&typeof t.status=="number"&&typeof t.statusText=="string")return new n4(t.status,t.statusText,t.data)}catch{}}function h4(e,{relative:t}={}){tt($s(),"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}=of(e,{relative:t}),s=i;return n!=="/"&&(s=i==="/"?n:mr([n,i])),r.createHref({pathname:s,search:l,hash:a})}function $s(){return O.useContext(lf)!=null}function Hr(){return tt($s(),"useLocation() may be used only in the context of a component."),O.useContext(lf).location}var A_="You should call navigate() in a React.useEffect(), not when your component is first rendered.";function N_(e){O.useContext(kn).static||O.useLayoutEffect(e)}function Hp(){let{isDataRoute:e}=O.useContext(Sr);return e?C4():p4()}function p4(){tt($s(),"useNavigate() may be used only in the context of a component.");let e=O.useContext(Ms),{basename:t,navigator:n}=O.useContext(kn),{matches:r}=O.useContext(Sr),{pathname:a}=Hr(),i=JSON.stringify(l0(r)),l=O.useRef(!1);return N_(()=>{l.current=!0}),O.useCallback((c,u={})=>{if(br(l.current,A_),!l.current)return;if(typeof c=="number"){n.go(c);return}let f=Up(c,JSON.parse(i),a,u.relative==="path");e==null&&t!=="/"&&(f.pathname=f.pathname==="/"?t:mr([t,f.pathname])),(u.replace?n.replace:n.push)(f,u.state,u)},[t,n,i,a,e])}var m4=O.createContext(null);function y4(e){let t=O.useContext(Sr).outlet;return O.useMemo(()=>t&&O.createElement(m4.Provider,{value:e},t),[t,e])}function of(e,{relative:t}={}){let{matches:n}=O.useContext(Sr),{pathname:r}=Hr(),a=JSON.stringify(l0(n));return O.useMemo(()=>Up(e,JSON.parse(a),r,t==="path"),[e,a,r,t])}function v4(e,t){return E_(e,t)}function E_(e,t,n){var p;tt($s(),"useRoutes() may be used only in the context of a component.");let{navigator:r}=O.useContext(kn),{matches:a}=O.useContext(Sr),i=a[a.length-1],l=i?i.params:{},s=i?i.pathname:"/",c=i?i.pathnameBase:"/",u=i&&i.route;{let y=u&&u.path||"";__(s,!u||y.endsWith("*")||y.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=Hr(),d;if(t){let y=typeof t=="string"?Ps(t):t;tt(c==="/"||((p=y.pathname)==null?void 0:p.startsWith(c)),`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 "${c}" but pathname "${y.pathname}" was given in the \`location\` prop.`),d=y}else d=f;let h=d.pathname||"/",m=h;if(c!=="/"){let y=c.replace(/^\//,"").split("/");m="/"+h.replace(/^\//,"").split("/").slice(y.length).join("/")}let j=n&&n.state.matches.length?n.state.matches.map(y=>Object.assign(y,{route:n.manifest[y.route.id]||y.route})):m_(e,{pathname:m});br(u||j!=null,`No routes matched location "${d.pathname}${d.search}${d.hash}" `),br(j==null||j[j.length-1].route.element!==void 0||j[j.length-1].route.Component!==void 0||j[j.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 v=S4(j&&j.map(y=>Object.assign({},y,{params:Object.assign({},l,y.params),pathname:mr([c,r.encodeLocation?r.encodeLocation(y.pathname.replace(/%/g,"%25").replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:y.pathname]),pathnameBase:y.pathnameBase==="/"?c:mr([c,r.encodeLocation?r.encodeLocation(y.pathnameBase.replace(/%/g,"%25").replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:y.pathnameBase])})),a,n);return t&&v?O.createElement(lf.Provider,{value:{location:{pathname:"/",search:"",hash:"",state:null,key:"default",mask:void 0,...d},navigationType:"POP"}},v):v}function g4(){let e=E4(),t=r4(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 x4=O.createElement(g4,null),C_=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(Sr.Provider,{value:this.props.routeContext},O.createElement(o0.Provider,{value:e,children:this.props.component})):this.props.children;return this.context?O.createElement(b4,{error:e},t):t}};C_.contextType=S_;var ky=new WeakMap;function b4({children:e,error:t}){let{basename:n}=O.useContext(kn);if(typeof t=="object"&&t&&"digest"in t&&typeof t.digest=="string"){let r=f4(t.digest);if(r){let a=ky.get(t);if(a)throw a;let i=b_(r.location,n);if(x_&&!ky.get(t))if(i.isExternal||r.reloadDocument)window.location.href=i.absoluteURL||i.to;else{const l=Promise.resolve().then(()=>window.__reactRouterDataRouter.navigate(i.to,{replace:r.replace}));throw ky.set(t,l),l}return O.createElement("meta",{httpEquiv:"refresh",content:`0;url=${i.absoluteURL||i.to}`})}}return e}function j4({routeContext:e,match:t,children:n}){let r=O.useContext(Ms);return r&&r.static&&r.staticContext&&(t.route.errorElement||t.route.ErrorBoundary)&&(r.staticContext._deepestRenderedBoundaryId=t.route.id),O.createElement(Sr.Provider,{value:e},n)}function S4(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);tt(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 c=n==null?void 0:n.onError,u=r&&c?(f,d)=>{var h,m;c(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,j=!1,v=null,p=null;r&&(m=i&&d.route.id?i[d.route.id]:void 0,v=d.route.errorElement||x4,l&&(s<0&&h===0?(__("route-fallback",!1,"No `HydrateFallback` element provided to render during initial hydration"),j=!0,p=null):s===h&&(j=!0,p=d.route.hydrateFallbackElement||null)));let y=t.concat(a.slice(0,h+1)),g=()=>{let b;return m?b=v:j?b=p:d.route.Component?b=O.createElement(d.route.Component,null):d.route.element?b=d.route.element:b=f,O.createElement(j4,{match:d,routeContext:{outlet:f,matches:y,isDataRoute:r!=null},children:b})};return r&&(d.route.ErrorBoundary||d.route.errorElement||h===0)?O.createElement(C_,{location:r.location,revalidation:r.revalidation,component:v,error:m,children:g(),routeContext:{outlet:null,matches:y,isDataRoute:!0},onError:u}):g()},null)}function s0(e){return`${e} must be used within a data router. See https://reactrouter.com/en/main/routers/picking-a-router.`}function w4(e){let t=O.useContext(Ms);return tt(t,s0(e)),t}function O4(e){let t=O.useContext(qp);return tt(t,s0(e)),t}function A4(e){let t=O.useContext(Sr);return tt(t,s0(e)),t}function c0(e){let t=A4(e),n=t.matches[t.matches.length-1];return tt(n.route.id,`${e} can only be used on routes that contain a unique "id"`),n.route.id}function N4(){return c0("useRouteId")}function E4(){var r;let e=O.useContext(o0),t=O4("useRouteError"),n=c0("useRouteError");return e!==void 0?e:(r=t.errors)==null?void 0:r[n]}function C4(){let{router:e}=w4("useNavigate"),t=c0("useNavigate"),n=O.useRef(!1);return N_(()=>{n.current=!0}),O.useCallback(async(a,i={})=>{br(n.current,A_),n.current&&(typeof a=="number"?await e.navigate(a):await e.navigate(a,{fromRouteId:t,...i}))},[e,t])}var yS={};function __(e,t,n){!t&&!yS[e]&&(yS[e]=!0,br(!1,n))}O.memo(_4);function _4({routes:e,manifest:t,future:n,state:r,isStatic:a,onError:i}){return E_(e,void 0,{manifest:t,state:r,isStatic:a,onError:i})}function T_({to:e,replace:t,state:n,relative:r}){tt($s()," may be used only in the context of a component.");let{static:a}=O.useContext(kn);br(!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(Sr),{pathname:l}=Hr(),s=Hp(),c=Up(e,l0(i),l,r==="path"),u=JSON.stringify(c);return O.useEffect(()=>{s(JSON.parse(u),{replace:t,state:n,relative:r})},[s,u,r,t,n]),null}function T4(e){return y4(e.context)}function Oe(e){tt(!1,"A is only ever to be used as the child of element, never rendered directly. Please wrap your in a .")}function P4({basename:e="/",children:t=null,location:n,navigationType:r="POP",navigator:a,static:i=!1,useTransitions:l}){tt(!$s(),"You cannot render a inside another . You should never have more than one in your app.");let s=e.replace(/^\/*/,"/"),c=O.useMemo(()=>({basename:s,navigator:a,static:i,useTransitions:l,future:{}}),[s,a,i,l]);typeof n=="string"&&(n=Ps(n));let{pathname:u="/",search:f="",hash:d="",state:h=null,key:m="default",mask:j}=n,v=O.useMemo(()=>{let p=ba(u,s);return p==null?null:{location:{pathname:p,search:f,hash:d,state:h,key:m,mask:j},navigationType:r}},[s,u,f,d,h,m,r,j]);return br(v!=null,` is not able to match the URL "${u}${f}${d}" because it does not start with the basename, so the won't render anything.`),v==null?null:O.createElement(kn.Provider,{value:c},O.createElement(lf.Provider,{children:t,value:v}))}function M4({children:e,location:t}){return v4(yg(e),t)}function yg(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,yg(r.props.children,i));return}tt(r.type===Oe,`[${typeof r.type=="string"?r.type:r.type.name}] is not a component. All component children of must be a or `),tt(!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=yg(r.props.children,i)),n.push(l)}),n}var Td="get",Pd="application/x-www-form-urlencoded";function Fp(e){return typeof HTMLElement<"u"&&e instanceof HTMLElement}function $4(e){return Fp(e)&&e.tagName.toLowerCase()==="button"}function R4(e){return Fp(e)&&e.tagName.toLowerCase()==="form"}function k4(e){return Fp(e)&&e.tagName.toLowerCase()==="input"}function D4(e){return!!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)}function L4(e,t){return e.button===0&&(!t||t==="_self")&&!D4(e)}var Hf=null;function z4(){if(Hf===null)try{new FormData(document.createElement("form"),0),Hf=!1}catch{Hf=!0}return Hf}var B4=new Set(["application/x-www-form-urlencoded","multipart/form-data","text/plain"]);function Dy(e){return e!=null&&!B4.has(e)?(br(!1,`"${e}" is not a valid \`encType\` for \`

\`/\`\` and will default to "${Pd}"`),null):e}function I4(e,t){let n,r,a,i,l;if(R4(e)){let s=e.getAttribute("action");r=s?ba(s,t):null,n=e.getAttribute("method")||Td,a=Dy(e.getAttribute("enctype"))||Pd,i=new FormData(e)}else if($4(e)||k4(e)&&(e.type==="submit"||e.type==="image")){let s=e.form;if(s==null)throw new Error('Cannot submit a