From ee9fb0149fe1fb2d8146730b7580c57372bc9a32 Mon Sep 17 00:00:00 2001 From: tzy <1042411602@qq.com> Date: Wed, 28 Oct 2026 17:14:56 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=EF=BC=8C=E5=B7=A5=E5=BA=8F?= =?UTF-8?q?=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/resources/application.yml | 2 +- .../resources/jpg/生产及工艺计划模版.xlsx | Bin 0 -> 18383 bytes .../config/DrawingFileQueryProperties.java | 23 + .../NullableQuantityTextConverter.java | 52 + .../dingding/relay/DingTodoRelayClient.java | 343 +++++++ .../ruoyi/common/helper/JobLoginContext.java | 104 ++ .../common/utils/DrawingDelayCalculator.java | 85 ++ .../common/utils/DrawingDelayResult.java | 28 + .../com/ruoyi/common/utils/JsyyOrderNo.java | 21 + .../ruoyi/common/utils/ProjectStatusLock.java | 108 +++ .../utils/file/PlmDrawingFileNames.java | 65 ++ .../system/constant/DingTodoSceneCodes.java | 25 + .../ruoyi/system/constant/DrawingMode.java | 75 ++ .../controller/DesignTaskController.java | 114 +++ .../system/controller/DevStatController.java | 35 + .../system/controller/DingTodoController.java | 53 ++ .../DrawingRouteOperLogController.java | 57 ++ .../controller/MesDrawingController.java | 112 +++ .../OrderProProgressReportController.java | 77 ++ .../controller/ProcessOrderProController.java | 7 + .../controller/ProcessRouteController.java | 4 +- .../controller/VmiMaterialController.java | 132 +++ .../WorkAlertGroupPushController.java | 105 ++ .../com/ruoyi/system/domain/DingTodoLog.java | 54 ++ .../ruoyi/system/domain/DingTodoScene.java | 54 ++ .../system/domain/DrawingRouteOperLog.java | 59 ++ .../system/domain/ProcessDesignReport.java | 37 + .../system/domain/ProcessDesignTask.java | 57 ++ .../ruoyi/system/domain/ProcessOrderPro.java | 16 + .../com/ruoyi/system/domain/RatioModel.java | 25 + .../com/ruoyi/system/domain/VmiMaterial.java | 77 ++ .../system/domain/VmiPurchaseReqLog.java | 55 ++ .../ruoyi/system/domain/VmiStockAlertLog.java | 62 ++ .../domain/WorkAlertGroupPushConfig.java | 50 + .../domain/bo/DesignTaskDesignDimBo.java | 19 + .../system/domain/bo/DesignTaskQueryBo.java | 30 + .../system/domain/bo/DesignTaskReportBo.java | 33 + .../domain/bo/DrawingRouteOperLogBo.java | 23 + .../system/domain/bo/OrderProBomStatusBo.java | 23 + .../domain/bo/OrderProProgressReportBo.java | 34 + .../ruoyi/system/domain/bo/VmiMaterialBo.java | 67 ++ .../system/domain/bo/VmiPurchaseReqLogBo.java | 25 + .../domain/bo/VmiReplenishConfirmBo.java | 25 + .../system/domain/bo/VmiReplenishLineBo.java | 21 + .../domain/bo/VmiReplenishPreviewBo.java | 16 + .../system/domain/bo/VmiStockAlertLogBo.java | 22 + .../domain/bo/WorkAlertGroupPushBo.java | 21 + .../domain/bo/WorkAlertGroupPushConfigBo.java | 41 + .../domain/bo/plm/PlmGeneralDrawingUtils.java | 98 ++ .../domain/dto/OrderProProgressTimeAgg.java | 18 + .../domain/dto/ratio/RatioBomParseResult.java | 40 + .../dto/ratio/RatioImportResultDTO.java | 24 + .../domain/dto/ratio/RatioModelOptionDTO.java | 11 + .../system/domain/vo/DesignAccountSyncVo.java | 23 + .../system/domain/vo/DesignTaskBoardVo.java | 38 + .../domain/vo/DesignTaskDayHoursVo.java | 22 + .../system/domain/vo/DesignTaskReportVo.java | 32 + .../system/domain/vo/DesignTaskSummaryVo.java | 21 + .../ruoyi/system/domain/vo/DesignTaskVo.java | 64 ++ .../domain/vo/DrawingRouteOperLogVo.java | 35 + .../domain/vo/OrderProDrawingImportVo.java | 35 + .../vo/OrderProProgressDesignerSummaryVo.java | 31 + .../vo/OrderProProgressReportExportVo.java | 140 +++ .../vo/OrderProProgressReportOverviewVo.java | 34 + .../domain/vo/OrderProProgressReportVo.java | 76 ++ .../system/domain/vo/ProcessOrderProVo.java | 16 + .../domain/vo/VmiLowStockAlertExportVo.java | 54 ++ .../ruoyi/system/domain/vo/VmiMaterialVo.java | 77 ++ .../system/domain/vo/VmiPurchaseReqLogVo.java | 54 ++ .../domain/vo/VmiReplenishPreviewItemVo.java | 42 + .../system/domain/vo/VmiStockAlertLogVo.java | 64 ++ .../domain/vo/WorkAlertGroupPushConfigVo.java | 35 + .../domain/vo/mes/MesDrawingItemVo.java | 68 ++ .../domain/vo/mes/MesDrawingListVo.java | 30 + .../system/job/DrawingDelayRefreshJob.java | 32 + .../system/job/VmiSafeStockCheckJob.java | 32 + .../system/mapper/DingTodoLogMapper.java | 10 + .../system/mapper/DingTodoSceneMapper.java | 10 + .../mapper/DrawingRouteOperLogMapper.java | 8 + .../mapper/OrderProProgressReportMapper.java | 24 + .../mapper/ProcessDesignReportMapper.java | 10 + .../mapper/ProcessDesignTaskMapper.java | 10 + .../ruoyi/system/mapper/RatioModelMapper.java | 7 + .../system/mapper/VmiMaterialMapper.java | 11 + .../mapper/VmiPurchaseReqLogMapper.java | 11 + .../system/mapper/VmiStockAlertLogMapper.java | 11 + .../WorkAlertGroupPushConfigMapper.java | 11 + .../service/DrawingFileQueryService.java | 248 +++++ .../DrawingRouteOperLogRenotifyService.java | 178 ++++ .../ruoyi/system/service/DrawingSuccess.java | 46 + .../system/service/IDesignTaskService.java | 59 ++ .../service/IDingTodoDispatchService.java | 29 + .../service/IDrawingRouteOperLogService.java | 54 ++ .../system/service/IMesDrawingService.java | 26 + .../service/IOrderProDrawingDelayService.java | 13 + .../IOrderProProgressReportService.java | 24 + .../service/IProcessOrderProService.java | 5 + .../service/IRatioBomImportService.java | 13 + .../system/service/IVmiMaterialService.java | 50 + .../IWorkAlertGroupPushConfigService.java | 25 + .../PlmDrawingArriveNotifyService.java | 297 ++++++ .../service/PlmPlanCompleteNotifyService.java | 338 +++++++ .../service/SkipProcessNotifyService.java | 250 +++++ .../service/impl/DesignTaskServiceImpl.java | 895 ++++++++++++++++++ .../impl/DingTodoDispatchServiceImpl.java | 226 +++++ .../impl/DrawingRouteOperLogServiceImpl.java | 134 +++ .../service/impl/MesDrawingServiceImpl.java | 464 +++++++++ .../impl/OrderProDrawingDelayServiceImpl.java | 109 +++ .../OrderProProgressReportServiceImpl.java | 714 ++++++++++++++ .../impl/ProcessOrderProServiceImpl.java | 327 ++++++- .../impl/RatioBomImportServiceImpl.java | 193 ++++ .../service/impl/VmiMaterialServiceImpl.java | 891 +++++++++++++++++ .../WorkAlertGroupPushConfigServiceImpl.java | 138 +++ .../service/support/RatioBomExcelParser.java | 343 +++++++ .../service/support/VmiInventoryQuery.java | 63 ++ .../system/OrderProProgressReportMapper.xml | 65 ++ .../impl/ProcessOrderProDwgFileNameTest.java | 37 + .../support/RatioBomExcelParserTest.java | 173 ++++ 118 files changed, 10324 insertions(+), 45 deletions(-) create mode 100644 ruoyi-admin/src/main/resources/jpg/生产及工艺计划模版.xlsx create mode 100644 ruoyi-common/src/main/java/com/ruoyi/common/config/DrawingFileQueryProperties.java create mode 100644 ruoyi-common/src/main/java/com/ruoyi/common/convert/NullableQuantityTextConverter.java create mode 100644 ruoyi-common/src/main/java/com/ruoyi/common/dingding/relay/DingTodoRelayClient.java create mode 100644 ruoyi-common/src/main/java/com/ruoyi/common/helper/JobLoginContext.java create mode 100644 ruoyi-common/src/main/java/com/ruoyi/common/utils/DrawingDelayCalculator.java create mode 100644 ruoyi-common/src/main/java/com/ruoyi/common/utils/DrawingDelayResult.java create mode 100644 ruoyi-common/src/main/java/com/ruoyi/common/utils/JsyyOrderNo.java create mode 100644 ruoyi-common/src/main/java/com/ruoyi/common/utils/ProjectStatusLock.java create mode 100644 ruoyi-common/src/main/java/com/ruoyi/common/utils/file/PlmDrawingFileNames.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/constant/DingTodoSceneCodes.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/constant/DrawingMode.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/controller/DesignTaskController.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/controller/DevStatController.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/controller/DingTodoController.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/controller/DrawingRouteOperLogController.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/controller/MesDrawingController.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/controller/OrderProProgressReportController.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/controller/VmiMaterialController.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/controller/WorkAlertGroupPushController.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/domain/DingTodoLog.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/domain/DingTodoScene.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/domain/DrawingRouteOperLog.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/domain/ProcessDesignReport.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/domain/ProcessDesignTask.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/domain/RatioModel.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/domain/VmiMaterial.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/domain/VmiPurchaseReqLog.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/domain/VmiStockAlertLog.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/domain/WorkAlertGroupPushConfig.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/DesignTaskDesignDimBo.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/DesignTaskQueryBo.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/DesignTaskReportBo.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/DrawingRouteOperLogBo.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/OrderProBomStatusBo.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/OrderProProgressReportBo.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/VmiMaterialBo.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/VmiPurchaseReqLogBo.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/VmiReplenishConfirmBo.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/VmiReplenishLineBo.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/VmiReplenishPreviewBo.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/VmiStockAlertLogBo.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/WorkAlertGroupPushBo.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/WorkAlertGroupPushConfigBo.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/plm/PlmGeneralDrawingUtils.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/OrderProProgressTimeAgg.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/ratio/RatioBomParseResult.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/ratio/RatioImportResultDTO.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/ratio/RatioModelOptionDTO.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/DesignAccountSyncVo.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/DesignTaskBoardVo.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/DesignTaskDayHoursVo.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/DesignTaskReportVo.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/DesignTaskSummaryVo.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/DesignTaskVo.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/DrawingRouteOperLogVo.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/OrderProDrawingImportVo.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/OrderProProgressDesignerSummaryVo.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/OrderProProgressReportExportVo.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/OrderProProgressReportOverviewVo.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/OrderProProgressReportVo.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/VmiLowStockAlertExportVo.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/VmiMaterialVo.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/VmiPurchaseReqLogVo.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/VmiReplenishPreviewItemVo.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/VmiStockAlertLogVo.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/WorkAlertGroupPushConfigVo.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/mes/MesDrawingItemVo.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/mes/MesDrawingListVo.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/job/DrawingDelayRefreshJob.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/job/VmiSafeStockCheckJob.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/mapper/DingTodoLogMapper.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/mapper/DingTodoSceneMapper.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/mapper/DrawingRouteOperLogMapper.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/mapper/OrderProProgressReportMapper.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/mapper/ProcessDesignReportMapper.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/mapper/ProcessDesignTaskMapper.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/mapper/RatioModelMapper.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/mapper/VmiMaterialMapper.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/mapper/VmiPurchaseReqLogMapper.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/mapper/VmiStockAlertLogMapper.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/mapper/WorkAlertGroupPushConfigMapper.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/service/DrawingFileQueryService.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/service/DrawingRouteOperLogRenotifyService.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/service/DrawingSuccess.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/service/IDesignTaskService.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/service/IDingTodoDispatchService.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/service/IDrawingRouteOperLogService.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/service/IMesDrawingService.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/service/IOrderProDrawingDelayService.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/service/IOrderProProgressReportService.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/service/IRatioBomImportService.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/service/IVmiMaterialService.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/service/IWorkAlertGroupPushConfigService.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/service/PlmDrawingArriveNotifyService.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/service/PlmPlanCompleteNotifyService.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/service/SkipProcessNotifyService.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DesignTaskServiceImpl.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DingTodoDispatchServiceImpl.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DrawingRouteOperLogServiceImpl.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/service/impl/MesDrawingServiceImpl.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/service/impl/OrderProDrawingDelayServiceImpl.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/service/impl/OrderProProgressReportServiceImpl.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/service/impl/RatioBomImportServiceImpl.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/service/impl/VmiMaterialServiceImpl.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/service/impl/WorkAlertGroupPushConfigServiceImpl.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/service/support/RatioBomExcelParser.java create mode 100644 ruoyi-system/src/main/java/com/ruoyi/system/service/support/VmiInventoryQuery.java create mode 100644 ruoyi-system/src/main/resources/mapper/system/OrderProProgressReportMapper.xml create mode 100644 ruoyi-system/src/test/java/com/ruoyi/system/service/impl/ProcessOrderProDwgFileNameTest.java create mode 100644 ruoyi-system/src/test/java/com/ruoyi/system/service/support/RatioBomExcelParserTest.java diff --git a/ruoyi-admin/src/main/resources/application.yml b/ruoyi-admin/src/main/resources/application.yml index 1059401..323ba8f 100644 --- a/ruoyi-admin/src/main/resources/application.yml +++ b/ruoyi-admin/src/main/resources/application.yml @@ -332,7 +332,7 @@ dingtalk: #工艺审核互动卡片回调路由Key callback-route-key: update_route_status2 # 出图完成 / PLM图纸到达通知(钉钉 userId/staffId,多人逗号分隔;以 0 开头的须加引号) - notify-user-id: "293047444820124967,0108392663561185496,03610242692523410847" + notify-user-id: "293047444820124967,0108392663561185496,03610242692523410847,283633411029629036" # 工艺审核互动卡片 route-approval-id: "0108392663561185496" # 工艺计划推送用户;无需做工艺且出图完成后、PLM 总装BOM与工艺都到齐后通知生管 diff --git a/ruoyi-admin/src/main/resources/jpg/生产及工艺计划模版.xlsx b/ruoyi-admin/src/main/resources/jpg/生产及工艺计划模版.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..f17c0d744ce35a6fbd7f44d7309e32c175113662 GIT binary patch literal 18383 zcmaJ}b9`UT(vNN1wr$%^8r!y=GLy*H44hV5FQV zbsYUZi_sv+Jn2}s?RgxJJ`)>S6!aB;M%hlF{bIb5(SFF193?;qZu1G^^M_fnsP9dc zn%G<_^l42DSpsEnqV*~nO)x-H`m%MYxCrL-i7Uj3_rX=KG-Aps@&f(8rfc`$RF3VR9((fZ{3znhzBH_Tp)h5LrTF~SMZe&s{H7BzPWWendL5D z=(POh@Y7?X9xIKi8#Nq-Jg9sN;DKc#e}K}qYj1*TQ=o7peZyG{YQ62Ld>5r4&6@YL zmc-?GoA@0zj1EG;bP^19X7c^RPOZjgZ=r8K5(k7?sKF$cEQEXY*j@`;COA-6EYEto z#%Ku~Q5J${T)RoS2-E%@(>^csS9X8bJb{D|rRclngWole{5Q?JI62$d{M3D8g0y@; z1C0N<#B&(cJ&VPv_36B;mLAeBKwvF(*{pNcC`t<#dkG@c7|p zw>zC9xkwOqIxskN8MdrtvTcJ8y$Bqu^LeFSluk7(pht@Wpo1WWm=wgjz877BKW{e#9i@YGps>(GMN*QOBniWNVMaZh!#RXAj!Ni?mS3TAe639b5T6gi zeWBXYSlpy}x|GCDHWgGe^%V^C5DCiD3IY9e$SlRPtKM*vFFqYRqwWzkh2@8GH7&Gs z&+a;9YDkvR*0o}P+aIehl`F2(u96C}4F5Vv#<8~NGF{Zgb{BV6l%1o}Mlg78V{`EW z{KY}A_*!xc;HTFAdBS_2%mCc2e@%7&OaS+rz|GFl%E{cs#QEQ*MPU3PigzhX?^7qn zztR3;Vf;Cc(Q0e9`3wj?dKPcJS6+aLUMNb9lJ%WZ3zqcLbZH={3|8EqUw+^^!49mF z!q;NPz%w64!ImG#l8BL$84oDKBaion7xKa9IiK(3@VlxzMHS>(2pDBE!TT{qR4?gu zS2)sb?guc2_!B1M@lhnx+Gsz4rzlulMittV?-6~5q8r>y`J~T6fks+;$~84FXP&g1 zU_cpaf58yt-L(J8Xi!Qe3G%Tt@Ohtb%y{w+5oQ+}Wb*SOS57@j^L|!nY!v;KG?AYD zsIbW+Pwn_TUAfNkN4cyBKS5&z>d(6`X&(R`zy>=-9SZn9Ivxk7S$bUYG+)VR`D&Rs zkRg0pk+F$Ux-^zX9XwXu=_#*SDklI!ZM>DJ`O?pB{J3ivjvlUuIKIDvV==zP;kU$& z2L#WZ{&8?(X_pwPKf*=>$Lk?R0DqJpv@{#IaKk=(+UoVg3|Tc0C{4kzhK>6(H`F|! zR>C&dL)PIv%_=^M2>GIQAR>Pmhtvu)cP+-5kPiRYA>&0KzZB8NJO=4aKE`#VlXY~_ zsa=uwGjr$}nR3*#P}$QFs@~|Hnhe9DjFZ*Qg!p8&fg}2xF_x+fgPKg0(WDMV>DV6s z@$iBtM)_@`oa9_DYcDahi+;`$tHn9k5g;p?l|jYgRHa3JNH&bg?2A%5h^?wley>N| zDh6+Ru-h@)R&r zm7HQ@$VUO}<8RS$!Vp*f2dM~rm08I?vm&T_ivWZlMuq?!+T$t#nZ661!NfVB*D(u_ zeHdW}HeL-`$0n$!+v^4R?@iAtuE4ic?j(EmO`Wc(2x7hCQTrBk}HJ%+t%T( z;bFFnwR@UIy5P&;1Hdb5bWIX?i=@3_fR|U77EPj$(3#>Kg`ALIz<0j6{v+sPfjks;Fg&7dD3-cYr#kBZ4uFH5WEMnxDy_G{;pQG=bq(WzYX-WLyHW&pI|#Mh zPJ;EkXc#u^mY}+6^&29PB^0Tfh9q!b*zI0AL=e=&`5`GE7wSn?x)Z7FOMGC?3um1W zsT{A4Xs+@L73wP0g7)&+SjJNKphl78Ac)Q2p8TnaGz`l{#@)h=!ObigUi|aT}t8|d&+1K<85A#@ML3MaXoe2jLsy`H2Jt1szqiK&5^(< z^uBN_Y7@4YG;|CkfRy}WRi!aa4bOB3>ja_>3E;mui1U@gYab2%q5_-PC zd;H(W&oe(io*QG_MYf^dS{`}Hq9$c5Xk){nm(jEPuK7hs)F8i03Gm+S#Wir60th_HO4x_7FwZw2QT!fPMIqZ` z(rxt)`R~A6+FJoccn97g#D6qv%s+vbtTz4=cpI@SZ~h(h(;cWvjRh8U7^PT~MyG+0 zCH|Oekf&SIjUFWa(G51-}OeLJkvsXFB3mZ@NKZOw!Mr(4K+7(~H1xrhuu}&&oex9`< z7a0&OE~BcaYQ;pGq^W{&JU~P2jet(KtL-tbSEIh@tC-}H>J@Fh0VA%B#mijiy+(kc z7^zNDc`maXVf8D#k%hw{!e3E}GA2nn!^TKwhLlKoq_F=S_kdqW^@G5|C4K2d&9C_T zUI->)#3dy%;G}PHyhJV;?mSrcoTdD%5fK3w>!C6w_ACs`_ST59X79+zWxHj0ik)Zu zxLr(?!l#)rb|Tgdn{Qv(oZU=UrHj9A6?wtpuBM#546l*D--Pn9rr!0nKikUru6{8X zXLANu+YxIT4+XFh)<|dqRXZ#~3t!#1+K@OVl-gt=wZ7kt&S>pEjGn2n~BDYaDZv@BMBbMZ;7F&0s+#>3p?`PKJbDgvz z&ME_LatWN~r9uxXhWW7rgtFeVcsJq~70ABq|SOO7xDR$N!8XmVf^^Y~5cJAymy^ zk;PLhQ}gn0?IkJk*Zhg2^JOrkp2*d()u?G4)2XO18hpMhm@GYA=IF!i{7*L42`APeIE~3r_oQQDL7J%fC zUpeD1M&PR`I7w?=$C%kKI!ZGV8+t+wdb|626G)=Jg^CtuqRqzHrz1jMel*gG3_T@& zMbev)1Z`!z)Cp-}B&W^c#~Vm3Ml=f~osG~qOaBt8Ikn+IKFx!LpgAxG>Bpb!CZHqa zXlIc!CZA=CjF60;T~?M^h9ajjuc!mLH?2dpL{6yLMVPR?prpQ?++J>y85mi16Fx^W z5c_I~Kf!=wK9ZB9wrd%yGP#A}r4IgsV<4L-A2T}XDZIzA$SU20aPtBATZUh{>S@5N z4QKT|?3>#CD784F>V z>PBbjGa3$&&=aZcHxcn%gn1Fyf+}x6BYj^zLuuNaq^+1*Hgf#^8}o4@O|(W zgbHHF_93Mo5!vf?&q=8pQHwUQ6E7I8vSThX0{c)da3mNEWZ2*tmL^x!uouWnUS{f^ z;Qx**+YthpgZDKv(|^Vl>n~iXj@qs+U%cCJm zC`o8Ye)b8QHa*nn-l%xSN zis|*9Yg1b}jR33A!Uq^|c_g$M+E_VGS`}F$b1{@XdS$h-lvCPQ6g?UV(8i`mox+w% zUfdi%?lF3^`0WJpu?Y9G#nR|Yd#m!4F1{w=|~x*9|C?Uc>3;bqYB zs;Y9TQsh+WG*uv1hjgkLi7Gr>NTL^>CR`UZ>$g; zy1c|2*XUDvjb9PMmgkIF^5fO;5nHm3LQOxJ2G%}$5iVrfihI7S`|6v4?Q7v^UJ!yp9{QbVS! zF)lI-BXU%F8f0b=JdkY$Knpt{z8^pbS*d8)eI>sAv0;=#o91!yHfo??Xqod&z~MI~ zW67I{>r`lSfr1w?yP63`pfr$c58i9FGt7b*tcjqrh+gvr=Xq1(l^B{>;&EGJ4=l|) z;_;o;T_Md6Ho0ALR*O`+@<19DA#CR6s_Nzp_$ZdW4?ZJ9K#)&ym7q*V!g#cCYkbCL z0Wv@0$f1oF!%k@91ZTjZ_o|GJAmLUu$t1LeVUSmkxUj?T6okFgo}f*0SBF9|N17VF zEeY20L7C!g%mQGHz3K$O>P~6nK3X3n;n2hl`XzNvSl7U;_CEq}FGRJhsJl>qH_^zs z%#6%m$v_f){)vrX*Tr#Lw(^xzoSHi+$AYa0jyj>dC^CfaoHf8Aoy3#%yM?uVo9;VM zuRy2TQ2a(v+C5IWJ;6>3T|J&9KHNt0x_kr89y3a zx|Z4cw*Wa*H{^cLiC;q%nGY$hvTCj>$Vjq5RR;r&7oR^MK{eB^V9=_T0oM&~)-6QQ znXhyM88y72)xhiX0)c+VMuL{^26x+Gb!PWbh%gqhUSfP8$*K={|Go@c0R;=Pbw)Su zk&Kfk@6oN#ePGrK1%02SWTcuX+tSVdvQp@T#yzV(9?*UYv6MMSVYA|{a;1*oI zCp2-`j+;~En)mWt#LO0AlZC9%GFeZfEQAj`)FD3Es{D>ct1elMpG2AxN9IWCGDNb;<6lS|TVmV^%9p@cwLy+3P@rMIgNDpV z#DOBi5Mo1<;0QILjaWl?)Ct&z#btpq;R|Kc#%ZXt9Uo@88ly#^008=^|8Zr*{u3Wr8rsThTnN7W%!mB$&$??&gbqBCTyY4w`$!PY zcat1Ym-DIfY#>j^(^8S%nn%t&7XnGsTfSPSRShv>dRvLh?)e^wCP&L8DMnZgSTyx3n$^ucegaYM1&x@~IQj{>$d`SP&fI`F>9fb5u z&o}}zuP`f?W^^Q5z04!0e~k`-s@poN+sb_E|pS=c~2`2;5a}es)uM`IO+8aXLkz zO=AyN*-Mx~GGV9?{3eD}3u}cXc`?QB=G7Rm;6#Wi`q+B~Bp1qa?^xFv&JgDB z{JXhd)~DZZOkDB^sLPee&*M{v5J^G79s#|p_&gWxwFD!i8VR$w@3HMIC$&f(5O5Qz zL5BmqamN+Vht@TZde->i2*+A;x1h>l+EPv6aB_DH%Pi=n?kVSbESYr*%&S!JSmx+V zRrTu&aJ8bsnB+@y;vkwC>p4`#wVaqo6Z3W@$a>H{=>xr-=?o@EPmJe*58#K7ir&re zr%c9T;(bt=fO3G})&?+F?7mp`@qX1XaSvxwrL@&HE@I-DEy^^fkT9X6P)JX%-xSo| zSIM8XQl26Sx#dL*a)R;h?{S0C(ce}Af?a}JyVkX!cwyVu62@$xgoYioQnqDDGRSZ& zR|=&lm4*c^JdqO?q-;+`!8`$noDaat!{LL1@-y0l^h{t=+6w~!d#(&Byf6cuafdMARNQ3Sv7m+8;j{(`#FrzXFFMI#o+RnO_Ere0D z@m*3-u+cTAC}E=~6+JkD0$#$<(BB3FZpP3s+?E4!AqX>EOAsg z3v{eL8B#s zgRtY2mpjoQMIsNlm!AR4AAm1E7ngCY&*QXWJ*Pc<0aATUdiVmV>MiDN9!}*eJgL- zarywq*h$STKk@Cqcy@kzGT%KrChpEAjVpJmd0aXWnpPRJXJ-4qn3aH2^5{R zfjmpIe9oX1?0N9VuhCfZ%nr+~$IWdoQ%==C0!C3@Ewk@zt6kdXx?AOYIv0bwTBHDW zF5~(9X2|6sPp)4SnlqjnOW`+Pd(7$BbG{(CPWKIuW5ku3qn3RmD^W~n5emw-yulqB zIBw3CopGXkGK}m>%A+s-_Naj`mR2D7EBepwx1RwC@1y8!ZenBd z%S-oHK*C%CRNU^nAt%B7?L7T|vR}l%hU-*Q+76o?p$q++pTyNw0Nu-2DG$XlR zaiHH0&Wdx^1-O}pAn$tkP`OLK4l9{*Cr3i)V?K8$tGCjrD99axn2c==+aB;rc>)z=-&T0IH`9{Ypldgu;Z^7d zo9l?XWOtkKVtOPGtbouXE8c#hjrK)`qX%VmGq;! zdIvm+PGqLfN+HIsVh8)j=}Y#hUXY`=6YN&m+3k1Q@ouoeq=GqC=f;`!GdeYGA+jhu zj-696pD(Lane>VZuqH00lw>r?8J4ty@)-C40)UhWB)5A!{m3W+sBVa)Msgj(NP|kU zL9Pr;n}@;y8rVwAoon0O;Pd6*&b7tK+e15)9C+c(UCT{+-BMn~n zbL?-=7lrrtcKlslmo|L2RJv&o&E4L&cd`6ihj?s>g2(Ntc6y!O*Dp(cFVB5DKC$_= zkofT+(C2h~mv}WGXeIeZ*S$u*j2{)F!c77UFHY?@QfomwxW(r~)`ndn@EyJ&qF*eY zL=3uMTz?{o`E))I0m@?-b;f)K(}T;JinudF6TO~z{VBrB|4Xga8J$!m$h{dR1cgKE z$q|yl%mtcRe8HC|N|86Bf>hykazG8F+3MInB36+$-)6*$7f$j&hfuzZ}{DW|8CcYt8edmE^7q43!v zp)^d}DqC!Ivb8?wS{{xxi1tyiX9UMYof!~`5z>_@M`wo(i8^iQ7R~1s3n>ot<<7Ld z@}Z?pvMyIz0?W!4=9aq6US_PpQhWk1!Sw@jb9-PO&2yr+=l>eBN41eR_IJF{-7O#p#U)5sgHyPK%KB_d5a@#zc z2DQS<5A_{uY#4FUP{kQVdBUkB|0cg_%}W@xxrD;UdgOwi=}IaaNC@1)_m3bdzv7()VS1P3T(9j&c<3ARrx4 zIr+!g`GgqrDT$i%C|_ML)zUvit#%sK_Ar-Z6-#22ZRxaph|pdk&YbJrF6820u2iFi zkwrU{!nJsrhQ5A-wtfZ~$OqSRG*gMxAz(4T|6{)$w8^Bv6a)aE`8}BCr=fT^2~Oq) zjwZ&+&W;wgW=_A>b!pmm$l@r&ThzDQ8b5ToL2vWM7?B*bL{$wbNRpq}PT=mKyp|>= zLJ6QFAWB=mY?sJFgy$ka8mi~9Si(ZO3Uy8Bo{)D5O)tiLzGI^AVH=B)6V zU8SN~E==%{sjx>o?2n(MCG>_`TDB?Tshd{&y$v=?g)$kNmsn!C>$H9>bu7JjH1m+s z1Y5*%)+uFBd!tLuw**~6m`r!E?wOEHoJR-}SDKpP>Xpbxjnb@4Z)ae;bEyh-79K4r zGGTB(XGc$tGWpg_%fpD(dXcO&qTCs025M(CeAaZbDP8OlH(e{lcNhzQm}!PDl7DQa zYn5ZRG5OM~#LV>Mz2WnmgzvJ*1}<&&T;kPDj^Uz9*ZS5(d@|;Be_QpCb+=QtWCM%W zaJtFuyRpLQ;CyEQf7kzL#Au<_0v$bsYG|1D8e;BZ?*HMj$x_ zo{uGG^rI;Rrl`K%LSuwc##u$SU&+2D(r?JKwQu%f!nhNaf-F`g+0rlX~ z70!MpK|Re^wcCHG@lV9E|9>pXz2^6#VRG_FuXPG1)$J$1q zyG)TmP}zJ%&H}qRV{|@9q(|L8z4Rl?+~L|!`0^_}5A2JGpWTcVzjK?{Gmb`Jvq0^D zJO_C7z#x*$^6=W-j^5O0P^|d))OTo%Bm8sNCNBI~HNy#rwcs7+Fka{3*d=6MS(lT84eUZ^5|QZ~0<# z%^eFnwD9{5!2z)BF3Ve@`xk7hfq0BV!;P}Vt@L*@8@{JPz!Z0HrvY=G-)uV_o88=} z<@1pyS+V#(q4nnr(MUgs5fxF*9Q!hpmpKYU%f)^TE`?lUAml+-zJuBkL1)P-yEL&G zAzSBS-~k1t3^)d7T`~CPS|r}7Uj{IJQEJ#>uqby*9tvrVh}o?olb<>Aq|GF)$jK>G z!v$?4m?v6tuOd_0!U}(SiJQ2d?(d18I4?F=&_5Dzy?0RukbU{&Bv-dS}vD@;f7LD@dcwYP03_hWa}M>`>*6!Z#7;jg1OIvuF5z<=L! z!W^_v!hQDx)`I~6!2h0?eg>2o+SytCc2jJa#HB@-thcUl{T z@yL&__Un&#O9WR4W-h7A-J!vVcHbtwL&3JN!bRI_ZyyooO3K zRfReeOprtz6=16mCpQY);LIK?w`upX;yLScU-9Fr(X|rj18Q_y&^+pw_~MKH9A?yB zG*Q<(crWk_01nOvl->g3Gyyl1dLH$ouGLdqUapUd#A)-792?DHot<{W;#=Z`pKtnM z<__aKJFADgw{-kbc(KgKGsW@VR*!7nc=(=|JudB4mLniID4bM`4~N4NPy46lvNkvQ z6f(Uzzw!9`s@byzPDgG#3IKEx+Q@mMuYDXK;_F~%8gos5GzP!}Wr`5}U>L~=963_C z^{~)%$`zrSwTe&^#aU$_Hb8*AtcwK$@U235j>Hf!cUH8oYM>!4A$B9C6qFOmTxEXh z%$LKw#96#`ZS56?8T`Uqh8sK@N$@o;i4YGCr&s?VsURJBRq%=DYcD0MkCw=N<3vG< z%>KN)mimpm)+p)Mbhh{yvoTMqMAb3odd!zzear^XeAac)Al!Y0{`)ku%itYO#}=*E z6{WEPoLtBQS^N~eQiQtw5Y=<-FfnSDn{q!g0pD7hf}5E)C!N53t3<}E+kHk{Gc>A& zv9zx1`h^8lADzRaNVROdEk92OhbJ|cy90t?p1NG78yww<-0Q3Dx1yUr0>VU)cQ8+& z0RSqE0RUkC*TOq{SeyJZ?F%1{?ba4i-}v<3fP3|SFib3d^ejxN*MJKE`hZA)$)=AD zq?KoFnD9+W#nKHUj&8CJZN#5wtqv7*v2mmhUb@L87R(~?l{wzpZ?(I|i5~x6=jw`3 z9HkT@oHFw8h5PV=PsHW}7Zz+`MjI>RgoAjMb&823{{2K3aI zmSInEzD4q@!c#k6BP&CE_**ORHe2Z)L!P4%@FpMkT9x!0xSM#k6&CngMsFsD%6QX$ zZL4k;&Iad7enOFagHkOzb}8aXjB&WIkNLV-WWwG}W-)NAYs<``p*}Jmqq8M$ zm$xsq78YK@3b!McUYR=#1D?h=G){(aC$jpgOyI!z#bGQJEK(`Ha|D|f<7?Vqk0fwc zkW0M~{6tG~-E!zg4Y(v)W2*#nXA;_k~=+9D8q{kRhTP7;tm2vu}()HlIN zUW5Q3)$Q%;N7%WR!Eyn2Qt{FmQ&F}r=F~h(Vx4Bxb}-N6EbXc6$Bp_%u~09_MsDNc z5lqrr1$@}czv&_^t*r%DM}wf8*&vY~2F)O=s*EZAAucl%5=fh%CV47@67-3b(s~2v zvovLtFsT$XJGf;jkEW#dM)bortLl{$=@rkx0;CyShBRBO6WM+zlLZ9zN-!N*GHYWI zo5hh?BS&6tCb{(p3;Sq9kCrP3B(w@WyPI;##J)z!tl#)VaN0xVR9gs_m6ZGt2H4k% zF~@#1;);zBxte*JaMZ;TGrLdNbXeggg-s(R`+b3%Kjiek8N!6UhNE&NIhlGvi%mD# zMyoz(IL21vS{*Q z2#7hk)K#4{)DL~u)~=JI)r-oRm~?I5C}Yv8RMRe1Rip2QyIN~hYgYti9n%zMwf2J@ zU_ZW9>zu8kO0JOhr}h&yUDTYMBAt-%r85w_a)M;;oE)q1Xf?Oo!!ae{Ph}u}Yz_W2 zb#8)xU^?}iAR$LO?WjTd&L9+&_gsuSH7|Pa5a}0f^*1ztMLs83=AR_-&+0JDxQl%P zp3@CaH41dCAG>QHzGba#Vtoh321`h(^%HnqJs^Bvq%^@wzoW-Tc#VB{6#-5f4m}%5 zZP|dalXwlh2IFjU(o%WNc?nfIxO1HMQmYJDmGDwYVQ!A;QfoM^v4)gC_J`o~V%8bR zkX&-dh(8A+fk7llS}auajBh`t4ptd=&ik`j%$ssU8AeaNT2eXqL7^?iqfwk!-R zFL`{p>Ug;4{D7?u)VhshbN@TN-YJec_Au*9qA&*bGdwQb4G&=7ui+?ddSjo<;-U=< znIyS+aPZ1|vYFgAdkarC;5r}m&*|Z#UoItvLDXsI7QNUuM%2MhS0O2j->RTHX;B%o6#)a}Ow)gJ4p zv6Z~q$VlEj2xTaR^c2Xrn<?(ApA{944K{kSn|icUc{G$jcoor0z)Dw6ZO5=AJ=<5&@$YYY-q6#*#< zEgwrwP{lK`7f`7BYg5*Fh2k6b>_=}8`z%LUPlzl=cj{WQz`xejREG7Fn(ItR1vioi z2O3&NI7kjbSml}+c)_Dz_TAyhjlW;uQbSI8GgIC#Is?#aUL3UVmzHq!rh9$b2)&P* z3l0N2r7*|;%e3w#4@uw44Si)!i?@rXQ3e+7nTWrqz(NXp7z?&5>LCJ4A(iJ^l$Y7) z(UK`A!>O#W$;~1wmW`T6+j15&FL<3{pE52qou;mE*wYZ@rauK?10vDN3Q!tu%AHzZ zXg>{t6l%)YDg!iKd_Nq+jhTv3rSclT+0@W~{DgQiZ{O4we5Y9O1wTp?`eg>jM)6tR z^<}UXM|PPGGKV8yS&&3>kU~Y+vJ#XEqDPdJJt=v7vV){KPxHb#K9-VayxLJ(95G#9 zFLhm{Ui3K6u}OoRTXcWWi$OCT!IU5qT|Ew?r1${UBcnbkFO5GF-QwgE2W@E&tvbb^ zZo>%iorOpXIK^$qLgn>`2UVOnmE5TeYnftz{SW$sZjW!Hgie=HLmmM7J+Srk zeEdFFw<|&93%*0a~lR>!pL3}&NS?B$61CIwfiXcPe{C1Q-EztT{Dc-@-)*c*D4t=>3p7@*~ z;QG}lps%D=#J*IShMGq#(<~hfcNgjLtFe0~47nr*dnyE-3+d&9J>Do%lJnE<>q$&( z3=}-PMi#1jR@}PJf%Fa{pmZTo0=XZb$5jvc4x?h>7848=+uOgfwl7!vbHjX)u_0v$(Dl{XS+8H&^u6yMdRL0^bGqI*gJ^mBVS*sZMnIX$ z_w6LHm1wZ>s2SX7_47&UcP@d)ae>&PjoEmLEQMqsCoZoODv)!Hs~`hVk5F@9Kd2b` zJK!Z@4Y(GlV`K=lBkj`7Qb)n!7~~Jw!x#l#?QPb>zfMc+Uf^%FOf ziP*l=W8<|(BD4ZkJR~;`8KL0?)Av~adRl*x#R8ZiQO&@{q7QBvE{u50nAUO4&|k+kx;xJ&?TDZ7)Y}#iGfrx?~|*&7-*S z^Zd<|SbJA}yP|pGJ0QQ1L!%LSpVtm^Wt#rczgeE z>)qS;zJmB)8oHm9l?l^Q0}Kd5=Yi)42VJsmsE8}h^h77J#y%nR*p=8&4xWN6&)_Ci zxK}%v14l^5qn%yZ@zgf&r+ndQX6ika_$(LeIMk>h@BHO%gj-QCFwSX57ryaxH>t3S zN>f@`QJs)9<;eAYoo)x3Ps2KL_>rW!laB&(bBBGs@iW>D;}TrKqS(oU)mstPm2X&(VC*{gtK=D1i6C1v}Q)&M*)4vM(<)Qnf{*btC zsR03mA+KIHjUx68ZV{2XR~3BVbZSsXH>1?H8l-OLN!Y5J*<3_gFKMI~ouhqEoKta|{qOjPt%{%5Mnj=_WFHpIX8%jp;s3fl|4Gw`PDFOh z?^-T+f6uXgYx+0YzwRjgQfueJq-Br*0{Dg3=W_zD>T)tho6&S>N)S)WIdBcGEv`B_uF;(RDi0NK1%%W(k+VbF|T-uy7WoBhi8udRq}; zU|q(nX3w(7Z3Kil>?FctxGR71ZFs+!Zbup0J88Si`vC&OC z8B9<6D$s^1bkfWXWcK=kcQUaWe1t7H3nmL)&x&ud=*?@A!0OHAYH*xy#bL(z0 zWlau<5h3(k);mnr4pw`>2nHG*uPQ1%9A059z~)<%Y%xJcXT7Kl0R&N+eZ6FJ*4x{` zmx9x?0;UxVAWJi7*K~GGFg32i(_DSK&affYaR#XJU3>h923aoo^_9!hB>#-NbxBKJ)LpFi~yUKZ1kxJT8}!rVgbF%f%nTq^y30G+^kO{k-|OvQ2&VNea0k@I*BD zfrP4Wdu%jyo5vAoY^l7W$N8lzMF1;5QYub3s}5-4f&`M#^1)R1nD&GJ>Et16S$?xC zj)C*SUA8pjILWp2b;OQKKp^UY;u;}cg`AtIAie+v4WOZk67_-Hb*ttAaA2Pjh(a~v z7uH^0BN}tN-Ed`KFK~%G>`dYf=k23*FZ_fFuzq;-(DNzSdtDn#UtLHWznvU1(9$0$ z^NwVY>qyd3Ye))fXpIq?g zFuso@03ZOt`xWawgXJFv^|ye3o)Ldqm*3^*AqWFp|7S=3tn^Q7^ONUyrQiOa=NC%< zTV+3|f#2n4Wk1Wm9M16fPX2j3!%yPh^^yN8+y5Tc@MmlPJObb^ZoqeI`Tv#v-+=&s zvj3UK{ujH~`>yw2?EjzD{@*(N&hfwHXQwaU-FfeBy#L1VcP)kU{$lvQsq^nz`g8L9 zStBk=qK`#<;P zPm}vo$nU<0{%0Zo+OEG0@bC8h6&r&OA0E?KT7$PRr;rtKlAARlA`_YPWy+p{K~5P zQ_7!NLw`xBNBT!8zjBEFl=5eO#a~jU-y_QZp)J3%E&i19XB7HhQqJE`k@yEGzhcrA Vq(Oh#A8Y_@fYtZAErFkW{{!xtEwTUr literal 0 HcmV?d00001 diff --git a/ruoyi-common/src/main/java/com/ruoyi/common/config/DrawingFileQueryProperties.java b/ruoyi-common/src/main/java/com/ruoyi/common/config/DrawingFileQueryProperties.java new file mode 100644 index 0000000..c35fa5a --- /dev/null +++ b/ruoyi-common/src/main/java/com/ruoyi/common/config/DrawingFileQueryProperties.java @@ -0,0 +1,23 @@ +package com.ruoyi.common.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * 图纸/模型文件查询服务。工艺路线「下载图纸模型」由后端转发,不让浏览器直连。 + */ +@Data +@Component +@ConfigurationProperties(prefix = "app.drawing.file-query") +public class DrawingFileQueryProperties { + + /** 文件服务根地址,如 http://192.168.5.20:28000 */ + private String baseUrl = "http://192.168.5.20:28000"; + + /** 下载路径 */ + private String downloadPath = "/api/FileQuery/download"; + + /** 读取超时(毫秒),模型文件可能较大 */ + private int readTimeoutMs = 300_000; +} diff --git a/ruoyi-common/src/main/java/com/ruoyi/common/convert/NullableQuantityTextConverter.java b/ruoyi-common/src/main/java/com/ruoyi/common/convert/NullableQuantityTextConverter.java new file mode 100644 index 0000000..1ad25e4 --- /dev/null +++ b/ruoyi-common/src/main/java/com/ruoyi/common/convert/NullableQuantityTextConverter.java @@ -0,0 +1,52 @@ +package com.ruoyi.common.convert; + +import com.alibaba.excel.converters.Converter; +import com.alibaba.excel.enums.CellDataTypeEnum; +import com.alibaba.excel.metadata.GlobalConfiguration; +import com.alibaba.excel.metadata.data.ReadCellData; +import com.alibaba.excel.metadata.data.WriteCellData; +import com.alibaba.excel.metadata.property.ExcelContentProperty; + +import java.math.BigDecimal; + +/** + * 单台数量按原文读取。数字单元格转成普通数字文本,文本单元格保留 {@code 1/4}、{@code 1/3} 这类分数。 + */ +public class NullableQuantityTextConverter implements Converter { + + @Override + public Class supportJavaTypeKey() { + return String.class; + } + + @Override + public String convertToJavaData(ReadCellData cellData, ExcelContentProperty contentProperty, + GlobalConfiguration globalConfiguration) { + if (cellData == null || cellData.getType() == null || cellData.getType() == CellDataTypeEnum.EMPTY) { + return null; + } + switch (cellData.getType()) { + case NUMBER: + BigDecimal number = cellData.getNumberValue(); + if (number == null) { + return null; + } + return number.stripTrailingZeros().toPlainString(); + case STRING: + return ExcelNullableText.normalize(cellData.getStringValue()); + case BOOLEAN: + if (cellData.getBooleanValue() == null) { + return null; + } + return cellData.getBooleanValue() ? "1" : "0"; + default: + return null; + } + } + + @Override + public WriteCellData convertToExcelData(String value, ExcelContentProperty contentProperty, + GlobalConfiguration globalConfiguration) { + return new WriteCellData<>(value == null ? "" : value); + } +} diff --git a/ruoyi-common/src/main/java/com/ruoyi/common/dingding/relay/DingTodoRelayClient.java b/ruoyi-common/src/main/java/com/ruoyi/common/dingding/relay/DingTodoRelayClient.java new file mode 100644 index 0000000..90ad221 --- /dev/null +++ b/ruoyi-common/src/main/java/com/ruoyi/common/dingding/relay/DingTodoRelayClient.java @@ -0,0 +1,343 @@ +package com.ruoyi.common.dingding.relay; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONObject; +import com.ruoyi.common.config.DingTalkProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.io.FileSystemResource; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; +import org.springframework.web.client.DefaultResponseErrorHandler; +import org.springframework.web.client.RestTemplate; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.List; + +/** + * 待办中转:先登录取 token,再以 multipart 调用 /ding/send 的 createToBeDoneUser。 + */ +@Service +public class DingTodoRelayClient { + + private static final Logger log = LoggerFactory.getLogger(DingTodoRelayClient.class); + + /** + * 中转按该名字反射钉钉创建待办 + */ + public static final String METHOD_CREATE_TO_BE_DONE_USER = "createToBeDoneUser"; + + private static final long DAY_MILLIS = 24L * 60L * 60L * 1000L; + + private final RestTemplate restTemplate; + private final DingTalkProperties properties; + + private volatile String cachedToken; + + public DingTodoRelayClient(DingTalkProperties properties) { + this.properties = properties; + RestTemplate template = new RestTemplate(); + template.setErrorHandler(new DefaultResponseErrorHandler() { + @Override + public void handleError(ClientHttpResponse response) throws IOException { + // 业务码在响应体里,HTTP 4xx 也把正文带回来 + } + }); + this.restTemplate = template; + } + + /** + * 账号未配齐时不调用中转。 + */ + public boolean isConfigured() { + return StringUtils.hasText(properties.getTodoBaseUrl()) + && StringUtils.hasText(properties.getTodoUsername()) + && StringUtils.hasText(properties.getTodoPassword()) + && StringUtils.hasText(properties.getTodoUuid()); + } + + /** + * 按场景文案创建工艺计划表待办。 + * + * @param userIdsCsv 逗号分隔的钉钉 userId + * @param createUser 创建人,空则用接收人中的第一个 + * @param dueDays 没有未来的工艺预计完成时间时,截止为当前时间加该天数 + * @param contentLead 正文第一句,空则用默认说明 + * @param recipientType code=考勤工号,name=姓名 + */ + public String createProcessPlanTodo(String productionOrderNo, String projectName, Date routeUptime, + File excelFile, String userIdsCsv, String createUser, int dueDays, + String contentLead, String recipientType) { + List users = splitUsers(userIdsCsv); + if (users.isEmpty()) { + throw new IllegalStateException("待办接收人为空"); + } + String creator = StringUtils.hasText(createUser) ? createUser.trim() : users.get(0); + String orderNo = StringUtils.hasText(productionOrderNo) ? productionOrderNo.trim() : ""; + String name = StringUtils.hasText(projectName) ? projectName.trim() : ""; + String fileName = excelFile != null ? excelFile.getName() : ""; + String title = "工艺任务待处理" + (StringUtils.hasText(orderNo) ? " " + orderNo : ""); + StringBuilder content = new StringBuilder(); + content.append(StringUtils.hasText(contentLead) ? contentLead.trim() : "工艺计划表已下发至ERP数据推送机器人或登录EVO物料系统导出工艺计划表,请及时处理。"); + if (StringUtils.hasText(orderNo)) { + content.append("\n项目令号:").append(orderNo); + } + if (StringUtils.hasText(name)) { + content.append("\n项目名称:").append(name); + } + if (StringUtils.hasText(fileName)) { + content.append("\n文件:").append(fileName); + } + return createToBeDoneUser(users, creator, title, content.toString(), resolveDueTime(routeUptime, dueDays), excelFile, recipientType); + } + + /** + * 调用中转创建待办。 + * + * @param users 接收人,类型由 recipientInfo / recipientType 决定 + * @param createUser 创建人,类型同接收人 + * @param dueTimeMillis 截止时间,毫秒时间戳 + * @param attachment 可选附件,表单字段名 files + * @return 中转原始响应 + */ + public String createToBeDoneUser(List users, String createUser, String title, String content, + long dueTimeMillis, File attachment, String recipientType) { + if (!isConfigured()) { + throw new IllegalStateException("未配置钉钉待办中转账号(dingtalk.todo-username/password/uuid/todo-base-url)"); + } + if (users == null || users.isEmpty()) { + throw new IllegalArgumentException("待办接收人不能为空"); + } + if (!StringUtils.hasText(createUser)) { + throw new IllegalArgumentException("待办创建人不能为空"); + } + if (!StringUtils.hasText(title) || !StringUtils.hasText(content)) { + throw new IllegalArgumentException("待办标题和内容不能为空"); + } + if (dueTimeMillis <= 0) { + throw new IllegalArgumentException("待办截止时间无效"); + } + + String robotCode = resolveRobotCode(); + String clientId = StringUtils.hasText(properties.getAppKey()) ? properties.getAppKey().trim() : robotCode; + String clientSecret = properties.getAppSecret(); + if (!StringUtils.hasText(clientSecret)) { + throw new IllegalStateException("未配置 dingtalk.app-secret,无法创建待办"); + } + + JSONObject data = new JSONObject(true); + data.put("month", METHOD_CREATE_TO_BE_DONE_USER); + data.put("robotCode", robotCode); + data.put("clientId", clientId); + data.put("clientSecret", clientSecret.trim()); + data.put("recipientType", normalizeRecipientType(recipientType)); + data.put("recipientInfo", "unionId"); + data.put("createUser", createUser.trim()); + data.put("users", users); + data.put("title", title); + data.put("content", content); + data.put("dueTime", dueTimeMillis); + + String body = postSend(data.toJSONString(), attachment, false); + log.info("钉钉待办已创建 title={} users={} dueTime={}", title, users.size(), dueTimeMillis); + return body; + } + + private String postSend(String dataJson, File attachment, boolean retried) { + String token = currentToken(retried); + String url = join(properties.getTodoBaseUrl(), "/ding/send"); + + MultiValueMap form = new LinkedMultiValueMap<>(); + form.add("data", dataJson); + if (attachment != null && attachment.exists() && attachment.length() > 0) { + form.add("files", new FileSystemResource(attachment)); + } + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.MULTIPART_FORM_DATA); + String headerName = defaultText(properties.getTodoTokenHeader(), "Authorization"); + String prefix = properties.getTodoTokenPrefix() == null ? "" : properties.getTodoTokenPrefix(); + headers.set(headerName, prefix + token); + + log.info("创建钉钉待办请求 POST {} Content-Type=multipart/form-data file={} data={}", + url, attachment == null ? "" : attachment.getName(), dataJson); + ResponseEntity response = restTemplate.exchange( + url, HttpMethod.POST, new HttpEntity<>(form, headers), String.class); + String body = response.getBody(); + log.info("创建钉钉待办响应 status={} body={}", response.getStatusCodeValue(), body); + if (isAuthFailure(response.getStatusCodeValue(), body) && !retried) { + log.info("待办中转 token 失效,重新登录后重试"); + cachedToken = null; + return postSend(dataJson, attachment, true); + } + assertBizOk(body, "创建待办"); + return body; + } + + private String currentToken(boolean forceLogin) { + if (!forceLogin && StringUtils.hasText(cachedToken)) { + return cachedToken; + } + synchronized (this) { + if (!forceLogin && StringUtils.hasText(cachedToken)) { + return cachedToken; + } + cachedToken = login(); + return cachedToken; + } + } + + private String login() { + String url = join(properties.getTodoBaseUrl(), "/login"); + JSONObject body = new JSONObject(true); + body.put("username", properties.getTodoUsername().trim()); + body.put("password", properties.getTodoPassword()); + body.put("uuid", properties.getTodoUuid().trim()); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + String payload = body.toJSONString(); + log.info("待办中转登录请求 POST {} Content-Type=application/json body={}", url, payload); + ResponseEntity response = restTemplate.exchange( + url, HttpMethod.POST, new HttpEntity<>(payload, headers), String.class); + String raw = response.getBody(); + log.info("待办中转登录响应 status={} body={}", response.getStatusCodeValue(), raw); + assertBizOk(raw, "待办中转登录"); + JSONObject root = JSON.parseObject(raw); + String token = root == null ? null : root.getString("token"); + if (!StringUtils.hasText(token)) { + throw new IllegalStateException("待办中转登录成功但未返回 token"); + } + return token.trim(); + } + + /** 截止时间用工艺预计完成时间;未填或已过期时,用当前时间加截止天数。 */ + private long resolveDueTime(Date routeUptime, int dueDays) { + long now = System.currentTimeMillis(); + if (routeUptime != null && routeUptime.getTime() > now) { + return routeUptime.getTime(); + } + int days = dueDays > 0 ? dueDays : 3; + return now + days * DAY_MILLIS; + } + + private String resolveRobotCode() { + if (StringUtils.hasText(properties.getRobotCode())) { + return properties.getRobotCode().trim(); + } + if (StringUtils.hasText(properties.getAppKey())) { + return properties.getAppKey().trim(); + } + throw new IllegalStateException("未配置 dingtalk.robot-code,无法创建待办"); + } + + private static List splitUsers(String userIdsCsv) { + if (!StringUtils.hasText(userIdsCsv)) { + return Collections.emptyList(); + } + List users = new ArrayList<>(); + for (String part : userIdsCsv.split("[,,]")) { + if (StringUtils.hasText(part)) { + users.add(part.trim()); + } + } + return users; + } + + private static void assertBizOk(String body, String action) { + if (!StringUtils.hasText(body)) { + throw new IllegalStateException(action + "响应为空"); + } + JSONObject root; + try { + root = JSON.parseObject(body); + } catch (Exception ex) { + throw new IllegalStateException(action + "响应无法解析: " + abbreviate(body)); + } + if (root == null) { + throw new IllegalStateException(action + "响应为空"); + } + Integer code = root.getInteger("code"); + if (code == null || code != 200) { + String msg = firstText(root.getString("mes"), root.getString("msg"), abbreviate(body)); + throw new IllegalStateException(action + "失败: " + msg); + } + } + + private static boolean isAuthFailure(int httpStatus, String body) { + if (httpStatus == 401 || httpStatus == 403) { + return true; + } + if (!StringUtils.hasText(body)) { + return false; + } + try { + JSONObject root = JSON.parseObject(body); + if (root == null) { + return false; + } + Integer code = root.getInteger("code"); + if (code != null && code == 200) { + return false; + } + if (code != null && (code == 401 || code == 403)) { + return true; + } + if (code != null) { + return false; + } + String msg = firstText(root.getString("mes"), root.getString("msg"), ""); + return msg.contains("token") || msg.contains("登录") || msg.contains("未授权") || msg.contains("认证"); + } catch (Exception ex) { + return false; + } + } + + private static String join(String base, String path) { + String b = base == null ? "" : base.trim(); + while (b.endsWith("/")) { + b = b.substring(0, b.length() - 1); + } + String p = path.startsWith("/") ? path : "/" + path; + return b + p; + } + + private static String normalizeRecipientType(String recipientType) { + return "code".equalsIgnoreCase(defaultText(recipientType, "")) ? "code" : "name"; + } + + private static String defaultText(String value, String fallback) { + return StringUtils.hasText(value) ? value.trim() : fallback; + } + + private static String firstText(String... values) { + if (values == null) { + return ""; + } + for (String value : values) { + if (StringUtils.hasText(value)) { + return value; + } + } + return ""; + } + + private static String abbreviate(String text) { + if (text == null) { + return ""; + } + return text.length() <= 300 ? text : text.substring(0, 300); + } +} diff --git a/ruoyi-common/src/main/java/com/ruoyi/common/helper/JobLoginContext.java b/ruoyi-common/src/main/java/com/ruoyi/common/helper/JobLoginContext.java new file mode 100644 index 0000000..371e56b --- /dev/null +++ b/ruoyi-common/src/main/java/com/ruoyi/common/helper/JobLoginContext.java @@ -0,0 +1,104 @@ +package com.ruoyi.common.helper; + +import cn.dev33.satoken.SaManager; +import cn.dev33.satoken.context.model.SaRequest; +import cn.dev33.satoken.context.model.SaResponse; +import cn.dev33.satoken.context.model.SaStorage; +import cn.dev33.satoken.context.second.SaTokenSecondContext; +import cn.dev33.satoken.exception.SaTokenContextException; +import com.ruoyi.common.core.domain.model.LoginUser; + +import java.util.HashMap; +import java.util.Map; + +/** + * 给定时任务线程补登录用户。 + * 不注册成 Spring Bean,避免和 Sa-Token 的 Web 上下文抢同一个注入点。 + * Web 请求仍走原来的上下文;只有当前线程调用过 {@link #login(LoginUser)} 时,这里才会生效。 + */ +public class JobLoginContext implements SaTokenSecondContext { + + private static final JobLoginContext INSTANCE = new JobLoginContext(); + + private static final ThreadLocal> STORE = new ThreadLocal<>(); + + private final SaStorage storage = new ThreadStorage(); + + private JobLoginContext() { + } + + public static void login(LoginUser loginUser) { + if (SaManager.getSaTokenSecondContext() != INSTANCE) { + SaManager.setSaTokenSecondContext(INSTANCE); + } + Map map = new HashMap<>(); + map.put(LoginHelper.LOGIN_USER_KEY, loginUser); + if (loginUser.getUserId() != null) { + map.put(LoginHelper.USER_KEY, loginUser.getUserId()); + } + STORE.set(map); + } + + public static void logout() { + STORE.remove(); + } + + @Override + public boolean isValid() { + return STORE.get() != null; + } + + @Override + public SaRequest getRequest() { + throw new SaTokenContextException("定时任务登录上下文不提供 Request"); + } + + @Override + public SaResponse getResponse() { + throw new SaTokenContextException("定时任务登录上下文不提供 Response"); + } + + @Override + public SaStorage getStorage() { + return storage; + } + + @Override + public boolean matchPath(String pattern, String path) { + return false; + } + + private static final class ThreadStorage implements SaStorage { + + @Override + public Object getSource() { + return STORE.get(); + } + + @Override + public Object get(String key) { + Map map = STORE.get(); + return map == null ? null : map.get(key); + } + + @Override + public SaStorage set(String key, Object value) { + Map map = STORE.get(); + if (map == null) { + map = new HashMap<>(); + STORE.set(map); + } + map.put(key, value); + return this; + } + + @Override + public SaStorage delete(String key) { + Map map = STORE.get(); + if (map != null) { + map.remove(key); + } + return this; + } + } +} diff --git a/ruoyi-common/src/main/java/com/ruoyi/common/utils/DrawingDelayCalculator.java b/ruoyi-common/src/main/java/com/ruoyi/common/utils/DrawingDelayCalculator.java new file mode 100644 index 0000000..05b2f21 --- /dev/null +++ b/ruoyi-common/src/main/java/com/ruoyi/common/utils/DrawingDelayCalculator.java @@ -0,0 +1,85 @@ +package com.ruoyi.common.utils; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.Collection; +import java.util.Date; + +/** + * 项目整体出图时间驱动的延期/完成判定。 + * 工作日按一周六天(周一至周六),周日不计入延期天数。 + */ +public final class DrawingDelayCalculator { + + public static final ZoneId ZONE = ZoneId.of("Asia/Shanghai"); + + /** 方案设计完成 */ + public static final long BOM_STATUS_SCHEME_DONE = 0L; + /** 设计出图完成 */ + public static final long BOM_STATUS_DRAWING_DONE = 1L; + + private DrawingDelayCalculator() { + } + + public static boolean allProductsHaveDrawPath(Collection drawPaths) { + if (drawPaths == null || drawPaths.isEmpty()) { + return false; + } + for (String path : drawPaths) { + if (path == null || path.trim().isEmpty()) { + return false; + } + } + return true; + } + + /** + * 出图时间之后到 now 之间的工作日天数(不含出图当天,周日不计)。 + */ + public static long delayWorkdays(Date drawingTime, Date now) { + if (drawingTime == null || now == null || !now.after(drawingTime)) { + return 0L; + } + LocalDate start = toLocalDate(drawingTime); + LocalDate end = toLocalDate(now); + long days = 0L; + for (LocalDate d = start.plusDays(1); !d.isAfter(end); d = d.plusDays(1)) { + if (d.getDayOfWeek() != DayOfWeek.SUNDAY) { + days++; + } + } + return days; + } + + public static DrawingDelayResult evaluate(Date drawingTime, Date now, boolean drawingComplete, Long bomStatus) { + boolean overdue = drawingTime != null && now != null && now.after(drawingTime); + long days = delayWorkdays(drawingTime, now); + + DrawingDelayResult result = new DrawingDelayResult(); + result.setDrawingComplete(drawingComplete); + result.setWhetherDelay(overdue ? 1 : 0); + result.setDelayDay(overdue ? days : 0L); + if (drawingComplete) { + result.setCurrentlyDelayed(false); + result.setProgressLabel("设计出图完成"); + result.setBomStatus(promoteBomStatus(bomStatus)); + } else { + result.setCurrentlyDelayed(overdue); + result.setProgressLabel(overdue ? "延期" : "进行中"); + result.setBomStatus(bomStatus); + } + return result; + } + + private static Long promoteBomStatus(Long bomStatus) { + if (bomStatus == null || bomStatus == BOM_STATUS_SCHEME_DONE) { + return BOM_STATUS_DRAWING_DONE; + } + return bomStatus; + } + + private static LocalDate toLocalDate(Date date) { + return date.toInstant().atZone(ZONE).toLocalDate(); + } +} diff --git a/ruoyi-common/src/main/java/com/ruoyi/common/utils/DrawingDelayResult.java b/ruoyi-common/src/main/java/com/ruoyi/common/utils/DrawingDelayResult.java new file mode 100644 index 0000000..9411a75 --- /dev/null +++ b/ruoyi-common/src/main/java/com/ruoyi/common/utils/DrawingDelayResult.java @@ -0,0 +1,28 @@ +package com.ruoyi.common.utils; + +import lombok.Data; + +/** + * 项目出图延期计算结果。 + */ +@Data +public class DrawingDelayResult { + + /** 已填产品是否全部有图纸路径 */ + private boolean drawingComplete; + + /** 超期标识:0否 1是(完成后仍保留) */ + private Integer whetherDelay; + + /** 延期工作日天数(完成后冻结) */ + private Long delayDay; + + /** 回写的项目状态;完成时 0→1,已进入后续流程则保持不变 */ + private Long bomStatus; + + /** 当前仍处于延期中(未完成且已过出图时间) */ + private boolean currentlyDelayed; + + /** 进行中 / 延期 / 设计出图完成 */ + private String progressLabel; +} diff --git a/ruoyi-common/src/main/java/com/ruoyi/common/utils/JsyyOrderNo.java b/ruoyi-common/src/main/java/com/ruoyi/common/utils/JsyyOrderNo.java new file mode 100644 index 0000000..4c385b4 --- /dev/null +++ b/ruoyi-common/src/main/java/com/ruoyi/common/utils/JsyyOrderNo.java @@ -0,0 +1,21 @@ +package com.ruoyi.common.utils; + +/** + * 技术预研令号:令号中含 JSYY 时不属于生产令号,禁止更新物料清单、推送工艺。 + */ +public final class JsyyOrderNo { + + public static final String MARK = "JSYY"; + public static final String MSG = "JSYY技术预研令号不属于生产令号,严禁直接用于生产执行,禁止更新物料清单、禁止推送工艺"; + + private JsyyOrderNo() { + } + + public static boolean matches(String orderNo) { + return orderNo != null && orderNo.toUpperCase().contains(MARK); + } + + public static String denyIfMatches(String orderNo) { + return matches(orderNo) ? MSG : null; + } +} diff --git a/ruoyi-common/src/main/java/com/ruoyi/common/utils/ProjectStatusLock.java b/ruoyi-common/src/main/java/com/ruoyi/common/utils/ProjectStatusLock.java new file mode 100644 index 0000000..3893b66 --- /dev/null +++ b/ruoyi-common/src/main/java/com/ruoyi/common/utils/ProjectStatusLock.java @@ -0,0 +1,108 @@ +package com.ruoyi.common.utils; + +/** + * 项目状态(process_order_pro.bom_status)操作锁。 + *
    + *
  • 8 工艺审核完成:禁止出图、禁止上传工艺路线
  • + *
  • 6 开始生产 / 7 生产完成:禁止出图、上传工艺路线、推送工艺、更新物料清单
  • + *
+ */ +public final class ProjectStatusLock { + + /** 方案设计完成 / 设计出图中 */ + public static final long SCHEME_DONE = 0L; + /** 设计出图完成 */ + public static final long DRAWING_DONE = 1L; + /** 工艺待审核 */ + public static final long ROUTE_PENDING = 2L; + /** 已推送BOM */ + public static final long BOM_PUSHED = 3L; + /** 已推送工艺 */ + public static final long ROUTE_PUSHED = 4L; + /** 开始生产 */ + public static final long IN_PRODUCTION = 6L; + /** 生产完成 */ + public static final long PRODUCTION_DONE = 7L; + /** 工艺审核完成 */ + public static final long ROUTE_APPROVED = 8L; + /** 工艺审核拒绝 */ + public static final long ROUTE_REJECTED = 9L; + + public enum Action { + /** 执行出图 */ + DRAWING("出图"), + /** 上传/导入工艺路线 */ + UPLOAD_ROUTE("上传工艺路线"), + /** 推送工艺到金蝶 */ + PUSH_ROUTE("推送工艺"), + /** 更新物料清单到金蝶 */ + UPDATE_MATERIAL("更新物料清单"); + + private final String label; + + Action(String label) { + this.label = label; + } + + public String getLabel() { + return label; + } + } + + private ProjectStatusLock() { + } + + public static String statusLabel(Long bomStatus) { + if (bomStatus == null) { + return "未知"; + } + long s = bomStatus; + if (s == SCHEME_DONE) return "方案设计完成"; + if (s == DRAWING_DONE) return "设计出图完成"; + if (s == ROUTE_PENDING) return "工艺待审核"; + if (s == BOM_PUSHED) return "已推送BOM"; + if (s == ROUTE_PUSHED) return "已推送工艺"; + if (s == 5L || s == IN_PRODUCTION) return "开始生产"; + if (s == PRODUCTION_DONE) return "生产完成"; + if (s == ROUTE_APPROVED) return "工艺审核完成"; + if (s == ROUTE_REJECTED) return "工艺审核拒绝"; + return String.valueOf(bomStatus); + } + + public static boolean isLocked(Long bomStatus, Action action) { + return denyIfLocked(bomStatus, action) != null; + } + + /** + * @return 禁止时返回提示文案;允许时返回 null + */ + public static String denyIfLocked(Long bomStatus, Action action) { + if (bomStatus == null || action == null) { + return null; + } + long s = bomStatus; + boolean productionLocked = s == 5L || s == IN_PRODUCTION || s == PRODUCTION_DONE; + boolean routeApproved = s == ROUTE_APPROVED; + + switch (action) { + case DRAWING: + case UPLOAD_ROUTE: + if (productionLocked || routeApproved) { + return buildMsg(bomStatus, action); + } + return null; + case PUSH_ROUTE: + case UPDATE_MATERIAL: + if (productionLocked) { + return buildMsg(bomStatus, action); + } + return null; + default: + return null; + } + } + + private static String buildMsg(Long bomStatus, Action action) { + return "当前项目状态为「" + statusLabel(bomStatus) + "」,不允许" + action.getLabel(); + } +} diff --git a/ruoyi-common/src/main/java/com/ruoyi/common/utils/file/PlmDrawingFileNames.java b/ruoyi-common/src/main/java/com/ruoyi/common/utils/file/PlmDrawingFileNames.java new file mode 100644 index 0000000..b7c6a63 --- /dev/null +++ b/ruoyi-common/src/main/java/com/ruoyi/common/utils/file/PlmDrawingFileNames.java @@ -0,0 +1,65 @@ +package com.ruoyi.common.utils.file; + +import java.util.Locale; +import java.util.regex.Pattern; + +/** + * PLM 落盘图纸文件名与物料编码(图号)匹配。 + *

+ * 标准名 {@code 图号_名称.pdf};历史名可能带 {@code -14位以上数字} 时间戳。 + * 不能用「图号-」做前缀匹配,否则 {@code QXCL3015040.01.0} 会误删 {@code QXCL3015040.01.0-MP*} 毛坯图。 + */ +public final class PlmDrawingFileNames { + + private static final Pattern TIMESTAMP_SUFFIX = Pattern.compile("-\\d{14,}$"); + + private PlmDrawingFileNames() { + } + + /** + * @param fileName 文件名或带路径的文件名 + * @param materialCode 物料编码/图号 + * @param separator 图号与名称分隔符,默认 {@code _} + * @param extension 不含点或含点,如 {@code pdf} / {@code .dwg} + */ + public static boolean matchesMaterialCode(String fileName, String materialCode, + String separator, String extension) { + if (fileName == null || materialCode == null) { + return false; + } + String code = materialCode.trim(); + if (code.isEmpty()) { + return false; + } + String name = fileNameOnly(fileName); + String ext = normalizeExtension(extension); + if (ext.isEmpty() || !name.toLowerCase(Locale.ROOT).endsWith(ext)) { + return false; + } + String base = name.substring(0, name.length() - ext.length()); + base = TIMESTAMP_SUFFIX.matcher(base).replaceFirst(""); + if (base.equalsIgnoreCase(code)) { + return true; + } + String sep = (separator == null || separator.isEmpty()) ? "_" : separator; + String prefix = code + sep; + return base.regionMatches(true, 0, prefix, 0, prefix.length()); + } + + private static String fileNameOnly(String fileName) { + String s = fileName.trim(); + int slash = Math.max(s.lastIndexOf('/'), s.lastIndexOf('\\')); + return slash >= 0 ? s.substring(slash + 1) : s; + } + + private static String normalizeExtension(String extension) { + if (extension == null) { + return ""; + } + String e = extension.trim().toLowerCase(Locale.ROOT); + if (e.isEmpty()) { + return ""; + } + return e.startsWith(".") ? e : ("." + e); + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/constant/DingTodoSceneCodes.java b/ruoyi-system/src/main/java/com/ruoyi/system/constant/DingTodoSceneCodes.java new file mode 100644 index 0000000..35d454b --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/constant/DingTodoSceneCodes.java @@ -0,0 +1,25 @@ +package com.ruoyi.system.constant; + +/** + * 可以勾选是否创建钉钉待办的业务方法。 + */ +public final class DingTodoSceneCodes { + + private DingTodoSceneCodes() { + } + + /** 出图完成后,工艺计划表发给工艺接收人 */ + public static final String DRAWING_PLAN_EXCEL = "DRAWING_PLAN_EXCEL"; + + /** 手动上传工艺计划表后,发给生管 */ + public static final String ROUTE_UPLOAD_PLAN = "ROUTE_UPLOAD_PLAN"; + + /** PLM 总装与工艺都到齐后,计划表发给生管 */ + public static final String PLM_PLAN_COMPLETE = "PLM_PLAN_COMPLETE"; + + /** 接收人留空时,用 dingtalk.notify-user-id */ + public static final String USER_SOURCE_NOTIFY = "NOTIFY"; + + /** 接收人留空时,用 dingtalk.plan-user-id */ + public static final String USER_SOURCE_PLAN = "PLAN"; +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/constant/DrawingMode.java b/ruoyi-system/src/main/java/com/ruoyi/system/constant/DrawingMode.java new file mode 100644 index 0000000..c727300 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/constant/DrawingMode.java @@ -0,0 +1,75 @@ +package com.ruoyi.system.constant; + +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.system.domain.ProcessOrderPro; + +import java.util.Locale; + +/** + * 项目令号出图模式 {@code process_order_pro.drawing_mode}。 + * 执行出图 / 添加产品前必须显式选择,不允许为空。 + */ +public final class DrawingMode { + + /** 正常:每个产品一行一个 ProductInfo,不按图纸路径合并 */ + public static final String NORMAL = "normal"; + + /** 零部件:同一图纸路径下多个子件合成一个 ProductInfo */ + public static final String PART = "part"; + + private DrawingMode() { + } + + public static boolean isValid(String mode) { + if (StringUtils.isBlank(mode)) { + return false; + } + String m = mode.trim().toLowerCase(Locale.ROOT); + return NORMAL.equals(m) || PART.equals(m); + } + + public static String normalize(String mode) { + if (StringUtils.isBlank(mode)) { + return null; + } + return mode.trim().toLowerCase(Locale.ROOT); + } + + /** + * 出图模式不能为空,必须为 normal / part。 + */ + public static void requireConfigured(ProcessOrderPro order) { + if (order == null) { + throw new IllegalArgumentException("项目不存在"); + } + requireConfigured(order.getDrawingMode()); + } + + /** + * 出图模式不能为空,必须为 normal / part。 + */ + public static void requireConfigured(String drawingMode) { + String mode = normalize(drawingMode); + if (!isValid(mode)) { + throw new IllegalArgumentException("出图模式不能为空,请先选择出图模式(正常/零部件)"); + } + } + + /** + * 是否零部件出图模式。仅认显式 {@link #PART};未设置视为未配置(由 {@link #requireConfigured} 拦截)。 + */ + public static boolean isPartMode(ProcessOrderPro order) { + if (order == null) { + return false; + } + return PART.equals(normalize(order.getDrawingMode())); + } + + /** 图纸路径比较用:去空白、统一斜杠 */ + public static String normalizeDrawPath(String drawPath) { + if (StringUtils.isBlank(drawPath)) { + return ""; + } + return drawPath.trim().replace('\\', '/'); + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/controller/DesignTaskController.java b/ruoyi-system/src/main/java/com/ruoyi/system/controller/DesignTaskController.java new file mode 100644 index 0000000..de912f9 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/controller/DesignTaskController.java @@ -0,0 +1,114 @@ +package com.ruoyi.system.controller; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import com.ruoyi.common.annotation.Log; +import com.ruoyi.common.annotation.RepeatSubmit; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.PageQuery; +import com.ruoyi.common.core.domain.R; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.system.domain.bo.DesignTaskDesignDimBo; +import com.ruoyi.system.domain.bo.DesignTaskQueryBo; +import com.ruoyi.system.domain.bo.DesignTaskReportBo; +import com.ruoyi.system.domain.vo.DesignAccountSyncVo; +import com.ruoyi.system.domain.vo.DesignTaskBoardVo; +import com.ruoyi.system.domain.vo.DesignTaskReportVo; +import com.ruoyi.system.domain.vo.DesignTaskSummaryVo; +import com.ruoyi.system.domain.vo.DesignTaskVo; +import com.ruoyi.system.service.IDesignTaskService; +import lombok.RequiredArgsConstructor; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.validation.constraints.NotNull; +import java.util.List; + +/** + * 设计个人工作台 + */ +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/system/designTask") +public class DesignTaskController extends BaseController { + + private final IDesignTaskService designTaskService; + + @SaCheckPermission("system:designTask:list") + @GetMapping("/summary") + public R summary() { + return R.ok(designTaskService.summaryMine()); + } + + @SaCheckPermission("system:designTask:list") + @GetMapping("/board") + public R board(String scope) { + return R.ok(designTaskService.boardMine(scope)); + } + + @SaCheckPermission("system:designTask:list") + @GetMapping("/mine") + public TableDataInfo mine(DesignTaskQueryBo bo, PageQuery pageQuery) { + return designTaskService.queryMine(bo, pageQuery); + } + + @SaCheckPermission("system:designTask:report") + @Log(title = "设计报工", businessType = BusinessType.INSERT) + @RepeatSubmit + @PostMapping("/report") + public R report(@Validated @RequestBody DesignTaskReportBo bo) { + return R.ok(designTaskService.report(bo)); + } + + @SaCheckPermission("system:designTask:report") + @Log(title = "接收设计任务", businessType = BusinessType.UPDATE) + @RepeatSubmit + @PostMapping("/accept/{taskId}") + public R accept(@NotNull @PathVariable Long taskId) { + designTaskService.accept(taskId); + return R.ok(); + } + + @SaCheckPermission("system:designTask:report") + @Log(title = "标注设计类型", businessType = BusinessType.UPDATE) + @PutMapping("/designDim") + public R designDim(@Validated @RequestBody DesignTaskDesignDimBo bo) { + designTaskService.updateDesignDim(bo); + return R.ok(); + } + + @SaCheckPermission("system:designTask:list") + @GetMapping("/reports/{taskId}") + public R> reports(@NotNull @PathVariable Long taskId) { + return R.ok(designTaskService.listMyReports(taskId)); + } + + @SaCheckPermission("system:designTask:report") + @Log(title = "删除设计报工", businessType = BusinessType.DELETE) + @DeleteMapping("/reports/{reportId}") + public R deleteReport(@NotNull @PathVariable Long reportId) { + designTaskService.deleteReport(reportId); + return R.ok(); + } + + @SaCheckPermission("system:orderPro:list") + @GetMapping("/byOrder/{orderId}") + public R> byOrder(@NotNull @PathVariable Long orderId) { + return R.ok(designTaskService.listByOrder(orderId)); + } + + @SaCheckPermission("system:designTask:sync") + @Log(title = "同步设计账号", businessType = BusinessType.OTHER) + @PostMapping("/syncAccounts") + public R syncAccounts() { + return R.ok(designTaskService.syncAccounts()); + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/controller/DevStatController.java b/ruoyi-system/src/main/java/com/ruoyi/system/controller/DevStatController.java new file mode 100644 index 0000000..38c5b01 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/controller/DevStatController.java @@ -0,0 +1,35 @@ +package com.ruoyi.system.controller; + +import cn.dev33.satoken.annotation.SaIgnore; +import cn.hutool.http.HttpRequest; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ruoyi.common.core.domain.R; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 北通道实验台设备状态 + */ +@Slf4j +@RestController +@RequestMapping("/open/devstat") +public class DevStatController { + + private static final String STAT_URL = "http://192.168.5.25:9001/devstat/stat"; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @SaIgnore + @GetMapping("/stat") + public R stat() { + try { + String body = HttpRequest.get(STAT_URL).timeout(4000).execute().body(); + return R.ok(objectMapper.readValue(body, Object.class)); + } catch (Exception e) { + log.warn("北通道实验台状态获取失败: {}", e.getMessage()); + return R.fail("实验台状态接口不可达"); + } + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/controller/DingTodoController.java b/ruoyi-system/src/main/java/com/ruoyi/system/controller/DingTodoController.java new file mode 100644 index 0000000..8417ec5 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/controller/DingTodoController.java @@ -0,0 +1,53 @@ +package com.ruoyi.system.controller; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import com.ruoyi.common.annotation.Log; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.PageQuery; +import com.ruoyi.common.core.domain.R; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.system.domain.DingTodoLog; +import com.ruoyi.system.domain.DingTodoScene; +import com.ruoyi.system.service.IDingTodoDispatchService; +import lombok.RequiredArgsConstructor; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 选择哪些方法创建钉钉待办,并查看发送记录。 + */ +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/system/dingTodo") +public class DingTodoController extends BaseController { + + private final IDingTodoDispatchService dingTodoDispatchService; + + @SaCheckPermission("system:dingTodo:list") + @GetMapping("/scenes") + public R> scenes() { + return R.ok(dingTodoDispatchService.listScenes()); + } + + @SaCheckPermission("system:dingTodo:edit") + @Log(title = "钉钉待办方法", businessType = BusinessType.UPDATE) + @PutMapping("/scenes") + public R updateScene(@RequestBody DingTodoScene scene) { + dingTodoDispatchService.updateScene(scene); + return R.ok(); + } + + @SaCheckPermission("system:dingTodo:list") + @GetMapping("/logs") + public TableDataInfo logs(DingTodoLog query, PageQuery pageQuery) { + return dingTodoDispatchService.queryLogs(query, pageQuery); + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/controller/DrawingRouteOperLogController.java b/ruoyi-system/src/main/java/com/ruoyi/system/controller/DrawingRouteOperLogController.java new file mode 100644 index 0000000..a12b492 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/controller/DrawingRouteOperLogController.java @@ -0,0 +1,57 @@ +package com.ruoyi.system.controller; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import com.ruoyi.common.annotation.Log; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.PageQuery; +import com.ruoyi.common.core.domain.R; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.system.domain.bo.DrawingRouteOperLogBo; +import com.ruoyi.system.domain.vo.DrawingRouteOperLogVo; +import com.ruoyi.system.service.DrawingRouteOperLogRenotifyService; +import com.ruoyi.system.service.IDrawingRouteOperLogService; +import lombok.RequiredArgsConstructor; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.validation.constraints.NotNull; + +/** + * 出图 / 工艺上传 / 钉钉推送留痕 + */ +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/system/drawingRouteOperLog") +public class DrawingRouteOperLogController extends BaseController { + + private final IDrawingRouteOperLogService drawingRouteOperLogService; + private final DrawingRouteOperLogRenotifyService drawingRouteOperLogRenotifyService; + + @SaCheckPermission("system:drawingRouteOperLog:list") + @GetMapping("/list") + public TableDataInfo list(DrawingRouteOperLogBo bo, PageQuery pageQuery) { + return drawingRouteOperLogService.queryPageList(bo, pageQuery); + } + + @SaCheckPermission("system:drawingRouteOperLog:query") + @GetMapping("/{id}") + public R getInfo(@NotNull(message = "主键不能为空") @PathVariable Long id) { + return R.ok(drawingRouteOperLogService.queryDetail(id)); + } + + /** + * 按这条日志重发对应钉钉通知。出图执行、工艺上传不能重发。 + */ + @SaCheckPermission("system:drawingRouteOperLog:query") + @Log(title = "重新通知", businessType = BusinessType.OTHER) + @PostMapping("/{id}/renotify") + public R renotify(@NotNull(message = "主键不能为空") @PathVariable Long id) { + return R.ok(drawingRouteOperLogRenotifyService.renotify(id)); + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/controller/MesDrawingController.java b/ruoyi-system/src/main/java/com/ruoyi/system/controller/MesDrawingController.java new file mode 100644 index 0000000..28bc795 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/controller/MesDrawingController.java @@ -0,0 +1,112 @@ +package com.ruoyi.system.controller; + +import cn.dev33.satoken.annotation.SaIgnore; +import com.alibaba.fastjson.JSONObject; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.R; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.system.domain.vo.mes.MesDrawingListVo; +import com.ruoyi.system.service.IMesDrawingService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import javax.servlet.http.HttpServletResponse; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +/** + * MES 图纸开放接口:按生产令号查询路径与下载地址,并按图号下载 PDF / DWG。 + *

+ * 鉴权:请求头 {@code X-App-Key} / {@code X-App-Secret},或 query 参数 {@code appKey} / {@code appSecret}。 + */ +@Slf4j +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/open/mes/drawing") +public class MesDrawingController extends BaseController { + + private final IMesDrawingService mesDrawingService; + + /** + * 按生产令号列出图纸。可选 materialCode 只返回该图号。 + */ + @SaIgnore + @GetMapping("/list") + public R list( + @RequestHeader(value = "X-App-Key", required = false) String headerAppKey, + @RequestHeader(value = "X-App-Secret", required = false) String headerAppSecret, + @RequestParam(value = "appKey", required = false) String appKey, + @RequestParam(value = "appSecret", required = false) String appSecret, + @RequestParam("productionOrderNo") String productionOrderNo, + @RequestParam(value = "materialCode", required = false) String materialCode) { + mesDrawingService.assertAuth(firstNonBlank(headerAppKey, appKey), firstNonBlank(headerAppSecret, appSecret)); + return R.ok(mesDrawingService.listDrawings(productionOrderNo, materialCode)); + } + + /** + * 按生产令号 + 物料编码下载图纸。fileType:pdf(默认)或 dwg。 + */ + @SaIgnore + @GetMapping("/download") + public void download( + @RequestHeader(value = "X-App-Key", required = false) String headerAppKey, + @RequestHeader(value = "X-App-Secret", required = false) String headerAppSecret, + @RequestParam(value = "appKey", required = false) String appKey, + @RequestParam(value = "appSecret", required = false) String appSecret, + @RequestParam("productionOrderNo") String productionOrderNo, + @RequestParam("materialCode") String materialCode, + @RequestParam(value = "fileType", required = false) String fileType, + HttpServletResponse response) { + try { + mesDrawingService.assertAuth(firstNonBlank(headerAppKey, appKey), firstNonBlank(headerAppSecret, appSecret)); + File file = mesDrawingService.resolveDownloadFile(productionOrderNo, materialCode, fileType); + response.setContentType("application/octet-stream"); + response.setCharacterEncoding("utf-8"); + String fileName = URLEncoder.encode(file.getName(), StandardCharsets.UTF_8.name()).replaceAll("\\+", "%20"); + response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName); + response.setContentLengthLong(file.length()); + try (FileInputStream fis = new FileInputStream(file); + OutputStream os = response.getOutputStream()) { + byte[] buffer = new byte[8192]; + int length; + while ((length = fis.read(buffer)) > 0) { + os.write(buffer, 0, length); + } + os.flush(); + } + } catch (ServiceException e) { + writeJsonFail(response, e.getMessage()); + } catch (Exception e) { + log.error("MES下载图纸失败 productionOrderNo={} materialCode={}", productionOrderNo, materialCode, e); + writeJsonFail(response, "下载失败: " + e.getMessage()); + } + } + + private static String firstNonBlank(String a, String b) { + return StringUtils.isNotBlank(a) ? a.trim() : StringUtils.trimToEmpty(b); + } + + private void writeJsonFail(HttpServletResponse response, String msg) { + try { + if (!response.isCommitted()) { + response.reset(); + } + response.setContentType("application/json;charset=utf-8"); + response.getWriter().write(JSONObject.toJSONString(R.fail(msg))); + } catch (IOException ex) { + log.error("写入MES图纸错误响应失败", ex); + } + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/controller/OrderProProgressReportController.java b/ruoyi-system/src/main/java/com/ruoyi/system/controller/OrderProProgressReportController.java new file mode 100644 index 0000000..9a29a60 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/controller/OrderProProgressReportController.java @@ -0,0 +1,77 @@ +package com.ruoyi.system.controller; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import com.ruoyi.common.annotation.Log; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.PageQuery; +import com.ruoyi.common.core.domain.R; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.common.utils.poi.ExcelUtil; +import com.ruoyi.system.domain.bo.OrderProProgressReportBo; +import com.ruoyi.system.domain.vo.OrderProProgressDesignerSummaryVo; +import com.ruoyi.system.domain.vo.OrderProProgressReportExportVo; +import com.ruoyi.system.domain.vo.OrderProProgressReportOverviewVo; +import com.ruoyi.system.domain.vo.OrderProProgressReportVo; +import com.ruoyi.system.service.IDrawingWorkloadService; +import com.ruoyi.system.service.IOrderProProgressReportService; +import lombok.RequiredArgsConstructor; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.servlet.http.HttpServletResponse; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 生产项目进度报表 + */ +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/system/orderProProgress") +public class OrderProProgressReportController extends BaseController { + + private final IOrderProProgressReportService orderProProgressReportService; + private final IDrawingWorkloadService drawingWorkloadService; + + @SaCheckPermission("system:orderProProgress:list") + @GetMapping("/overview") + public R overview(OrderProProgressReportBo bo) { + return R.ok(orderProProgressReportService.queryOverview(bo)); + } + + @SaCheckPermission("system:orderProProgress:list") + @GetMapping("/designerSummary") + public R> designerSummary(OrderProProgressReportBo bo) { + return R.ok(orderProProgressReportService.queryDesignerSummary(bo)); + } + + @SaCheckPermission("system:orderProProgress:list") + @GetMapping("/list") + public TableDataInfo list(OrderProProgressReportBo bo, PageQuery pageQuery) { + return orderProProgressReportService.queryPageList(bo, pageQuery); + } + + @SaCheckPermission("system:orderProProgress:list") + @Log(title = "读取图纸工作量", businessType = BusinessType.OTHER) + @PostMapping("/syncWorkload") + public R> syncWorkload(@RequestBody List ids) { + return R.ok(drawingWorkloadService.syncFromFtpBatch(ids)); + } + + @SaCheckPermission("system:orderProProgress:export") + @Log(title = "生产项目进度报表", businessType = BusinessType.EXPORT) + @PostMapping("/export") + public void export(OrderProProgressReportBo bo, HttpServletResponse response) { + List exportList = orderProProgressReportService.queryList(bo).stream() + .map(OrderProProgressReportExportVo::from) + .collect(Collectors.toList()); + ExcelUtil.exportExcel(exportList, "生产项目进度报表", OrderProProgressReportExportVo.class, response); + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/controller/ProcessOrderProController.java b/ruoyi-system/src/main/java/com/ruoyi/system/controller/ProcessOrderProController.java index 00e5ea1..f8a4ee9 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/controller/ProcessOrderProController.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/controller/ProcessOrderProController.java @@ -314,6 +314,12 @@ public class ProcessOrderProController extends BaseController { String s = iProcessOrderProService.executDrawing(orderPro); return R.ok(s); } + @SaCheckPermission("system:orderPro:query") + @GetMapping("/drawingState/{id}") + public R captureDrawingState(@PathVariable Long id) { + return R.ok(iProcessOrderProService.captureCurrentDrawingState(id)); + } + @GetMapping("/getstate") public R getPlotState() { String result = HttpUtils.sendGet("http://192.168.14.218:9000/getstate", "UTF-8"); @@ -2002,6 +2008,7 @@ public class ProcessOrderProController extends BaseController { */ @SaCheckPermission("system:orderPro:sendProcessRoutePlanExcel") @Log(title = "发送工艺计划表", businessType = BusinessType.OTHER) + @RepeatSubmit @PostMapping("/sendProcessRoutePlanExcel/{id}") public R sendProcessRoutePlanExcel(@NotNull(message = "项目ID不能为空") @PathVariable Long id) { return iProcessOrderProService.sendProcessRoutePlanExcel(id); diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/controller/ProcessRouteController.java b/ruoyi-system/src/main/java/com/ruoyi/system/controller/ProcessRouteController.java index e683b26..4c76562 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/controller/ProcessRouteController.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/controller/ProcessRouteController.java @@ -499,7 +499,7 @@ public class ProcessRouteController extends BaseController { } String todoProjectName = orderForLock == null ? null : orderForLock.getProductionName(); - Date todoProjectEndTime = orderForLock == null ? null : orderForLock.getProjectEndTime(); + Date todoRouteUptime = orderForLock == null ? null : orderForLock.getRouteUptime(); String planUserIds = dingTalkProperties.getPlanUserId(); // 异步:按 plan-user-id 发文件 + 发工艺审核卡片(落盘文件保留) String finalNickName = currentUserNickName; @@ -516,7 +516,7 @@ public class ProcessRouteController extends BaseController { DingTodoSceneCodes.ROUTE_UPLOAD_PLAN, productionOrderNo, todoProjectName, - todoProjectEndTime, + todoRouteUptime, uploadFile, planUserIds); if (StringUtils.isNotBlank(todoError) && filePush != null) { diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/controller/VmiMaterialController.java b/ruoyi-system/src/main/java/com/ruoyi/system/controller/VmiMaterialController.java new file mode 100644 index 0000000..435c887 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/controller/VmiMaterialController.java @@ -0,0 +1,132 @@ +package com.ruoyi.system.controller; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import com.ruoyi.common.annotation.Log; +import com.ruoyi.common.annotation.RepeatSubmit; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.PageQuery; +import com.ruoyi.common.core.domain.R; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.core.validate.AddGroup; +import com.ruoyi.common.core.validate.EditGroup; +import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.system.domain.bo.VmiMaterialBo; +import com.ruoyi.system.domain.bo.VmiPurchaseReqLogBo; +import com.ruoyi.system.domain.bo.VmiReplenishConfirmBo; +import com.ruoyi.system.domain.bo.VmiReplenishPreviewBo; +import com.ruoyi.system.domain.bo.VmiStockAlertLogBo; +import com.ruoyi.system.domain.vo.VmiMaterialVo; +import com.ruoyi.system.domain.vo.VmiPurchaseReqLogVo; +import com.ruoyi.system.domain.vo.VmiReplenishPreviewItemVo; +import com.ruoyi.system.domain.vo.VmiStockAlertLogVo; +import com.ruoyi.system.service.IVmiMaterialService; +import lombok.RequiredArgsConstructor; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; +import java.util.Arrays; +import java.util.List; + +/** + * VMI物料安全库存 + */ +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/system/vmiMaterial") +public class VmiMaterialController extends BaseController { + + private final IVmiMaterialService vmiMaterialService; + + @SaCheckPermission("system:vmiMaterial:list") + @GetMapping("/list") + public TableDataInfo list(VmiMaterialBo bo, PageQuery pageQuery) { + return vmiMaterialService.queryPageList(bo, pageQuery); + } + + @SaCheckPermission("system:vmiMaterial:query") + @GetMapping("/{id}") + public R getInfo(@NotNull @PathVariable Long id) { + return R.ok(vmiMaterialService.queryById(id)); + } + + @SaCheckPermission("system:vmiMaterial:add") + @Log(title = "VMI物料", businessType = BusinessType.INSERT) + @RepeatSubmit + @PostMapping + public R add(@Validated(AddGroup.class) @RequestBody VmiMaterialBo bo) { + return toAjax(vmiMaterialService.insertByBo(bo)); + } + + @SaCheckPermission("system:vmiMaterial:edit") + @Log(title = "VMI物料", businessType = BusinessType.UPDATE) + @RepeatSubmit + @PutMapping + public R edit(@Validated(EditGroup.class) @RequestBody VmiMaterialBo bo) { + return toAjax(vmiMaterialService.updateByBo(bo)); + } + + @SaCheckPermission("system:vmiMaterial:remove") + @Log(title = "VMI物料", businessType = BusinessType.DELETE) + @DeleteMapping("/{ids}") + public R remove(@NotEmpty @PathVariable Long[] ids) { + return toAjax(vmiMaterialService.deleteWithValidByIds(Arrays.asList(ids))); + } + + @SaCheckPermission("system:vmiMaterial:edit") + @Log(title = "VMI低库存扫描", businessType = BusinessType.OTHER) + @PostMapping("/scan") + public R scan() { + return R.ok(vmiMaterialService.scanLowStock()); + } + + @SaCheckPermission("system:vmiMaterial:edit") + @PostMapping("/confirm/preview") + public R> confirmPreview(@RequestBody VmiReplenishPreviewBo bo) { + return R.ok(vmiMaterialService.previewReplenish(bo)); + } + + @SaCheckPermission("system:vmiMaterial:edit") + @Log(title = "VMI补货确认", businessType = BusinessType.OTHER) + @PostMapping("/confirm") + public R confirm(@RequestBody VmiReplenishConfirmBo bo) { + return R.ok(vmiMaterialService.confirmReplenish(bo)); + } + + @SaCheckPermission("system:vmiMaterial:edit") + @Log(title = "VMI忽略补货", businessType = BusinessType.OTHER) + @PostMapping("/ignore/{alertId}") + public R ignore(@NotNull @PathVariable Long alertId) { + vmiMaterialService.ignoreAlert(alertId); + return R.ok(); + } + + @SaCheckPermission("system:vmiMaterial:edit") + @Log(title = "VMI需求预测通知", businessType = BusinessType.OTHER) + @PostMapping("/forecast/notify/{id}") + public R notifyForecast(@NotNull @PathVariable Long id) { + vmiMaterialService.notifyForecast(id); + return R.ok(); + } + + @SaCheckPermission("system:vmiMaterial:list") + @GetMapping("/alert/list") + public TableDataInfo alertList(VmiStockAlertLogBo bo, PageQuery pageQuery) { + return vmiMaterialService.queryAlertPage(bo, pageQuery); + } + + @SaCheckPermission("system:vmiMaterial:list") + @GetMapping("/req/list") + public TableDataInfo reqList(VmiPurchaseReqLogBo bo, PageQuery pageQuery) { + return vmiMaterialService.queryReqPage(bo, pageQuery); + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/controller/WorkAlertGroupPushController.java b/ruoyi-system/src/main/java/com/ruoyi/system/controller/WorkAlertGroupPushController.java new file mode 100644 index 0000000..8235695 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/controller/WorkAlertGroupPushController.java @@ -0,0 +1,105 @@ +package com.ruoyi.system.controller; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import com.ruoyi.common.annotation.Log; +import com.ruoyi.common.core.controller.BaseController; +import com.ruoyi.common.core.domain.R; +import com.ruoyi.common.core.validate.AddGroup; +import com.ruoyi.common.core.validate.EditGroup; +import com.ruoyi.common.enums.BusinessType; +import com.ruoyi.system.domain.bo.WorkAlertGroupPushBo; +import com.ruoyi.system.domain.bo.WorkAlertGroupPushConfigBo; +import com.ruoyi.system.domain.vo.WorkAlertGroupPushConfigVo; +import com.ruoyi.system.service.IWorkAlertGroupPushConfigService; +import com.ruoyi.system.service.WorkAlertPushScheduleService; +import com.ruoyi.system.service.support.WorkAlertPushTypeRegistry; +import lombok.RequiredArgsConstructor; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +/** + * 指定钉钉群会话推送预警数据(与预警推送配置同一套任务数据) + */ +@Validated +@RequiredArgsConstructor +@RestController +@RequestMapping("/system/workAlertGroupPush") +public class WorkAlertGroupPushController extends BaseController { + + private final WorkAlertPushScheduleService workAlertPushScheduleService; + private final IWorkAlertGroupPushConfigService workAlertGroupPushConfigService; + + @SaCheckPermission("system:workAlertGroupPush:list") + @GetMapping("/pushTypes") + public R>> pushTypes() { + List> list = new ArrayList<>(); + for (WorkAlertPushTypeRegistry.PushType type : WorkAlertPushTypeRegistry.groupConversationTypes()) { + list.add(type.toOption()); + } + return R.ok(list); + } + + @SaCheckPermission("system:workAlertGroupPush:send") + @Log(title = "指定群预警推送", businessType = BusinessType.OTHER) + @PostMapping("/send") + public R send(@Validated @RequestBody WorkAlertGroupPushBo bo) { + workAlertPushScheduleService.executeGroupPush( + bo.getPushCode(), bo.getOpenConversationId(), bo.getRobotCode()); + return R.ok(); + } + + @SaCheckPermission("system:workAlertGroupPush:list") + @GetMapping("/schedule/list") + public R> scheduleList() { + return R.ok(workAlertGroupPushConfigService.queryList()); + } + + @SaCheckPermission("system:workAlertGroupPush:list") + @GetMapping("/schedule/{id}") + public R scheduleInfo(@NotNull @PathVariable Long id) { + return R.ok(workAlertGroupPushConfigService.queryById(id)); + } + + @SaCheckPermission("system:workAlertGroupPush:send") + @Log(title = "群预警定时配置", businessType = BusinessType.INSERT) + @PostMapping("/schedule") + public R addSchedule(@Validated(AddGroup.class) @RequestBody WorkAlertGroupPushConfigBo bo) { + return toAjax(workAlertGroupPushConfigService.insertByBo(bo)); + } + + @SaCheckPermission("system:workAlertGroupPush:send") + @Log(title = "群预警定时配置", businessType = BusinessType.UPDATE) + @PutMapping("/schedule") + public R editSchedule(@Validated(EditGroup.class) @RequestBody WorkAlertGroupPushConfigBo bo) { + return toAjax(workAlertGroupPushConfigService.updateByBo(bo)); + } + + @SaCheckPermission("system:workAlertGroupPush:send") + @Log(title = "群预警定时配置", businessType = BusinessType.DELETE) + @DeleteMapping("/schedule/{ids}") + public R removeSchedule(@NotEmpty @PathVariable Long[] ids) { + return toAjax(workAlertGroupPushConfigService.deleteWithValidByIds(Arrays.asList(ids))); + } + + @SaCheckPermission("system:workAlertGroupPush:send") + @Log(title = "群预警定时配置", businessType = BusinessType.OTHER) + @PostMapping("/schedule/trigger/{id}") + public R triggerSchedule(@NotNull @PathVariable Long id) { + workAlertGroupPushConfigService.trigger(id); + return R.ok(); + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/DingTodoLog.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/DingTodoLog.java new file mode 100644 index 0000000..bf91f8e --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/DingTodoLog.java @@ -0,0 +1,54 @@ +package com.ruoyi.system.domain; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.io.Serializable; +import java.util.Date; + +/** + * 已经发起的钉钉待办 ding_todo_log + */ +@Data +@TableName("ding_todo_log") +public class DingTodoLog implements Serializable { + + private static final long serialVersionUID = 1L; + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + private String sceneCode; + + private String sceneName; + + private String productionOrderNo; + + private String projectName; + + private String title; + + private String content; + + private String userIds; + + private String createUser; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date dueTime; + + private String fileName; + + /** SUCCESS 或 FAIL */ + private String status; + + private String errorMsg; + + private String responseBody; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date createTime; +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/DingTodoScene.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/DingTodoScene.java new file mode 100644 index 0000000..d372d0e --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/DingTodoScene.java @@ -0,0 +1,54 @@ +package com.ruoyi.system.domain; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.ruoyi.common.core.domain.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 哪些业务方法要创建钉钉待办 ding_todo_scene + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("ding_todo_scene") +public class DingTodoScene extends BaseEntity { + + private static final long serialVersionUID = 1L; + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** 方法编码,与代码里的调用点对应 */ + private String sceneCode; + + /** 页面上看到的方法名称 */ + private String sceneName; + + /** 0 不发待办,1 发送待办 */ + private String enabled; + + /** + * 接收人。类型为 name 时填姓名,多个用逗号,例如田志阳;类型为 code 时填考勤工号。 + * 类型为 code 且这里为空时,用 notify-user-id 或 plan-user-id。 + */ + private String userIds; + + /** NOTIFY 或 PLAN,仅 recipientType=code 且接收人为空时使用 */ + private String userSource; + + /** code=考勤工号,name=姓名 */ + private String recipientType; + + /** 创建人,类型与接收人一致。空则用接收人里的第一个 */ + private String createUser; + + /** 没有未来的项目结束时间时,截止为当前时间加该天数 */ + private Integer dueDays; + + /** 待办正文第一句 */ + private String contentLead; + + private String remark; +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/DrawingRouteOperLog.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/DrawingRouteOperLog.java new file mode 100644 index 0000000..0742afd --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/DrawingRouteOperLog.java @@ -0,0 +1,59 @@ +package com.ruoyi.system.domain; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.util.Date; + +/** + * 出图 / 工艺上传 / 钉钉推送业务留痕。 + */ +@Data +@TableName("drawing_route_oper_log") +public class DrawingRouteOperLog { + + @TableId(type = IdType.AUTO) + private Long id; + + /** DRAWING_EXEC / DRAWING_NOTIFY / ROUTE_UPLOAD / ROUTE_NOTIFY / SKIP_PROCESS_NOTIFY / ELECTRICAL_BOM_NOTIFY */ + private String bizType; + + /** MANUAL / PLM */ + private String channel; + + private String apiPath; + + private Long projectId; + + private String productionOrderNo; + + private String drawingMode; + + /** MARKDOWN / FILE / CARD / MARKDOWN_AND_FILE / FILE_AND_CARD */ + private String notifyType; + + private String notifyUserIds; + + private String messageTitle; + + private String messageContent; + + private String fileName; + + /** SUCCESS / FAIL / PARTIAL */ + private String status; + + private String errorMsg; + + private String summaryJson; + + private String operator; + + private String clientIp; + + private Long durationMs; + + private Date operTime; +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/ProcessDesignReport.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/ProcessDesignReport.java new file mode 100644 index 0000000..1a820bf --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/ProcessDesignReport.java @@ -0,0 +1,37 @@ +package com.ruoyi.system.domain; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.ruoyi.common.core.domain.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.math.BigDecimal; +import java.util.Date; + +/** + * 设计报工明细 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("process_design_report") +public class ProcessDesignReport extends BaseEntity { + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + private Long taskId; + + private Date reportDate; + + private BigDecimal workHours; + + private Integer progress; + + private String content; + + private String reportBy; + + private Date reportTime; +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/ProcessDesignTask.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/ProcessDesignTask.java new file mode 100644 index 0000000..2d311b1 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/ProcessDesignTask.java @@ -0,0 +1,57 @@ +package com.ruoyi.system.domain; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.ruoyi.common.core.domain.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.math.BigDecimal; +import java.util.Date; + +/** + * 设计个人任务 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("process_design_task") +public class ProcessDesignTask extends BaseEntity { + + public static final int STATUS_PENDING = 0; + public static final int STATUS_WORKING = 1; + public static final int STATUS_DONE = 2; + /** 进度达到 80 且未到 100 */ + public static final int STATUS_DRAWING = 3; + /** 进度达到 100,仍可继续补报工时 */ + public static final int STATUS_REVIEW = 4; + public static final int STATUS_REVOKED = 9; + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + private Long orderProId; + + private String productionOrderNo; + + /** 设计人登录名,等于字典 plan_proer 的键值 */ + private String designerUserName; + + /** 设计类型:2D 二维,3D 三维 */ + private String designDim; + + /** 0待接收 1设计中 2已完成 3设计出图中 4工艺待审核 9已撤回 */ + private Integer status; + + private Integer progress; + + private BigDecimal totalHours; + + private Date assignTime; + + private Date startTime; + + private Date finishTime; + + private String remark; +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/ProcessOrderPro.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/ProcessOrderPro.java index 8e453ae..1427a6d 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/domain/ProcessOrderPro.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/ProcessOrderPro.java @@ -45,6 +45,22 @@ public class ProcessOrderPro extends BaseEntity { * 出图模式:{@link com.ruoyi.system.constant.DrawingMode#NORMAL} / {@link com.ruoyi.system.constant.DrawingMode#PART};执行出图前必填 */ private String drawingMode; + /** + * 最近一次出图 processstate:4 完成,5 失败 + */ + private Integer drawingState; + /** + * 最近一次出图提示,例如「图号唯一性检查失败!」 + */ + private String drawingStateMsg; + /** + * 最近一次出图输出路径 + */ + private String drawingStatePath; + /** + * 最近一次出图状态写入时间 + */ + private Date drawingStateTime; /** * 是否做工艺:0否 1是 */ diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/RatioModel.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/RatioModel.java new file mode 100644 index 0000000..37f063b --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/RatioModel.java @@ -0,0 +1,25 @@ +package com.ruoyi.system.domain; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.ruoyi.common.core.domain.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("ratio_model") +public class RatioModel extends BaseEntity { + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** 所属速比 */ + private Long ratioConfigId; + + /** 减速机型号 */ + private String modelCode; + + private Integer status; +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/VmiMaterial.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/VmiMaterial.java new file mode 100644 index 0000000..16ec828 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/VmiMaterial.java @@ -0,0 +1,77 @@ +package com.ruoyi.system.domain; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.ruoyi.common.core.domain.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.math.BigDecimal; +import java.util.Date; + +/** + * VMI物料安全库存对象 vmi_material + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("vmi_material") +public class VmiMaterial extends BaseEntity { + + private static final long serialVersionUID = 1L; + + /** 主键 */ + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** 物料编码 */ + private String materialCode; + + /** 名称 */ + private String materialName; + + /** 材料 */ + private String materialQuality; + + /** 供应商编码 */ + private String supplierCode; + + /** 供应商 */ + private String supplierName; + + /** 最小包装量 */ + private BigDecimal minPackQty; + + /** 建议最高安全库存 */ + private BigDecimal maxSafeQty; + + /** 建议最低安全库存 */ + private BigDecimal minSafeQty; + + /** 仓储部VMI负责人钉钉userId,多个逗号分隔 */ + private String dingUserId; + + /** 需求预测数量 */ + private BigDecimal forecastQty; + + /** 需求预测说明(旺季、订单波动等) */ + private String forecastRemark; + + /** 即时库存(最近一次扫描) */ + private BigDecimal lastStockQty; + + /** 最近一次扫描的预计可用库存 */ + private BigDecimal lastAvailableQty; + + /** 最近一次扫描时间 */ + private Date lastCheckTime; + + /** 库存状态:NORMAL正常 / LOW低于安全库存 */ + private String stockStatus; + + /** 是否启用:0停用 1启用 */ + private String enabled; + + /** 备注 */ + private String remark; +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/VmiPurchaseReqLog.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/VmiPurchaseReqLog.java new file mode 100644 index 0000000..5ab9cf5 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/VmiPurchaseReqLog.java @@ -0,0 +1,55 @@ +package com.ruoyi.system.domain; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.ruoyi.common.core.domain.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.math.BigDecimal; + +/** + * VMI采购申请推送记录对象 vmi_purchase_req_log + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("vmi_purchase_req_log") +public class VmiPurchaseReqLog extends BaseEntity { + + private static final long serialVersionUID = 1L; + + /** 主键 */ + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** 对应的低库存触发记录ID */ + private Long alertLogId; + + /** VMI物料主键 */ + private Long materialId; + + /** 物料编码 */ + private String materialCode; + + /** 物料名称 */ + private String materialName; + + /** 供应商编码 */ + private String supplierCode; + + /** 采购申请数量 */ + private BigDecimal reqQty; + + /** 金蝶采购申请单号 */ + private String kingdeeBillNo; + + /** 金蝶采购申请FID */ + private String kingdeeFid; + + /** 推送结果:SUCCESS成功 / FAIL失败 */ + private String pushStatus; + + /** 金蝶返回或失败原因 */ + private String pushMsg; +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/VmiStockAlertLog.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/VmiStockAlertLog.java new file mode 100644 index 0000000..a00ceca --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/VmiStockAlertLog.java @@ -0,0 +1,62 @@ +package com.ruoyi.system.domain; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.ruoyi.common.core.domain.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.math.BigDecimal; +import java.util.Date; + +/** + * VMI低库存触发记录对象 vmi_stock_alert_log + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("vmi_stock_alert_log") +public class VmiStockAlertLog extends BaseEntity { + + private static final long serialVersionUID = 1L; + + /** 主键 */ + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** VMI物料主键 */ + private Long materialId; + + /** 物料编码 */ + private String materialCode; + + /** 物料名称 */ + private String materialName; + + /** 触发时的最低安全库存 */ + private BigDecimal minSafeQty; + + /** 触发时的即时库存 */ + private BigDecimal currentQty; + + /** 触发时的预计可用库存 */ + private BigDecimal availableQty; + + /** 处理状态:PENDING待确认 / CONFIRMED已确认 / IGNORED已忽略 */ + private String alertStatus; + + /** 钉钉推送结果:SUCCESS成功 / FAIL失败 */ + private String dingPushStatus; + + /** 钉钉推送返回信息 */ + private String dingPushMsg; + + /** 确认人或忽略人 */ + private String confirmBy; + + /** 确认或忽略时间 */ + private Date confirmTime; + + /** 备注 */ + private String remark; +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/WorkAlertGroupPushConfig.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/WorkAlertGroupPushConfig.java new file mode 100644 index 0000000..b22927f --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/WorkAlertGroupPushConfig.java @@ -0,0 +1,50 @@ +package com.ruoyi.system.domain; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.ruoyi.common.core.domain.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.Date; + +/** + * 指定群预警定时推送配置 work_alert_group_push_config + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("work_alert_group_push_config") +public class WorkAlertGroupPushConfig extends BaseEntity { + + private static final long serialVersionUID = 1L; + + @TableId(value = "id", type = IdType.AUTO) + private Long id; + + /** 配置名称 */ + private String configName; + + /** 群会话 ID */ + private String openConversationId; + + /** 机器人 Code */ + private String robotCode; + + /** 推送任务编码,逗号分隔 */ + private String pushCodes; + + /** 每日推送时间 HH:mm */ + private String pushTime; + + /** 是否启用 0否 1是 */ + private String enabled; + + /** 上次推送日期 */ + @JsonFormat(pattern = "yyyy-MM-dd") + private Date lastPushDate; + + /** 备注 */ + private String remark; +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/DesignTaskDesignDimBo.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/DesignTaskDesignDimBo.java new file mode 100644 index 0000000..5234241 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/DesignTaskDesignDimBo.java @@ -0,0 +1,19 @@ +package com.ruoyi.system.domain.bo; + +import lombok.Data; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +/** + * 标注任务的二维 / 三维 + */ +@Data +public class DesignTaskDesignDimBo { + + @NotNull(message = "任务不能为空") + private Long taskId; + + @NotBlank(message = "请选择二维或三维") + private String designDim; +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/DesignTaskQueryBo.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/DesignTaskQueryBo.java new file mode 100644 index 0000000..f362f87 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/DesignTaskQueryBo.java @@ -0,0 +1,30 @@ +package com.ruoyi.system.domain.bo; + +import lombok.Data; + +/** + * 我的设计任务查询 + */ +@Data +public class DesignTaskQueryBo { + + /** 0待接收 1设计中 2已完成 3设计出图中 4工艺待审核 9已撤回。空则不含已撤回 */ + private Integer status; + + private String productionOrderNo; + + /** true 只看逾期,false 只看未逾期 */ + private Boolean overdue; + + /** mine 我的,all 全部,pending 待我接收 */ + private String scope; + + /** 生产令号、项目名称、设计师 */ + private String keyword; + + /** 2D、3D,空为全部;none 为未标注 */ + private String designDim; + + /** overdue 已逾期,today 今天到期,upcoming 未逾期 */ + private String due; +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/DesignTaskReportBo.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/DesignTaskReportBo.java new file mode 100644 index 0000000..ecafd7c --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/DesignTaskReportBo.java @@ -0,0 +1,33 @@ +package com.ruoyi.system.domain.bo; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Size; +import java.math.BigDecimal; +import java.util.Date; + +/** + * 设计报工 + */ +@Data +public class DesignTaskReportBo { + + @NotNull(message = "任务不能为空") + private Long taskId; + + @NotNull(message = "请填写本次工时") + private BigDecimal workHours; + + @NotNull(message = "请填写当前进度") + private Integer progress; + + @NotBlank(message = "请填写工作说明") + @Size(max = 1000, message = "工作说明不能超过1000个字符") + private String content; + + @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8") + private Date reportDate; +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/DrawingRouteOperLogBo.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/DrawingRouteOperLogBo.java new file mode 100644 index 0000000..e7635e5 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/DrawingRouteOperLogBo.java @@ -0,0 +1,23 @@ +package com.ruoyi.system.domain.bo; + +import com.ruoyi.common.core.domain.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = true) +public class DrawingRouteOperLogBo extends BaseEntity { + + private Long id; + + /** DRAWING_EXEC / DRAWING_NOTIFY / ROUTE_UPLOAD / ROUTE_NOTIFY */ + private String bizType; + + /** MANUAL / PLM */ + private String channel; + + private String productionOrderNo; + + /** SUCCESS / FAIL / PARTIAL */ + private String status; +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/OrderProBomStatusBo.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/OrderProBomStatusBo.java new file mode 100644 index 0000000..63f98ad --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/OrderProBomStatusBo.java @@ -0,0 +1,23 @@ +package com.ruoyi.system.domain.bo; + +import lombok.Data; + +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.NotNull; +import java.util.List; + +/** + * 批量设置项目状态入参。 + * 不走整单编辑校验(设计人等),只改 bomStatus。 + */ +@Data +public class OrderProBomStatusBo { + + /** 选中的项目主键 */ + @NotEmpty(message = "项目不能为空") + private List ids; + + /** 项目状态(字典 process_status) */ + @NotNull(message = "项目状态不能为空") + private Long bomStatus; +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/OrderProProgressReportBo.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/OrderProProgressReportBo.java new file mode 100644 index 0000000..1dabc3d --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/OrderProProgressReportBo.java @@ -0,0 +1,34 @@ +package com.ruoyi.system.domain.bo; + +import com.ruoyi.common.core.domain.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.List; + +/** + * 生产项目进度报表查询。 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class OrderProProgressReportBo extends BaseEntity { + + private String productionOrderNo; + + private String productionName; + + /** 设计人(字典值,模糊匹配) */ + private String drawingBy; + + /** 仅查询未填写设计人的项目 */ + private Boolean unassignedDesigner; + + /** 手动 / PLM */ + private String source; + + private String isEnterpriseStandard; + + private List isEnterpriseStandardList; + + private String createBy; +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/VmiMaterialBo.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/VmiMaterialBo.java new file mode 100644 index 0000000..d728b93 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/VmiMaterialBo.java @@ -0,0 +1,67 @@ +package com.ruoyi.system.domain.bo; + +import com.ruoyi.common.core.domain.BaseEntity; +import com.ruoyi.common.core.validate.AddGroup; +import com.ruoyi.common.core.validate.EditGroup; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import java.math.BigDecimal; + +/** + * VMI物料安全库存业务对象 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class VmiMaterialBo extends BaseEntity { + + /** 主键 */ + @NotNull(message = "主键不能为空", groups = { EditGroup.class }) + private Long id; + + /** 物料编码 */ + @NotBlank(message = "物料编码不能为空", groups = { AddGroup.class, EditGroup.class }) + private String materialCode; + + /** 名称 */ + private String materialName; + + /** 材料 */ + private String materialQuality; + + /** 供应商编码 */ + private String supplierCode; + + /** 供应商 */ + private String supplierName; + + /** 最小包装量 */ + private BigDecimal minPackQty; + + /** 建议最高安全库存 */ + private BigDecimal maxSafeQty; + + /** 建议最低安全库存 */ + @NotNull(message = "建议最低安全库存不能为空", groups = { AddGroup.class, EditGroup.class }) + private BigDecimal minSafeQty; + + /** 仓储部VMI负责人钉钉userId,多个逗号分隔 */ + private String dingUserId; + + /** 需求预测数量 */ + private BigDecimal forecastQty; + + /** 需求预测说明(旺季、订单波动等) */ + private String forecastRemark; + + /** 是否启用:0停用 1启用 */ + private String enabled; + + /** 备注 */ + private String remark; + + /** 库存状态:NORMAL正常 / LOW低于安全库存 */ + private String stockStatus; +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/VmiPurchaseReqLogBo.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/VmiPurchaseReqLogBo.java new file mode 100644 index 0000000..34ca54a --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/VmiPurchaseReqLogBo.java @@ -0,0 +1,25 @@ +package com.ruoyi.system.domain.bo; + +import com.ruoyi.common.core.domain.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * VMI采购申请推送记录查询对象 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class VmiPurchaseReqLogBo extends BaseEntity { + + /** 主键 */ + private Long id; + + /** 物料编码 */ + private String materialCode; + + /** 推送结果:SUCCESS成功 / FAIL失败 */ + private String pushStatus; + + /** 金蝶采购申请单号 */ + private String kingdeeBillNo; +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/VmiReplenishConfirmBo.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/VmiReplenishConfirmBo.java new file mode 100644 index 0000000..2682e22 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/VmiReplenishConfirmBo.java @@ -0,0 +1,25 @@ +package com.ruoyi.system.domain.bo; + +import lombok.Data; + +import java.math.BigDecimal; +import java.util.List; + +/** + * VMI补货确认请求对象(支持单条或批量勾选) + */ +@Data +public class VmiReplenishConfirmBo { + + /** 单条触发记录ID(兼容旧用法) */ + private Long alertId; + + /** 批量勾选的触发记录ID(兼容;优先使用 lines) */ + private List alertIds; + + /** 单条确认时的采购申请数量(兼容) */ + private BigDecimal reqQty; + + /** 带数量的明细行(预览窗口确认后提交) */ + private List lines; +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/VmiReplenishLineBo.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/VmiReplenishLineBo.java new file mode 100644 index 0000000..dc5638a --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/VmiReplenishLineBo.java @@ -0,0 +1,21 @@ +package com.ruoyi.system.domain.bo; + +import lombok.Data; + +import java.math.BigDecimal; + +/** + * VMI补货确认明细行 + */ +@Data +public class VmiReplenishLineBo { + + /** 触发记录ID */ + private Long alertId; + + /** 采购申请数量(管理员可改) */ + private BigDecimal reqQty; + + /** 到货时间,格式 yyyy-MM-dd 或 yyyy-MM-dd HH:mm:ss */ + private String arrivalDate; +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/VmiReplenishPreviewBo.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/VmiReplenishPreviewBo.java new file mode 100644 index 0000000..0f9eb29 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/VmiReplenishPreviewBo.java @@ -0,0 +1,16 @@ +package com.ruoyi.system.domain.bo; + +import lombok.Data; + +import java.util.List; + +/** + * VMI补货预览请求 + */ +@Data +public class VmiReplenishPreviewBo { + + private Long alertId; + + private List alertIds; +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/VmiStockAlertLogBo.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/VmiStockAlertLogBo.java new file mode 100644 index 0000000..44c7de0 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/VmiStockAlertLogBo.java @@ -0,0 +1,22 @@ +package com.ruoyi.system.domain.bo; + +import com.ruoyi.common.core.domain.BaseEntity; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * VMI低库存触发记录查询对象 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class VmiStockAlertLogBo extends BaseEntity { + + /** 主键 */ + private Long id; + + /** 物料编码 */ + private String materialCode; + + /** 处理状态:PENDING待确认 / CONFIRMED已确认 / IGNORED已忽略 */ + private String alertStatus; +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/WorkAlertGroupPushBo.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/WorkAlertGroupPushBo.java new file mode 100644 index 0000000..cee9f7d --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/WorkAlertGroupPushBo.java @@ -0,0 +1,21 @@ +package com.ruoyi.system.domain.bo; + +import lombok.Data; + +import javax.validation.constraints.NotBlank; + +/** + * 指定钉钉群会话推送预警数据 + */ +@Data +public class WorkAlertGroupPushBo { + + @NotBlank(message = "推送编码不能为空") + private String pushCode; + + @NotBlank(message = "群会话ID不能为空") + private String openConversationId; + + /** 机器人 Code,空则使用默认预警推送机器人 */ + private String robotCode; +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/WorkAlertGroupPushConfigBo.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/WorkAlertGroupPushConfigBo.java new file mode 100644 index 0000000..9e03053 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/WorkAlertGroupPushConfigBo.java @@ -0,0 +1,41 @@ +package com.ruoyi.system.domain.bo; + +import com.ruoyi.common.core.domain.BaseEntity; +import com.ruoyi.common.core.validate.AddGroup; +import com.ruoyi.common.core.validate.EditGroup; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Pattern; + +/** + * 指定群预警定时推送配置业务对象 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class WorkAlertGroupPushConfigBo extends BaseEntity { + + @NotNull(message = "主键不能为空", groups = { EditGroup.class }) + private Long id; + + private String configName; + + @NotBlank(message = "群会话ID不能为空", groups = { AddGroup.class, EditGroup.class }) + private String openConversationId; + + private String robotCode; + + @NotBlank(message = "请选择推送任务", groups = { AddGroup.class, EditGroup.class }) + private String pushCodes; + + @NotBlank(message = "推送时间不能为空", groups = { AddGroup.class, EditGroup.class }) + @Pattern(regexp = "^([01]\\d|2[0-3]):[0-5]\\d$", message = "推送时间格式应为 HH:mm", groups = { AddGroup.class, EditGroup.class }) + private String pushTime; + + @NotBlank(message = "启用状态不能为空", groups = { AddGroup.class, EditGroup.class }) + private String enabled; + + private String remark; +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/plm/PlmGeneralDrawingUtils.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/plm/PlmGeneralDrawingUtils.java new file mode 100644 index 0000000..4b19e84 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/bo/plm/PlmGeneralDrawingUtils.java @@ -0,0 +1,98 @@ +package com.ruoyi.system.domain.bo.plm; + +import com.ruoyi.common.utils.StringUtils; + +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * PLM 总图号(图号/物料编码以 {@code .00} 结尾)识别与归属。 + */ +public final class PlmGeneralDrawingUtils { + + public static final String GENERAL_DRAWING_SUFFIX = ".00"; + + private PlmGeneralDrawingUtils() { + } + + /** 工艺状态:1=已审核 */ + public static boolean isRouteApproved(Long routeStatus) { + return routeStatus != null && routeStatus == 1L; + } + + /** 是否总图号:trim 后以 {@code .00} 结尾。 */ + public static boolean isGeneralDrawingNo(String code) { + String s = StringUtils.trimToEmpty(code); + return s.endsWith(GENERAL_DRAWING_SUFFIX); + } + + /** {@code FS26062.00} → {@code FS26062};非总图号原样返回 trim 值。 */ + public static String generalDrawingPrefix(String generalDrawingNo) { + String s = StringUtils.trimToEmpty(generalDrawingNo); + if (s.endsWith(GENERAL_DRAWING_SUFFIX)) { + return s.substring(0, s.length() - GENERAL_DRAWING_SUFFIX.length()); + } + return s; + } + + public static Set collectGeneralDrawingNos(Collection codes) { + Set result = new LinkedHashSet<>(); + if (codes == null) { + return result; + } + for (String code : codes) { + String s = StringUtils.trimToEmpty(code); + if (isGeneralDrawingNo(s)) { + result.add(s); + } + } + return result; + } + + /** + * 物料是否归属某总图号:等于总图号,或以「去掉 .00 后的前缀 + .」开头。 + */ + public static boolean belongsToGeneralDrawing(String materialCode, String generalDrawingNo) { + String code = StringUtils.trimToEmpty(materialCode); + String general = StringUtils.trimToEmpty(generalDrawingNo); + if (code.isEmpty() || general.isEmpty() || !isGeneralDrawingNo(general)) { + return false; + } + if (code.equals(general)) { + return true; + } + String prefix = generalDrawingPrefix(general); + return StringUtils.isNotBlank(prefix) && code.startsWith(prefix + "."); + } + + /** 在候选总图号中取最长匹配(更具体的总图优先)。 */ + public static String resolveOwnerGeneralDrawing(String materialCode, Collection generalDrawings) { + String best = null; + if (generalDrawings == null) { + return null; + } + for (String general : generalDrawings) { + if (!belongsToGeneralDrawing(materialCode, general)) { + continue; + } + if (best == null || general.length() > best.length()) { + best = general; + } + } + return best; + } + + public static Set intersection(Set left, Set right) { + Set overlap = new LinkedHashSet<>(); + if (left == null || right == null || left.isEmpty() || right.isEmpty()) { + return overlap; + } + for (String item : left) { + if (right.contains(item)) { + overlap.add(item); + } + } + return overlap; + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/OrderProProgressTimeAgg.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/OrderProProgressTimeAgg.java new file mode 100644 index 0000000..33633df --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/OrderProProgressTimeAgg.java @@ -0,0 +1,18 @@ +package com.ruoyi.system.domain.dto; + +import lombok.Data; + +import java.util.Date; + +/** + * 进度报表时间聚合(按项目或令号)。 + */ +@Data +public class OrderProProgressTimeAgg { + + private Long id; + + private String productionOrderNo; + + private Date eventTime; +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/ratio/RatioBomParseResult.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/ratio/RatioBomParseResult.java new file mode 100644 index 0000000..abf42eb --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/ratio/RatioBomParseResult.java @@ -0,0 +1,40 @@ +package com.ruoyi.system.domain.dto.ratio; + +import lombok.Data; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +@Data +public class RatioBomParseResult { + + private List sheets = new ArrayList<>(); + + private List skippedSheets = new ArrayList<>(); + + @Data + public static class SheetBom { + private String sheetName; + private String ratioValue; + private List commonParts = new ArrayList<>(); + private List models = new ArrayList<>(); + } + + @Data + public static class ModelBom { + private String modelCode; + private List parts = new ArrayList<>(); + } + + @Data + public static class LineBom { + private String partCategory; + private String materialCode; + private String materialName; + private String material; + private BigDecimal bomQty; + /** Excel 行号,从 1 开始 */ + private int excelRow; + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/ratio/RatioImportResultDTO.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/ratio/RatioImportResultDTO.java new file mode 100644 index 0000000..d49b020 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/ratio/RatioImportResultDTO.java @@ -0,0 +1,24 @@ +package com.ruoyi.system.domain.dto.ratio; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +@Data +public class RatioImportResultDTO { + + private String message; + + private int sheetCount; + + private int modelCount; + + private int commonCount; + + private int specialCount; + + private List details = new ArrayList<>(); + + private List skippedSheets = new ArrayList<>(); +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/ratio/RatioModelOptionDTO.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/ratio/RatioModelOptionDTO.java new file mode 100644 index 0000000..7b2685b --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/dto/ratio/RatioModelOptionDTO.java @@ -0,0 +1,11 @@ +package com.ruoyi.system.domain.dto.ratio; + +import lombok.Data; + +@Data +public class RatioModelOptionDTO { + + private Long id; + + private String modelCode; +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/DesignAccountSyncVo.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/DesignAccountSyncVo.java new file mode 100644 index 0000000..340473d --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/DesignAccountSyncVo.java @@ -0,0 +1,23 @@ +package com.ruoyi.system.domain.vo; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * 设计账号同步结果 + */ +@Data +public class DesignAccountSyncVo { + + private int createdUsers; + + private int attachedRoles; + + private int skipped; + + private int backfillCreated; + + private List messages = new ArrayList<>(); +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/DesignTaskBoardVo.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/DesignTaskBoardVo.java new file mode 100644 index 0000000..472de72 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/DesignTaskBoardVo.java @@ -0,0 +1,38 @@ +package com.ruoyi.system.domain.vo; + +import lombok.Data; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +/** + * 设计工作台顶部卡片 + */ +@Data +public class DesignTaskBoardVo { + + private BigDecimal weekHours = BigDecimal.ZERO; + + private BigDecimal weekTarget = new BigDecimal("40"); + + private BigDecimal todayHours = BigDecimal.ZERO; + + private BigDecimal todayTarget = new BigDecimal("8"); + + private long todayReportCount; + + private long workingCount; + + private long pendingCount; + + private long overdueCount; + + private long mineCount; + + private long allCount; + + private long weekNewCount; + + private List last7 = new ArrayList<>(); +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/DesignTaskDayHoursVo.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/DesignTaskDayHoursVo.java new file mode 100644 index 0000000..d6f8c7f --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/DesignTaskDayHoursVo.java @@ -0,0 +1,22 @@ +package com.ruoyi.system.domain.vo; + +import lombok.Data; + +import java.math.BigDecimal; + +/** + * 近 7 天中的一天工时 + */ +@Data +public class DesignTaskDayHoursVo { + + /** yyyy-MM-dd */ + private String date; + + /** 一 到 日 */ + private String label; + + private BigDecimal hours = BigDecimal.ZERO; + + private Boolean today; +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/DesignTaskReportVo.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/DesignTaskReportVo.java new file mode 100644 index 0000000..b62f19b --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/DesignTaskReportVo.java @@ -0,0 +1,32 @@ +package com.ruoyi.system.domain.vo; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.math.BigDecimal; +import java.util.Date; + +/** + * 设计报工明细 + */ +@Data +public class DesignTaskReportVo { + + private Long id; + + private Long taskId; + + @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8") + private Date reportDate; + + private BigDecimal workHours; + + private Integer progress; + + private String content; + + private String reportBy; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date reportTime; +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/DesignTaskSummaryVo.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/DesignTaskSummaryVo.java new file mode 100644 index 0000000..090ca13 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/DesignTaskSummaryVo.java @@ -0,0 +1,21 @@ +package com.ruoyi.system.domain.vo; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +/** + * 首页设计任务摘要 + */ +@Data +public class DesignTaskSummaryVo { + + private long pendingCount; + + private long overdueCount; + + private long finishedThisMonth; + + private List recent = new ArrayList<>(); +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/DesignTaskVo.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/DesignTaskVo.java new file mode 100644 index 0000000..3b48de9 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/DesignTaskVo.java @@ -0,0 +1,64 @@ +package com.ruoyi.system.domain.vo; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.math.BigDecimal; +import java.util.Date; + +/** + * 设计任务展示 + */ +@Data +public class DesignTaskVo { + + private Long id; + + private Long orderProId; + + private String productionOrderNo; + + private String productionName; + + private String projectType; + + @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8") + private Date drawingTime; + + @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8") + private Date projectEndTime; + + private Long bomStatus; + + private Integer whetherDelay; + + private String designerUserName; + + private String designerName; + + /** 2D / 3D,未标注为空 */ + private String designDim; + + private Integer status; + + private Integer progress; + + private BigDecimal totalHours; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date assignTime; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date startTime; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") + private Date finishTime; + + private String remark; + + /** 预计出图日早于今天,且任务未完成、未撤回 */ + private Boolean overdue; + + /** 该项目除已撤回外的设计任务都已完成 */ + private Boolean allDesignersDone; +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/DrawingRouteOperLogVo.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/DrawingRouteOperLogVo.java new file mode 100644 index 0000000..9929829 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/DrawingRouteOperLogVo.java @@ -0,0 +1,35 @@ +package com.ruoyi.system.domain.vo; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.util.Date; + +/** + * 出图 / 工艺 / 钉钉推送留痕列表与详情。 + */ +@Data +public class DrawingRouteOperLogVo { + + private Long id; + private String bizType; + private String channel; + private String apiPath; + private Long projectId; + private String productionOrderNo; + private String drawingMode; + private String notifyType; + private String notifyUserIds; + private String messageTitle; + private String messageContent; + private String fileName; + private String status; + private String errorMsg; + private String summaryJson; + private String operator; + private String clientIp; + private Long durationMs; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date operTime; +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/OrderProDrawingImportVo.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/OrderProDrawingImportVo.java new file mode 100644 index 0000000..4e35181 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/OrderProDrawingImportVo.java @@ -0,0 +1,35 @@ +package com.ruoyi.system.domain.vo; + +import com.alibaba.excel.annotation.ExcelIgnoreUnannotated; +import com.alibaba.excel.annotation.ExcelProperty; +import lombok.Data; + +/** + * 临时导入:按生产令号更新出图时间、项目完成时间、工艺完成时间、计划排产时间、BOM/物料更新、设计人。 + * 空单元格不更新。 + */ +@Data +@ExcelIgnoreUnannotated +public class OrderProDrawingImportVo { + + @ExcelProperty(value = "生产令号") + private String productionOrderNo; + + @ExcelProperty(value = "出图时间") + private String drawingTime; + + @ExcelProperty(value = "项目完成时间") + private String projectEndTime; + + @ExcelProperty(value = "工艺完成时间") + private String routeUptime; + + @ExcelProperty(value = "计划排产时间") + private String planUptime; + + @ExcelProperty(value = "BOM/物料更新") + private String bomUptime; + + @ExcelProperty(value = "设计人") + private String drawingBy; +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/OrderProProgressDesignerSummaryVo.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/OrderProProgressDesignerSummaryVo.java new file mode 100644 index 0000000..4a1ecf6 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/OrderProProgressDesignerSummaryVo.java @@ -0,0 +1,31 @@ +package com.ruoyi.system.domain.vo; + +import lombok.Data; + +/** + * 生产项目进度报表:按设计人汇总。 + */ +@Data +public class OrderProProgressDesignerSummaryVo { + + /** 设计人(字典 plan_proer 的值) */ + private String drawingBy; + + /** 参与项目数。同一项目多名设计人时,分别计入每位设计人。 */ + private long projectCount; + + /** 已有实际出图时间的项目数 */ + private long drawn; + + /** 出图逾期项目数(预计出图当天完成不算逾期) */ + private long delayed; + + /** 未逾期项目数 */ + private long onTime; + + /** A4 图纸数量合计 */ + private long a4Total; + + /** 等效 A4 合计 */ + private long equivalentA4Total; +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/OrderProProgressReportExportVo.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/OrderProProgressReportExportVo.java new file mode 100644 index 0000000..f1caf96 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/OrderProProgressReportExportVo.java @@ -0,0 +1,140 @@ +package com.ruoyi.system.domain.vo; + +import cn.hutool.core.date.DateUtil; +import com.alibaba.excel.annotation.ExcelIgnoreUnannotated; +import com.alibaba.excel.annotation.ExcelProperty; +import com.ruoyi.common.utils.StringUtils; +import lombok.Data; + +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +/** + * 生产项目进度报表导出。 + */ +@Data +@ExcelIgnoreUnannotated +public class OrderProProgressReportExportVo { + + private static final Map PROJECT_TYPE_LABELS = new HashMap<>(); + + static { + PROJECT_TYPE_LABELS.put("CP", "产品"); + PROJECT_TYPE_LABELS.put("FB", "非标"); + PROJECT_TYPE_LABELS.put("BG", "变更"); + PROJECT_TYPE_LABELS.put("QB", "企标"); + PROJECT_TYPE_LABELS.put("EY", "研发"); + PROJECT_TYPE_LABELS.put("GZ", "固资"); + PROJECT_TYPE_LABELS.put("ZP", "展品"); + PROJECT_TYPE_LABELS.put("KC", "库存"); + PROJECT_TYPE_LABELS.put("产品", "产品"); + PROJECT_TYPE_LABELS.put("非标", "非标"); + PROJECT_TYPE_LABELS.put("变更", "变更"); + PROJECT_TYPE_LABELS.put("研发", "研发"); + PROJECT_TYPE_LABELS.put("企标", "企标"); + PROJECT_TYPE_LABELS.put("库存", "库存"); + PROJECT_TYPE_LABELS.put("固资", "固资"); + PROJECT_TYPE_LABELS.put("展品", "展品"); + } + + @ExcelProperty(value = "生产令号") + private String productionOrderNo; + + @ExcelProperty(value = "预计出图时间") + private String drawingTime; + + @ExcelProperty(value = "实际出图时间") + private String actualDrawingTime; + + @ExcelProperty(value = "出图是否逾期") + private String drawingOverdueLabel; + + @ExcelProperty(value = "项目名称") + private String productionName; + + @ExcelProperty(value = "设计人") + private String drawingBy; + + @ExcelProperty(value = "来源") + private String source; + + @ExcelProperty(value = "项目类型") + private String projectTypeLabel; + + @ExcelProperty(value = "项目完成时间") + private String projectEndTime; + + @ExcelProperty(value = "工艺预计完成时间") + private String routeUptime; + + @ExcelProperty(value = "实际完成时间") + private String actualCompleteTime; + + @ExcelProperty(value = "计划排产时间") + private String planUptime; + + @ExcelProperty(value = "BOM物料更新时间") + private String bomUptime; + + @ExcelProperty(value = "A4图纸数量") + private Integer a4Count; + + @ExcelProperty(value = "等效A4") + private Integer equivalentA4Total; + + @ExcelProperty(value = "创建人") + private String createBy; + + public static OrderProProgressReportExportVo from(OrderProProgressReportVo vo) { + OrderProProgressReportExportVo row = new OrderProProgressReportExportVo(); + if (vo == null) { + return row; + } + row.setProductionOrderNo(vo.getProductionOrderNo()); + row.setDrawingTime(formatDate(vo.getDrawingTime())); + row.setActualDrawingTime(formatDateTime(vo.getActualDrawingTime())); + row.setDrawingOverdueLabel(formatOverdue(vo.getDrawingOverdue())); + row.setProductionName(vo.getProductionName()); + row.setDrawingBy(vo.getDrawingBy()); + row.setSource(formatSource(vo.getSource())); + row.setProjectTypeLabel(StringUtils.isNotBlank(vo.getProjectTypeLabel()) + ? vo.getProjectTypeLabel() + : formatProjectType(vo.getIsEnterpriseStandard())); + row.setProjectEndTime(formatDateTime(vo.getProjectEndTime())); + row.setRouteUptime(formatDateTime(vo.getRouteUptime())); + row.setActualCompleteTime(formatDateTime(vo.getActualCompleteTime())); + row.setPlanUptime(formatDateTime(vo.getPlanUptime())); + row.setBomUptime(formatDateTime(vo.getBomUptime())); + row.setA4Count(vo.getA4Count()); + row.setEquivalentA4Total(vo.getEquivalentA4Total()); + row.setCreateBy(vo.getCreateBy()); + return row; + } + + private static String formatSource(String value) { + return StringUtils.isNotBlank(value) ? value.trim() : "手动"; + } + + private static String formatProjectType(String code) { + if (StringUtils.isBlank(code)) { + return "-"; + } + return PROJECT_TYPE_LABELS.getOrDefault(code.trim(), code.trim()); + } + + private static String formatOverdue(Boolean overdue) { + if (overdue == null) { + return ""; + } + return overdue ? "逾期" : "未逾期"; + } + + private static String formatDate(Date date) { + return date == null ? "" : DateUtil.format(date, "yyyy-MM-dd"); + } + + private static String formatDateTime(Date date) { + return date == null ? "" : DateUtil.format(date, "yyyy-MM-dd HH:mm:ss"); + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/OrderProProgressReportOverviewVo.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/OrderProProgressReportOverviewVo.java new file mode 100644 index 0000000..62ff763 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/OrderProProgressReportOverviewVo.java @@ -0,0 +1,34 @@ +package com.ruoyi.system.domain.vo; + +import lombok.Data; + +/** + * 生产项目进度报表顶部统计。 + */ +@Data +public class OrderProProgressReportOverviewVo { + + /** 项目总数 */ + private long total; + + /** 已有实际出图时间 */ + private long drawn; + + /** 出图逾期:实际出图日晚于预计出图日;预计出图当天完成不算逾期 */ + private long delayed; + + /** 已有工艺实际完成时间 */ + private long routeDone; + + /** 已填 BOM 物料更新时间 */ + private long bomUpdated; + + /** 已读取图纸工作量的项目数 */ + private long workloadReady; + + /** A4 图纸数量合计 */ + private long a4Total; + + /** 等效 A4 合计 */ + private long equivalentA4Total; +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/OrderProProgressReportVo.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/OrderProProgressReportVo.java new file mode 100644 index 0000000..3aacbcb --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/OrderProProgressReportVo.java @@ -0,0 +1,76 @@ +package com.ruoyi.system.domain.vo; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.util.Date; + +/** + * 生产项目进度报表。 + */ +@Data +public class OrderProProgressReportVo { + + private Long id; + + /** 生产令号 */ + private String productionOrderNo; + + /** 预计出图时间 */ + @JsonFormat(pattern = "yyyy-MM-dd") + private Date drawingTime; + + /** 实际出图时间:产品图纸最后一次上传时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date actualDrawingTime; + + /** + * 出图是否逾期。预计出图当天及之前完成不算逾期; + * 尚无实际出图时,过了预计出图当天算逾期。 + */ + private Boolean drawingOverdue; + + /** 项目名称 */ + private String productionName; + + /** 设计人(字典 plan_proer,多人逗号分隔) */ + private String drawingBy; + + /** 来源:手动 / PLM */ + private String source; + + /** 项目类型原始值 */ + private String isEnterpriseStandard; + + /** 项目类型展示名 */ + private String projectTypeLabel; + + /** 项目完成时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date projectEndTime; + + /** 工艺预计完成时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date routeUptime; + + /** 实际完成时间:工艺实际上传完成 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date actualCompleteTime; + + /** 计划排产时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date planUptime; + + /** BOM 物料更新时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date bomUptime; + + /** A4 图纸数量(图纸工作量汇总) */ + private Integer a4Count; + + /** 等效 A4 合计(图纸工作量汇总) */ + private Integer equivalentA4Total; + + /** 创建人 */ + private String createBy; +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/ProcessOrderProVo.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/ProcessOrderProVo.java index fd428b7..d273a31 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/ProcessOrderProVo.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/ProcessOrderProVo.java @@ -64,6 +64,22 @@ public class ProcessOrderProVo { @ExcelProperty(value = "出图模式") private String drawingMode; + /** 最近一次出图 processstate:4 完成,5 失败 */ + @ExcelIgnore + private Integer drawingState; + + /** 最近一次出图提示 */ + @ExcelIgnore + private String drawingStateMsg; + + /** 最近一次出图输出路径 */ + @ExcelIgnore + private String drawingStatePath; + + /** 最近一次出图状态写入时间 */ + @ExcelIgnore + private Date drawingStateTime; + /** * 是否做工艺:0否 1是 */ diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/VmiLowStockAlertExportVo.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/VmiLowStockAlertExportVo.java new file mode 100644 index 0000000..01f209d --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/VmiLowStockAlertExportVo.java @@ -0,0 +1,54 @@ +package com.ruoyi.system.domain.vo; + +import com.alibaba.excel.annotation.ExcelIgnoreUnannotated; +import com.alibaba.excel.annotation.ExcelProperty; +import com.alibaba.excel.annotation.write.style.ColumnWidth; +import lombok.Data; + +import java.math.BigDecimal; + +/** + * VMI低库存钉钉提醒 Excel 行 + */ +@Data +@ExcelIgnoreUnannotated +@ColumnWidth(16) +public class VmiLowStockAlertExportVo { + + @ExcelProperty("物料编码") + @ColumnWidth(18) + private String materialCode; + + @ExcelProperty("名称") + @ColumnWidth(22) + private String materialName; + + @ExcelProperty("材料") + private String materialQuality; + + @ExcelProperty("供应商") + @ColumnWidth(14) + private String supplierName; + + @ExcelProperty("即时库存") + private BigDecimal currentQty; + + @ExcelProperty("预计可用") + private BigDecimal availableQty; + + @ExcelProperty("最低安全库存") + private BigDecimal minSafeQty; + + @ExcelProperty("最高安全库存") + private BigDecimal maxSafeQty; + + @ExcelProperty("最小包装量") + private BigDecimal minPackQty; + + @ExcelProperty("建议补货数量") + private BigDecimal suggestQty; + + @ExcelProperty("备注") + @ColumnWidth(20) + private String remark; +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/VmiMaterialVo.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/VmiMaterialVo.java new file mode 100644 index 0000000..5ccd551 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/VmiMaterialVo.java @@ -0,0 +1,77 @@ +package com.ruoyi.system.domain.vo; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.math.BigDecimal; +import java.util.Date; + +/** + * VMI物料安全库存视图对象 + */ +@Data +public class VmiMaterialVo { + + /** 主键 */ + private Long id; + + /** 物料编码 */ + private String materialCode; + + /** 名称 */ + private String materialName; + + /** 材料 */ + private String materialQuality; + + /** 供应商编码 */ + private String supplierCode; + + /** 供应商 */ + private String supplierName; + + /** 最小包装量 */ + private BigDecimal minPackQty; + + /** 建议最高安全库存 */ + private BigDecimal maxSafeQty; + + /** 建议最低安全库存 */ + private BigDecimal minSafeQty; + + /** 仓储部VMI负责人钉钉userId,多个逗号分隔 */ + private String dingUserId; + + /** 需求预测数量 */ + private BigDecimal forecastQty; + + /** 需求预测说明(旺季、订单波动等) */ + private String forecastRemark; + + /** 即时库存 */ + private BigDecimal lastStockQty; + + /** 最近一次扫描的预计可用库存 */ + private BigDecimal lastAvailableQty; + + /** 最近一次扫描时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date lastCheckTime; + + /** 库存状态:NORMAL正常 / LOW低于安全库存 */ + private String stockStatus; + + /** 是否启用:0停用 1启用 */ + private String enabled; + + /** 备注 */ + private String remark; + + /** 创建时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date createTime; + + /** 更新时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date updateTime; +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/VmiPurchaseReqLogVo.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/VmiPurchaseReqLogVo.java new file mode 100644 index 0000000..02e0a81 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/VmiPurchaseReqLogVo.java @@ -0,0 +1,54 @@ +package com.ruoyi.system.domain.vo; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.math.BigDecimal; +import java.util.Date; + +/** + * VMI采购申请推送记录视图对象 + */ +@Data +public class VmiPurchaseReqLogVo { + + /** 主键 */ + private Long id; + + /** 对应的低库存触发记录ID */ + private Long alertLogId; + + /** VMI物料主键 */ + private Long materialId; + + /** 物料编码 */ + private String materialCode; + + /** 物料名称 */ + private String materialName; + + /** 供应商编码 */ + private String supplierCode; + + /** 采购申请数量 */ + private BigDecimal reqQty; + + /** 金蝶采购申请单号 */ + private String kingdeeBillNo; + + /** 金蝶采购申请FID */ + private String kingdeeFid; + + /** 推送结果:SUCCESS成功 / FAIL失败 */ + private String pushStatus; + + /** 金蝶返回或失败原因 */ + private String pushMsg; + + /** 操作人 */ + private String createBy; + + /** 推送时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date createTime; +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/VmiReplenishPreviewItemVo.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/VmiReplenishPreviewItemVo.java new file mode 100644 index 0000000..6f2c007 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/VmiReplenishPreviewItemVo.java @@ -0,0 +1,42 @@ +package com.ruoyi.system.domain.vo; + +import lombok.Data; + +import java.math.BigDecimal; + +/** + * VMI补货确认预览行(推送金蝶前供管理员核对/修改数量) + */ +@Data +public class VmiReplenishPreviewItemVo { + + private Long alertId; + + private Long materialId; + + private String materialCode; + + private String materialName; + + private String supplierCode; + + private String supplierName; + + /** 触发时即时库存 */ + private BigDecimal currentQty; + + /** 触发时预计可用 */ + private BigDecimal availableQty; + + private BigDecimal minSafeQty; + + private BigDecimal maxSafeQty; + + private BigDecimal minPackQty; + + /** 系统按规则计算的建议补货数量 */ + private BigDecimal suggestQty; + + /** 实际申请数量(默认同 suggestQty,前端可改) */ + private BigDecimal reqQty; +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/VmiStockAlertLogVo.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/VmiStockAlertLogVo.java new file mode 100644 index 0000000..5a82d26 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/VmiStockAlertLogVo.java @@ -0,0 +1,64 @@ +package com.ruoyi.system.domain.vo; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.math.BigDecimal; +import java.util.Date; + +/** + * VMI低库存触发记录视图对象 + */ +@Data +public class VmiStockAlertLogVo { + + /** 主键 */ + private Long id; + + /** VMI物料主键 */ + private Long materialId; + + /** 物料编码 */ + private String materialCode; + + /** 物料名称 */ + private String materialName; + + /** 供应商编码(来自VMI物料) */ + private String supplierCode; + + /** 供应商名称(来自VMI物料) */ + private String supplierName; + + /** 触发时的最低安全库存 */ + private BigDecimal minSafeQty; + + /** 触发时的即时库存 */ + private BigDecimal currentQty; + + /** 触发时的预计可用库存 */ + private BigDecimal availableQty; + + /** 处理状态:PENDING待确认 / CONFIRMED已确认 / IGNORED已忽略 */ + private String alertStatus; + + /** 钉钉推送结果:SUCCESS成功 / FAIL失败 */ + private String dingPushStatus; + + /** 钉钉推送返回信息 */ + private String dingPushMsg; + + /** 确认人或忽略人 */ + private String confirmBy; + + /** 确认或忽略时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date confirmTime; + + /** 备注 */ + private String remark; + + /** 触发时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date createTime; +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/WorkAlertGroupPushConfigVo.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/WorkAlertGroupPushConfigVo.java new file mode 100644 index 0000000..cb58b56 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/WorkAlertGroupPushConfigVo.java @@ -0,0 +1,35 @@ +package com.ruoyi.system.domain.vo; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.util.Date; + +/** + * 指定群预警定时推送配置视图对象 + */ +@Data +public class WorkAlertGroupPushConfigVo { + + private Long id; + + private String configName; + + private String openConversationId; + + private String robotCode; + + private String pushCodes; + + /** 任务中文名,逗号分隔 */ + private String pushCodeNames; + + private String pushTime; + + private String enabled; + + @JsonFormat(pattern = "yyyy-MM-dd") + private Date lastPushDate; + + private String remark; +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/mes/MesDrawingItemVo.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/mes/MesDrawingItemVo.java new file mode 100644 index 0000000..d6f42fa --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/mes/MesDrawingItemVo.java @@ -0,0 +1,68 @@ +package com.ruoyi.system.domain.vo.mes; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.util.Date; + +/** + * MES 图纸清单中的一张图(同一物料编码下 PDF / DWG 合并为一条)。 + */ +@Data +public class MesDrawingItemVo { + + /** 推送明细 ID,磁盘扫描补全的图纸可能为空 */ + private Long itemId; + + /** 生产令号 */ + private String productionOrderNo; + + /** 物料编码 / 图号 */ + private String materialCode; + + /** 物料名称 */ + private String materialName; + + /** PDF 文件名 */ + private String pdfFileName; + + /** DWG 文件名 */ + private String dwgFileName; + + /** PDF 相对路径,如 令号/图号_名称.pdf */ + private String pdfRelativePath; + + /** DWG 相对路径,如 令号/dwg/图号_名称.dwg */ + private String dwgRelativePath; + + /** PDF 下载地址(相对本系统根路径,MES 需拼接主机) */ + private String pdfDownloadUrl; + + /** DWG 下载地址 */ + private String dwgDownloadUrl; + + /** PDF 是否存在 */ + private Boolean pdfExists; + + /** DWG 是否存在 */ + private Boolean dwgExists; + + /** PDF 字节数 */ + private Long pdfSize; + + /** DWG 字节数 */ + private Long dwgSize; + + /** 单台数量 */ + private java.math.BigDecimal unitQty; + + /** 本批数量 */ + private java.math.BigDecimal batchQty; + + /** 是否变更图纸 */ + private Boolean isDrawingChanged; + + /** 最近一次接收时间 */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date receiveTime; +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/mes/MesDrawingListVo.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/mes/MesDrawingListVo.java new file mode 100644 index 0000000..d5b4147 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/mes/MesDrawingListVo.java @@ -0,0 +1,30 @@ +package com.ruoyi.system.domain.vo.mes; + +import lombok.Data; + +import java.util.List; + +/** + * MES 按生产令号查询图纸清单。 + */ +@Data +public class MesDrawingListVo { + + /** 生产令号 */ + private String productionOrderNo; + + /** 项目名称 */ + private String productionName; + + /** 图纸是否已齐:0否 1是 */ + private Integer drawingQtyIssued; + + /** 是否允许下载:0否 1是 */ + private Integer isDownload; + + /** 图纸条数 */ + private Integer itemCount; + + /** 图纸列表 */ + private List items; +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/job/DrawingDelayRefreshJob.java b/ruoyi-system/src/main/java/com/ruoyi/system/job/DrawingDelayRefreshJob.java new file mode 100644 index 0000000..90f5831 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/job/DrawingDelayRefreshJob.java @@ -0,0 +1,32 @@ +package com.ruoyi.system.job; + +import com.ruoyi.system.service.IOrderProDrawingDelayService; +import com.xxl.job.core.context.XxlJobHelper; +import com.xxl.job.core.handler.annotation.XxlJob; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * 每日刷新项目出图延期天数(工作日一周六天)。 + * XXL-JOB Cron 建议:0 10 0 * * ? (每天 00:10) + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class DrawingDelayRefreshJob { + + private final IOrderProDrawingDelayService orderProDrawingDelayService; + + @XxlJob("drawingDelayRefreshHandler") + public void drawingDelayRefreshHandler() { + XxlJobHelper.log("开始刷新项目出图延期状态..."); + try { + orderProDrawingDelayService.refreshAll(); + XxlJobHelper.handleSuccess("刷新完成"); + } catch (Exception e) { + log.error("刷新项目出图延期状态异常", e); + XxlJobHelper.handleFail("刷新异常: " + e.getMessage()); + } + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/job/VmiSafeStockCheckJob.java b/ruoyi-system/src/main/java/com/ruoyi/system/job/VmiSafeStockCheckJob.java new file mode 100644 index 0000000..ef8217a --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/job/VmiSafeStockCheckJob.java @@ -0,0 +1,32 @@ +package com.ruoyi.system.job; + +import com.ruoyi.system.service.IVmiMaterialService; +import com.xxl.job.core.context.XxlJobHelper; +import com.xxl.job.core.handler.annotation.XxlJob; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * 扫描VMI物料即时库存,低于最低安全库存则钉钉提醒仓储部VMI负责人。 + * XXL-JOB JobHandler:vmiSafeStockCheckHandler + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class VmiSafeStockCheckJob { + + private final IVmiMaterialService vmiMaterialService; + + @XxlJob("vmiSafeStockCheckHandler") + public void vmiSafeStockCheckHandler() { + XxlJobHelper.log("开始扫描VMI低库存..."); + try { + int hit = vmiMaterialService.scanLowStock(); + XxlJobHelper.handleSuccess("扫描完成,新触发 " + hit + " 条"); + } catch (Exception e) { + log.error("VMI低库存扫描异常", e); + XxlJobHelper.handleFail("扫描异常: " + e.getMessage()); + } + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/mapper/DingTodoLogMapper.java b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/DingTodoLogMapper.java new file mode 100644 index 0000000..88672da --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/DingTodoLogMapper.java @@ -0,0 +1,10 @@ +package com.ruoyi.system.mapper; + +import com.ruoyi.common.core.mapper.BaseMapperPlus; +import com.ruoyi.system.domain.DingTodoLog; + +/** + * 钉钉待办记录 + */ +public interface DingTodoLogMapper extends BaseMapperPlus { +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/mapper/DingTodoSceneMapper.java b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/DingTodoSceneMapper.java new file mode 100644 index 0000000..dd082fe --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/DingTodoSceneMapper.java @@ -0,0 +1,10 @@ +package com.ruoyi.system.mapper; + +import com.ruoyi.common.core.mapper.BaseMapperPlus; +import com.ruoyi.system.domain.DingTodoScene; + +/** + * 钉钉待办场景 + */ +public interface DingTodoSceneMapper extends BaseMapperPlus { +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/mapper/DrawingRouteOperLogMapper.java b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/DrawingRouteOperLogMapper.java new file mode 100644 index 0000000..1799075 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/DrawingRouteOperLogMapper.java @@ -0,0 +1,8 @@ +package com.ruoyi.system.mapper; + +import com.ruoyi.common.core.mapper.BaseMapperPlus; +import com.ruoyi.system.domain.DrawingRouteOperLog; +import com.ruoyi.system.domain.vo.DrawingRouteOperLogVo; + +public interface DrawingRouteOperLogMapper extends BaseMapperPlus { +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/mapper/OrderProProgressReportMapper.java b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/OrderProProgressReportMapper.java new file mode 100644 index 0000000..d3dd3f2 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/OrderProProgressReportMapper.java @@ -0,0 +1,24 @@ +package com.ruoyi.system.mapper; + +import com.ruoyi.system.domain.dto.OrderProProgressTimeAgg; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 生产项目进度报表时间聚合。 + */ +public interface OrderProProgressReportMapper { + + List selectActualDrawingTimes(@Param("ids") List ids); + + List selectActualRouteLogTimes(@Param("orderNos") List orderNos); + + List selectActualRouteCreateTimes(@Param("orderNos") List orderNos); + + /** PLM 图纸推送接收记录的接收时间。 */ + List selectPlmDrawingReceiveTimes(@Param("orderNos") List orderNos); + + /** PLM 工艺路线接收记录的接收时间。 */ + List selectPlmRouteReceiveTimes(@Param("orderNos") List orderNos); +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/mapper/ProcessDesignReportMapper.java b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/ProcessDesignReportMapper.java new file mode 100644 index 0000000..fbeb0e4 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/ProcessDesignReportMapper.java @@ -0,0 +1,10 @@ +package com.ruoyi.system.mapper; + +import com.ruoyi.common.core.mapper.BaseMapperPlus; +import com.ruoyi.system.domain.ProcessDesignReport; + +/** + * 设计报工明细 + */ +public interface ProcessDesignReportMapper extends BaseMapperPlus { +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/mapper/ProcessDesignTaskMapper.java b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/ProcessDesignTaskMapper.java new file mode 100644 index 0000000..06e05d7 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/ProcessDesignTaskMapper.java @@ -0,0 +1,10 @@ +package com.ruoyi.system.mapper; + +import com.ruoyi.common.core.mapper.BaseMapperPlus; +import com.ruoyi.system.domain.ProcessDesignTask; + +/** + * 设计个人任务 + */ +public interface ProcessDesignTaskMapper extends BaseMapperPlus { +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/mapper/RatioModelMapper.java b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/RatioModelMapper.java new file mode 100644 index 0000000..50050df --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/RatioModelMapper.java @@ -0,0 +1,7 @@ +package com.ruoyi.system.mapper; + +import com.ruoyi.common.core.mapper.BaseMapperPlus; +import com.ruoyi.system.domain.RatioModel; + +public interface RatioModelMapper extends BaseMapperPlus { +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/mapper/VmiMaterialMapper.java b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/VmiMaterialMapper.java new file mode 100644 index 0000000..9aa55e7 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/VmiMaterialMapper.java @@ -0,0 +1,11 @@ +package com.ruoyi.system.mapper; + +import com.ruoyi.common.core.mapper.BaseMapperPlus; +import com.ruoyi.system.domain.VmiMaterial; +import com.ruoyi.system.domain.vo.VmiMaterialVo; + +/** + * VMI物料安全库存 Mapper + */ +public interface VmiMaterialMapper extends BaseMapperPlus { +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/mapper/VmiPurchaseReqLogMapper.java b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/VmiPurchaseReqLogMapper.java new file mode 100644 index 0000000..43c3bb0 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/VmiPurchaseReqLogMapper.java @@ -0,0 +1,11 @@ +package com.ruoyi.system.mapper; + +import com.ruoyi.common.core.mapper.BaseMapperPlus; +import com.ruoyi.system.domain.VmiPurchaseReqLog; +import com.ruoyi.system.domain.vo.VmiPurchaseReqLogVo; + +/** + * VMI采购申请推送记录 Mapper + */ +public interface VmiPurchaseReqLogMapper extends BaseMapperPlus { +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/mapper/VmiStockAlertLogMapper.java b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/VmiStockAlertLogMapper.java new file mode 100644 index 0000000..a51a8eb --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/VmiStockAlertLogMapper.java @@ -0,0 +1,11 @@ +package com.ruoyi.system.mapper; + +import com.ruoyi.common.core.mapper.BaseMapperPlus; +import com.ruoyi.system.domain.VmiStockAlertLog; +import com.ruoyi.system.domain.vo.VmiStockAlertLogVo; + +/** + * VMI低库存触发记录 Mapper + */ +public interface VmiStockAlertLogMapper extends BaseMapperPlus { +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/mapper/WorkAlertGroupPushConfigMapper.java b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/WorkAlertGroupPushConfigMapper.java new file mode 100644 index 0000000..7ab5aca --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/WorkAlertGroupPushConfigMapper.java @@ -0,0 +1,11 @@ +package com.ruoyi.system.mapper; + +import com.ruoyi.common.core.mapper.BaseMapperPlus; +import com.ruoyi.system.domain.WorkAlertGroupPushConfig; +import com.ruoyi.system.domain.vo.WorkAlertGroupPushConfigVo; + +/** + * 指定群预警定时推送配置 Mapper + */ +public interface WorkAlertGroupPushConfigMapper extends BaseMapperPlus { +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/DrawingFileQueryService.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/DrawingFileQueryService.java new file mode 100644 index 0000000..c33fb53 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/DrawingFileQueryService.java @@ -0,0 +1,248 @@ +package com.ruoyi.system.service; + +import cn.hutool.core.io.IoUtil; +import cn.hutool.http.HttpRequest; +import cn.hutool.http.HttpResponse; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.ruoyi.common.config.DrawingFileQueryProperties; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.system.constant.ProcessOrderProSource; +import com.ruoyi.system.domain.ProcessOrderPro; +import com.ruoyi.system.mapper.ProcessOrderProMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import javax.servlet.http.HttpServletResponse; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 按图号转发图纸/模型下载。仅 PLM 来源项目允许调用。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class DrawingFileQueryService { + + private static final Set FILE_TYPES = new HashSet<>(Arrays.asList( + "sldprt", "sldasm", "dwg", "pdf", "x_t" + )); + + private static final Pattern FILENAME_STAR = Pattern.compile("filename\\*=(?:UTF-8'')?([^;]+)", Pattern.CASE_INSENSITIVE); + private static final Pattern FILENAME = Pattern.compile("filename=\"?([^\";]+)\"?", Pattern.CASE_INSENSITIVE); + + private final DrawingFileQueryProperties properties; + private final ProcessOrderProMapper processOrderProMapper; + + public void download(String productionOrderNo, String materialCode, String fileType, HttpServletResponse response) { + String orderNo = StringUtils.trimToEmpty(productionOrderNo); + String code = normalizeMaterialCode(materialCode); + String type = normalizeFileType(fileType); + assertPlmOrder(orderNo); + + String url = buildUrl(code, type); + HttpResponse remote = null; + try { + remote = HttpRequest.get(url).timeout(properties.getReadTimeoutMs()).executeAsync(); + int status = remote.getStatus(); + String contentType = StringUtils.trimToEmpty(remote.header("Content-Type")); + if (status < 200 || status >= 300 || isErrorContent(contentType)) { + String body = ""; + try { + body = remote.body(); + } catch (Exception ignored) { + // 错误正文读不到时只返回状态码 + } + log.warn("图纸文件服务返回异常 status={} url={} body={}", status, url, abbreviate(body)); + throw new ServiceException(briefError(body, status)); + } + String filename = resolveFilename(remote.header("Content-Disposition"), code, type); + String responseType = StringUtils.isBlank(contentType) ? "application/octet-stream" : contentType; + response.setContentType(responseType); + String encoded = URLEncoder.encode(filename, StandardCharsets.UTF_8.name()).replace("+", "%20"); + response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + encoded); + long length = remote.contentLength(); + if (length > 0) { + response.setContentLengthLong(length); + } + try (InputStream in = remote.bodyStream(); OutputStream out = response.getOutputStream()) { + IoUtil.copy(in, out); + out.flush(); + } + } catch (ServiceException e) { + throw e; + } catch (Exception e) { + if (isConnectFailure(e)) { + String target = StringUtils.trimToEmpty(properties.getBaseUrl()); + log.warn("图纸文件服务连不上 url={} msg={}", url, rootMessage(e)); + if (!response.isCommitted()) { + throw new ServiceException("无法连接图纸文件服务 " + target + ",连接被拒绝。请确认该地址和端口对本机开放"); + } + return; + } + log.error("下载图纸模型失败 order={} code={} type={}", orderNo, code, type, e); + if (!response.isCommitted()) { + throw new ServiceException("图纸模型下载失败,请稍后重试"); + } + } finally { + if (remote != null) { + remote.close(); + } + } + } + + private static boolean isConnectFailure(Throwable error) { + Throwable current = error; + while (current != null) { + if (current instanceof java.net.SocketException || current instanceof java.net.ConnectException) { + return true; + } + String message = current.getMessage(); + if (message != null && (message.contains("Permission denied: connect") + || message.contains("connect timed out") + || message.contains("Connection refused") + || message.contains("访问权限不允许"))) { + return true; + } + current = current.getCause(); + } + return false; + } + + private static String rootMessage(Throwable error) { + Throwable current = error; + while (current.getCause() != null) { + current = current.getCause(); + } + return StringUtils.trimToEmpty(current.getMessage()); + } + + private void assertPlmOrder(String orderNo) { + if (StringUtils.isBlank(orderNo)) { + throw new ServiceException("生产令号不能为空"); + } + ProcessOrderPro order = processOrderProMapper.selectOne( + Wrappers.lambdaQuery() + .eq(ProcessOrderPro::getProductionOrderNo, orderNo) + .last("LIMIT 1")); + if (order == null) { + throw new ServiceException("未找到项目令号: " + orderNo); + } + String source = StringUtils.trimToEmpty(order.getSource()); + if (!ProcessOrderProSource.PLM.equalsIgnoreCase(source)) { + throw new ServiceException("仅 PLM 来源项目可下载图纸模型"); + } + } + + private String buildUrl(String materialCode, String fileType) { + String base = StringUtils.trimToEmpty(properties.getBaseUrl()); + if (base.endsWith("/")) { + base = base.substring(0, base.length() - 1); + } + String path = StringUtils.trimToEmpty(properties.getDownloadPath()); + if (!path.startsWith("/")) { + path = "/" + path; + } + if (StringUtils.isBlank(base)) { + throw new ServiceException("图纸文件服务地址未配置"); + } + return base + path + + "?propertyValue=" + urlEncode(materialCode) + + "&fileType=" + urlEncode(fileType); + } + + private static String normalizeMaterialCode(String materialCode) { + String code = StringUtils.trimToEmpty(materialCode); + if (StringUtils.isBlank(code) || code.length() > 128 + || code.contains("..") || code.indexOf('/') >= 0 || code.indexOf('\\') >= 0 + || code.indexOf('\r') >= 0 || code.indexOf('\n') >= 0) { + throw new ServiceException("物料编码不合法"); + } + return code; + } + + private static String normalizeFileType(String fileType) { + String type = StringUtils.trimToEmpty(fileType).toLowerCase(Locale.ROOT); + if (!FILE_TYPES.contains(type)) { + throw new ServiceException("不支持的图纸格式"); + } + return type; + } + + private static boolean isErrorContent(String contentType) { + String type = contentType.toLowerCase(Locale.ROOT); + return type.contains("json") || type.contains("text/html"); + } + + private static String abbreviate(String body) { + String text = StringUtils.trimToEmpty(body).replaceAll("\\s+", " "); + return text.length() > 200 ? text.substring(0, 200) : text; + } + + private static String briefError(String body, int status) { + String text = StringUtils.trimToEmpty(body).replaceAll("\\s+", " "); + if (text.length() > 200) { + text = text.substring(0, 200); + } + if (StringUtils.isBlank(text) || text.startsWith("<") || text.startsWith("{")) { + return "图纸模型下载失败(HTTP " + status + ")"; + } + return text; + } + + private static String resolveFilename(String disposition, String materialCode, String fileType) { + String name = parseDispositionFilename(disposition); + if (StringUtils.isBlank(name)) { + name = materialCode + "." + fileType; + } + name = name.replace("\\", "/"); + int slash = name.lastIndexOf('/'); + if (slash >= 0) { + name = name.substring(slash + 1); + } + name = name.replace("\r", "").replace("\n", "").replace("\"", "").trim(); + return StringUtils.isBlank(name) ? materialCode + "." + fileType : name; + } + + private static String parseDispositionFilename(String disposition) { + if (StringUtils.isBlank(disposition)) { + return ""; + } + Matcher star = FILENAME_STAR.matcher(disposition); + if (star.find()) { + return urlDecode(star.group(1).trim()); + } + Matcher plain = FILENAME.matcher(disposition); + if (plain.find()) { + return plain.group(1).trim(); + } + return ""; + } + + private static String urlEncode(String value) { + try { + return URLEncoder.encode(value, StandardCharsets.UTF_8.name()); + } catch (Exception e) { + throw new ServiceException("参数编码失败"); + } + } + + private static String urlDecode(String value) { + try { + return URLDecoder.decode(value, StandardCharsets.UTF_8.name()); + } catch (Exception e) { + return value; + } + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/DrawingRouteOperLogRenotifyService.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/DrawingRouteOperLogRenotifyService.java new file mode 100644 index 0000000..8458e58 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/DrawingRouteOperLogRenotifyService.java @@ -0,0 +1,178 @@ +package com.ruoyi.system.service; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONObject; +import com.ruoyi.common.core.domain.R; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.system.domain.DrawingRouteOperLog; +import com.ruoyi.system.domain.ProcessOrderPro; +import com.ruoyi.system.mapper.DrawingRouteOperLogMapper; +import com.ruoyi.system.mapper.ProcessOrderProMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.io.File; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 按推送日志重发对应钉钉通知。执行出图、工艺上传本身不是通知,不在这里重放。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class DrawingRouteOperLogRenotifyService { + + private final DrawingRouteOperLogMapper drawingRouteOperLogMapper; + private final ProcessOrderProMapper processOrderProMapper; + private final PlmPlanCompleteNotifyService plmPlanCompleteNotifyService; + private final PlmDrawingArriveNotifyService plmDrawingArriveNotifyService; + private final SkipProcessNotifyService skipProcessNotifyService; + private final IProcessOrderProService processOrderProService; + private final ProcessPlanExcelPushService processPlanExcelPushService; + private final IDrawingRouteOperLogService drawingRouteOperLogService; + + public String renotify(Long id) { + if (id == null) { + throw new ServiceException("日志ID不能为空"); + } + DrawingRouteOperLog row = drawingRouteOperLogMapper.selectById(id); + if (row == null) { + throw new ServiceException("日志不存在"); + } + if (IDrawingRouteOperLogService.STATUS_SKIP.equals(row.getStatus())) { + throw new ServiceException("这条是跳过记录,没有发出去的通知"); + } + String bizType = StringUtils.trimToEmpty(row.getBizType()); + String result; + if (IDrawingRouteOperLogService.BIZ_ROUTE_NOTIFY.equals(bizType)) { + result = renotifyRoute(row); + } else if (IDrawingRouteOperLogService.BIZ_DRAWING_NOTIFY.equals(bizType)) { + result = renotifyDrawing(row); + } else if (IDrawingRouteOperLogService.BIZ_SKIP_PROCESS_NOTIFY.equals(bizType)) { + result = skipProcessNotifyService.resendDrawingComplete(loadOrder(row)); + } else if (IDrawingRouteOperLogService.BIZ_ELECTRICAL_BOM_NOTIFY.equals(bizType)) { + result = skipProcessNotifyService.resendElectricalBom(loadOrder(row), savedCount(row)); + } else { + throw new ServiceException("该类型不能重新通知"); + } + return requireSent(result); + } + + /** PLM 到齐通知重导计划表发给生管;手动上传只重发已落盘的计划表,不重发审核卡片。 */ + private String renotifyRoute(DrawingRouteOperLog row) { + if (isPlmRouteNotify(row)) { + return plmPlanCompleteNotifyService.notifyIfAssemblyAndRouteComplete(row.getProductionOrderNo(), null); + } + return resendSavedPlanFile(row); + } + + private String renotifyDrawing(DrawingRouteOperLog row) { + if (IDrawingRouteOperLogService.CHANNEL_PLM.equals(row.getChannel())) { + return plmDrawingArriveNotifyService.resend(row.getProductionOrderNo(), row.getSummaryJson()); + } + ProcessOrderPro order = loadOrder(row); + if (order == null || order.getId() == null) { + return "项目不存在,未发送"; + } + R sent = processOrderProService.sendProcessRoutePlanExcel(order.getId()); + if (sent == null) { + return "发送失败:没有返回结果"; + } + if (sent.getCode() != R.SUCCESS) { + throw new ServiceException(StringUtils.defaultIfBlank(sent.getMsg(), "发送失败")); + } + String msg = StringUtils.defaultIfBlank(sent.getMsg(), "已重新发送出图后推送"); + if (msg.contains("跳过")) { + return "项目无需做工艺,未推送给工艺"; + } + return "已重新发送出图后推送 令号=" + order.getProductionOrderNo(); + } + + private String resendSavedPlanFile(DrawingRouteOperLog row) { + String orderNo = StringUtils.trimToEmpty(row.getProductionOrderNo()); + String fileName = StringUtils.trimToEmpty(row.getFileName()); + if (StringUtils.isBlank(orderNo) || StringUtils.isBlank(fileName)) { + return "缺少令号或文件名,未发送"; + } + File file = new File(new File(processPlanExcelPushService.getPlanExcelBaseDir(), orderNo), fileName); + if (!file.exists() || file.length() <= 0) { + return "服务器上找不到已落盘的计划表,未发送"; + } + long started = System.currentTimeMillis(); + ProcessPlanExcelPushService.FilePushResult filePush = + processPlanExcelPushService.sendToPlanUsers(file, fileName); + Map summary = new LinkedHashMap<>(); + summary.put("resend", true); + summary.put("sourceLogId", row.getId()); + summary.put("fileStatus", filePush.resolveStatus()); + drawingRouteOperLogService.record(IDrawingRouteOperLogService.BIZ_ROUTE_NOTIFY, logRow -> { + logRow.setChannel(IDrawingRouteOperLogService.CHANNEL_MANUAL); + logRow.setApiPath("/system/drawingRouteOperLog/renotify"); + logRow.setProjectId(row.getProjectId()); + logRow.setProductionOrderNo(orderNo); + logRow.setNotifyType(IDrawingRouteOperLogService.NOTIFY_FILE); + logRow.setNotifyUserIds(filePush.getUserIds()); + logRow.setMessageTitle("重新发送工艺计划表"); + logRow.setMessageContent("按日志重发已落盘计划表,不重发审核卡片"); + logRow.setFileName(fileName); + logRow.setStatus(filePush.resolveStatus()); + logRow.setErrorMsg(filePush.getErrorMsg()); + logRow.setSummaryJson(JSON.toJSONString(summary)); + logRow.setDurationMs(System.currentTimeMillis() - started); + }); + if (StringUtils.isNotBlank(filePush.getErrorMsg())) { + return "计划表重发未完成:" + filePush.getErrorMsg(); + } + return "已重发工艺计划表给生管 令号=" + orderNo + " 文件=" + fileName + "(未重发审核卡片)"; + } + + private static boolean isPlmRouteNotify(DrawingRouteOperLog row) { + if (IDrawingRouteOperLogService.CHANNEL_PLM.equals(row.getChannel())) { + return true; + } + String apiPath = StringUtils.defaultString(row.getApiPath()); + return apiPath.contains("/system/plm/"); + } + + private ProcessOrderPro loadOrder(DrawingRouteOperLog row) { + if (row.getProjectId() != null) { + ProcessOrderPro byId = processOrderProMapper.selectById(row.getProjectId()); + if (byId != null) { + return byId; + } + } + String orderNo = StringUtils.trimToEmpty(row.getProductionOrderNo()); + if (StringUtils.isBlank(orderNo)) { + return null; + } + return processOrderProMapper.selectByProjectNumber(orderNo); + } + + private static int savedCount(DrawingRouteOperLog row) { + if (StringUtils.isBlank(row.getSummaryJson())) { + return 0; + } + try { + JSONObject obj = JSON.parseObject(row.getSummaryJson()); + return obj == null ? 0 : obj.getIntValue("savedCount"); + } catch (Exception e) { + return 0; + } + } + + private static String requireSent(String result) { + String msg = StringUtils.defaultIfBlank(result, "重新通知没有返回结果"); + if (msg.contains("未发送") + || msg.contains("暂不发送") + || msg.contains("发送失败") + || msg.contains("未完成") + || msg.contains("未推送") + || msg.contains("失败")) { + throw new ServiceException(msg); + } + return msg; + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/DrawingSuccess.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/DrawingSuccess.java new file mode 100644 index 0000000..7b785c8 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/DrawingSuccess.java @@ -0,0 +1,46 @@ +package com.ruoyi.system.service; + +import com.ruoyi.common.utils.ProjectStatusLock; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.system.domain.ProcessOrderPro; + +/** + * 发送计划表、创建待办前,确认项目已经出图成功。 + */ +public final class DrawingSuccess { + + private DrawingSuccess() { + } + + /** + * @return 不能发送或创建待办时的提示;可以继续时返回 null + */ + public static String denyIfNotSuccess(ProcessOrderPro order) { + if (order == null) { + return "项目不存在,不能发送计划表或创建待办"; + } + Integer state = order.getDrawingState(); + String msg = StringUtils.trimToEmpty(order.getDrawingStateMsg()); + if (state != null) { + if (state == 4 && !messageFailed(msg)) { + return null; + } + if (StringUtils.isNotBlank(msg)) { + return "项目尚未出图成功:" + msg; + } + if (state == 5) { + return "项目出图失败,不能发送计划表或创建待办"; + } + return "项目出图尚未完成,不能发送计划表或创建待办"; + } + Long bomStatus = order.getBomStatus(); + if (bomStatus != null && bomStatus != ProjectStatusLock.SCHEME_DONE) { + return null; + } + return "项目尚未出图成功,不能发送计划表或创建待办"; + } + + private static boolean messageFailed(String msg) { + return msg.contains("失败") || msg.contains("未找到") || msg.contains("错误") || msg.contains("异常"); + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/IDesignTaskService.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/IDesignTaskService.java new file mode 100644 index 0000000..965d5ab --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/IDesignTaskService.java @@ -0,0 +1,59 @@ +package com.ruoyi.system.service; + +import com.ruoyi.common.core.domain.PageQuery; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.system.domain.ProcessOrderPro; +import com.ruoyi.system.domain.bo.DesignTaskDesignDimBo; +import com.ruoyi.system.domain.bo.DesignTaskQueryBo; +import com.ruoyi.system.domain.bo.DesignTaskReportBo; +import com.ruoyi.system.domain.vo.DesignAccountSyncVo; +import com.ruoyi.system.domain.vo.DesignTaskBoardVo; +import com.ruoyi.system.domain.vo.DesignTaskReportVo; +import com.ruoyi.system.domain.vo.DesignTaskSummaryVo; +import com.ruoyi.system.domain.vo.DesignTaskVo; + +import java.util.Collection; +import java.util.List; + +/** + * 设计个人任务 + */ +public interface IDesignTaskService { + + DesignTaskSummaryVo summaryMine(); + + /** + * @param scope all 且当前用户是管理员时,汇总全部设计人;否则只统计本人 + */ + DesignTaskBoardVo boardMine(String scope); + + TableDataInfo queryMine(DesignTaskQueryBo bo, PageQuery pageQuery); + + /** + * @return 新报工记录 id + */ + Long report(DesignTaskReportBo bo); + + void accept(Long taskId); + + void updateDesignDim(DesignTaskDesignDimBo bo); + + void deleteReport(Long reportId); + + List listMyReports(Long taskId); + + List listByOrder(Long orderId); + + DesignAccountSyncVo syncAccounts(); + + /** + * 按项目当前设计人同步任务。backfill 为 true 时,新任务按项目状态补历史;false 时新任务一律待接收。 + * + * @return 新插入的任务数 + */ + int syncOrder(ProcessOrderPro order, boolean backfill); + + void withdrawOpenByOrderIds(Collection orderIds); + + void updateOrderNo(Long orderId, String productionOrderNo); +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/IDingTodoDispatchService.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/IDingTodoDispatchService.java new file mode 100644 index 0000000..1f83403 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/IDingTodoDispatchService.java @@ -0,0 +1,29 @@ +package com.ruoyi.system.service; + +import com.ruoyi.common.core.domain.PageQuery; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.system.domain.DingTodoLog; +import com.ruoyi.system.domain.DingTodoScene; + +import java.io.File; +import java.util.Date; +import java.util.List; + +/** + * 按数据库里勾选的方法创建钉钉待办,并把结果写入 ding_todo_log。 + */ +public interface IDingTodoDispatchService { + + List listScenes(); + + void updateScene(DingTodoScene scene); + + TableDataInfo queryLogs(DingTodoLog query, PageQuery pageQuery); + + /** + * 该场景未勾选时什么都不做,返回 null。 + * 勾选后发送失败则写入记录并返回失败原因。 + */ + String sendIfEnabled(String sceneCode, String productionOrderNo, String projectName, Date routeUptime, + File excelFile, String fallbackUserIds); +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/IDrawingRouteOperLogService.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/IDrawingRouteOperLogService.java new file mode 100644 index 0000000..e2f0fb0 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/IDrawingRouteOperLogService.java @@ -0,0 +1,54 @@ +package com.ruoyi.system.service; + +import com.ruoyi.common.core.domain.PageQuery; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.system.domain.DrawingRouteOperLog; +import com.ruoyi.system.domain.bo.DrawingRouteOperLogBo; +import com.ruoyi.system.domain.vo.DrawingRouteOperLogVo; + +import java.util.function.Consumer; + +/** + * 出图 / 工艺上传 / 钉钉推送留痕。写入失败不影响主流程。 + *

+ * 业务类型:执行出图、出图后推送、工艺上传、上传后钉钉、无需做工艺通知、电气 BOM 通知。 + */ +public interface IDrawingRouteOperLogService { + + String BIZ_DRAWING_EXEC = "DRAWING_EXEC"; + String BIZ_DRAWING_NOTIFY = "DRAWING_NOTIFY"; + String BIZ_ROUTE_UPLOAD = "ROUTE_UPLOAD"; + String BIZ_ROUTE_NOTIFY = "ROUTE_NOTIFY"; + /** 无需做工艺且出图完成后通知生管 */ + String BIZ_SKIP_PROCESS_NOTIFY = "SKIP_PROCESS_NOTIFY"; + /** 电气 BOM 导入后的钉钉通知 */ + String BIZ_ELECTRICAL_BOM_NOTIFY = "ELECTRICAL_BOM_NOTIFY"; + + String CHANNEL_MANUAL = "MANUAL"; + String CHANNEL_PLM = "PLM"; + + String STATUS_SUCCESS = "SUCCESS"; + String STATUS_FAIL = "FAIL"; + String STATUS_PARTIAL = "PARTIAL"; + /** 无需做工艺,出图后推送未发给工艺 */ + String STATUS_SKIP = "SKIP"; + + String NOTIFY_MARKDOWN = "MARKDOWN"; + String NOTIFY_FILE = "FILE"; + String NOTIFY_CARD = "CARD"; + String NOTIFY_MARKDOWN_AND_FILE = "MARKDOWN_AND_FILE"; + String NOTIFY_FILE_AND_CARD = "FILE_AND_CARD"; + + /** + * 追加一条记录;自动补操作人、IP、时间。内部吞掉异常。 + */ + void record(String bizType, Consumer filler); + + String currentOperator(); + + String currentClientIp(); + + TableDataInfo queryPageList(DrawingRouteOperLogBo bo, PageQuery pageQuery); + + DrawingRouteOperLogVo queryDetail(Long id); +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/IMesDrawingService.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/IMesDrawingService.java new file mode 100644 index 0000000..50cefa3 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/IMesDrawingService.java @@ -0,0 +1,26 @@ +package com.ruoyi.system.service; + +import com.ruoyi.system.domain.vo.mes.MesDrawingListVo; + +import java.io.File; + +/** + * MES 开放图纸查询 / 下载。 + */ +public interface IMesDrawingService { + + /** + * 校验 MES appKey / appSecret。 + */ + void assertAuth(String appKey, String appSecret); + + /** + * 按生产令号列出图纸;materialCode 非空时只返回该图号。 + */ + MesDrawingListVo listDrawings(String productionOrderNo, String materialCode); + + /** + * 解析可下载文件。fileType:pdf / dwg。 + */ + File resolveDownloadFile(String productionOrderNo, String materialCode, String fileType); +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/IOrderProDrawingDelayService.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/IOrderProDrawingDelayService.java new file mode 100644 index 0000000..c18692b --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/IOrderProDrawingDelayService.java @@ -0,0 +1,13 @@ +package com.ruoyi.system.service; + +/** + * 按项目整体出图时间刷新设计出图延期/完成状态。 + */ +public interface IOrderProDrawingDelayService { + + /** 刷新所有仍处于「方案设计完成」且已填出图时间的项目 */ + void refreshAll(); + + /** 刷新单个项目;已进入出图完成及后续状态的不再改延期天数 */ + void refreshByProjectId(Long projectId); +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/IOrderProProgressReportService.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/IOrderProProgressReportService.java new file mode 100644 index 0000000..2af29b2 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/IOrderProProgressReportService.java @@ -0,0 +1,24 @@ +package com.ruoyi.system.service; + +import com.ruoyi.common.core.domain.PageQuery; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.system.domain.bo.OrderProProgressReportBo; +import com.ruoyi.system.domain.vo.OrderProProgressDesignerSummaryVo; +import com.ruoyi.system.domain.vo.OrderProProgressReportOverviewVo; +import com.ruoyi.system.domain.vo.OrderProProgressReportVo; + +import java.util.List; + +/** + * 生产项目进度报表。 + */ +public interface IOrderProProgressReportService { + + TableDataInfo queryPageList(OrderProProgressReportBo bo, PageQuery pageQuery); + + List queryList(OrderProProgressReportBo bo); + + OrderProProgressReportOverviewVo queryOverview(OrderProProgressReportBo bo); + + List queryDesignerSummary(OrderProProgressReportBo bo); +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/IProcessOrderProService.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/IProcessOrderProService.java index 3f5efa7..61cbc5a 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/service/IProcessOrderProService.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/IProcessOrderProService.java @@ -85,6 +85,11 @@ public interface IProcessOrderProService { String executDrawing(ProcessOrderProBo orderPro); + /** + * 若出图服务当前结果属于该令号,写入项目最后一次出图状态;否则返回已保存的结果。 + */ + ProcessOrderProVo captureCurrentDrawingState(Long id); + /** * 上传PDF并生成zip包 * @param id 项目ID diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/IRatioBomImportService.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/IRatioBomImportService.java new file mode 100644 index 0000000..1b2ac0b --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/IRatioBomImportService.java @@ -0,0 +1,13 @@ +package com.ruoyi.system.service; + +import com.ruoyi.system.domain.dto.ratio.RatioImportResultDTO; +import org.springframework.web.multipart.MultipartFile; + +public interface IRatioBomImportService { + + /** + * 按机型和速比 Sheet 导入通用件、减速机型号专用件。 + * 同一机型下的同一个速比会先清掉原有型号和物料明细,再写入本次文件的内容。 + */ + RatioImportResultDTO importExcel(MultipartFile file, String seriesType); +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/IVmiMaterialService.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/IVmiMaterialService.java new file mode 100644 index 0000000..aa19e55 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/IVmiMaterialService.java @@ -0,0 +1,50 @@ +package com.ruoyi.system.service; + +import com.ruoyi.common.core.domain.PageQuery; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.system.domain.bo.VmiMaterialBo; +import com.ruoyi.system.domain.bo.VmiPurchaseReqLogBo; +import com.ruoyi.system.domain.bo.VmiReplenishConfirmBo; +import com.ruoyi.system.domain.bo.VmiReplenishPreviewBo; +import com.ruoyi.system.domain.bo.VmiStockAlertLogBo; +import com.ruoyi.system.domain.vo.VmiMaterialVo; +import com.ruoyi.system.domain.vo.VmiPurchaseReqLogVo; +import com.ruoyi.system.domain.vo.VmiReplenishPreviewItemVo; +import com.ruoyi.system.domain.vo.VmiStockAlertLogVo; + +import java.util.Collection; +import java.util.List; + +public interface IVmiMaterialService { + + TableDataInfo queryPageList(VmiMaterialBo bo, PageQuery pageQuery); + + VmiMaterialVo queryById(Long id); + + Boolean insertByBo(VmiMaterialBo bo); + + Boolean updateByBo(VmiMaterialBo bo); + + Boolean deleteWithValidByIds(Collection ids); + + int scanLowStock(); + + /** + * 确认补货前预览:返回库存与建议申请数量,供管理员修改。 + */ + List previewReplenish(VmiReplenishPreviewBo bo); + + /** + * 确认补货:按供应商合并生成金蝶采购申请(仅保存,不自动审批)。 + * @return 生成的采购申请单数量 + */ + int confirmReplenish(VmiReplenishConfirmBo bo); + + void ignoreAlert(Long alertId); + + void notifyForecast(Long id); + + TableDataInfo queryAlertPage(VmiStockAlertLogBo bo, PageQuery pageQuery); + + TableDataInfo queryReqPage(VmiPurchaseReqLogBo bo, PageQuery pageQuery); +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/IWorkAlertGroupPushConfigService.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/IWorkAlertGroupPushConfigService.java new file mode 100644 index 0000000..8281fb2 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/IWorkAlertGroupPushConfigService.java @@ -0,0 +1,25 @@ +package com.ruoyi.system.service; + +import com.ruoyi.system.domain.bo.WorkAlertGroupPushConfigBo; +import com.ruoyi.system.domain.vo.WorkAlertGroupPushConfigVo; + +import java.util.Collection; +import java.util.List; + +/** + * 指定群预警定时推送配置 + */ +public interface IWorkAlertGroupPushConfigService { + + List queryList(); + + WorkAlertGroupPushConfigVo queryById(Long id); + + Boolean insertByBo(WorkAlertGroupPushConfigBo bo); + + Boolean updateByBo(WorkAlertGroupPushConfigBo bo); + + Boolean deleteWithValidByIds(Collection ids); + + void trigger(Long id); +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/PlmDrawingArriveNotifyService.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/PlmDrawingArriveNotifyService.java new file mode 100644 index 0000000..5625792 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/PlmDrawingArriveNotifyService.java @@ -0,0 +1,297 @@ +package com.ruoyi.system.service; + +import com.alibaba.fastjson.JSON; +import com.ruoyi.common.config.DingTalkProperties; +import com.ruoyi.common.dingding.robot.RobotGroupService; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.system.domain.ProcessOrderPro; +import com.ruoyi.system.mapper.ProcessOrderProMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; + +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +/** + * PLM 图纸推送接收成功后,把到达通知发给 dingtalk.notify-user-id。 + * 失败只记日志,不影响图纸落盘。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class PlmDrawingArriveNotifyService { + + private static final String TITLE = "PLM图纸到达通知"; + private static final int PREVIEW_LIMIT = 8; + + private final DingTalkProperties dingTalkProperties; + private final RobotGroupService robotGroupService; + private final ProcessOrderProMapper processOrderProMapper; + private final IDrawingRouteOperLogService drawingRouteOperLogService; + + /** + * 请求线程内立刻入队,避免拖慢 PLM 接收接口。 + */ + public void notifyAsync(String productionOrderNo, String apiPath, String source, + Long processOrderProId, Long pushRecordId, + List> savedItems) { + String orderNo = StringUtils.trimToEmpty(productionOrderNo); + if (StringUtils.isBlank(orderNo)) { + return; + } + String clientIp = drawingRouteOperLogService.currentClientIp(); + List> items = CollectionUtils.isEmpty(savedItems) + ? new ArrayList<>() + : new ArrayList<>(savedItems); + CompletableFuture.runAsync(() -> { + try { + notifyNow(orderNo, apiPath, source, processOrderProId, pushRecordId, items, clientIp); + } catch (Exception e) { + log.error("PLM 图纸到达通知失败 令号={} source={}", orderNo, source, e); + } + }); + } + + /** + * 日志页重发图纸到达通知。图纸明细以当时日志摘要里的数量为准,不再列预览。 + */ + public String resend(String productionOrderNo, String summaryJson) { + String orderNo = StringUtils.trimToEmpty(productionOrderNo); + if (StringUtils.isBlank(orderNo)) { + return "令号为空,未发送"; + } + String source = null; + int itemCount = 0; + int changedCount = 0; + if (StringUtils.isNotBlank(summaryJson)) { + try { + com.alibaba.fastjson.JSONObject obj = JSON.parseObject(summaryJson); + if (obj != null) { + source = obj.getString("source"); + itemCount = obj.getIntValue("itemCount"); + changedCount = obj.getIntValue("changedCount"); + } + } catch (Exception ignored) { + source = null; + } + } + long started = System.currentTimeMillis(); + String clientIp = drawingRouteOperLogService.currentClientIp(); + String operator = drawingRouteOperLogService.currentOperator(); + ProcessOrderPro order = resolveOrder(null, orderNo); + Long projectId = order != null ? order.getId() : null; + String notifyUserIds = dingTalkProperties.getNotifyUserId(); + Map summary = new LinkedHashMap<>(); + summary.put("source", source); + summary.put("itemCount", itemCount); + summary.put("changedCount", changedCount); + summary.put("resend", true); + String apiPath = "/system/drawingRouteOperLog/renotify"; + if (order != null && SkipProcessNotifyService.isSkipProcess(order.getNeedProcess())) { + summary.put("needProcess", 0); + record(apiPath, projectId, orderNo, null, + IDrawingRouteOperLogService.STATUS_SKIP, "无需做工艺,未推送给工艺", + summary, operator, clientIp, started); + return "项目无需做工艺,未推送给工艺"; + } + if (StringUtils.isBlank(notifyUserIds)) { + record(apiPath, projectId, orderNo, notifyUserIds, + IDrawingRouteOperLogService.STATUS_FAIL, "未配置钉钉 notify-user-id", + summary, operator, clientIp, started); + return "未配置钉钉 notify-user-id,未发送"; + } + String markdown = buildMarkdown(orderNo, order, source, itemCount, changedCount, new ArrayList<>()); + try { + robotGroupService.sendMarkdownToUser(notifyUserIds, TITLE, markdown); + record(apiPath, projectId, orderNo, notifyUserIds, + IDrawingRouteOperLogService.STATUS_SUCCESS, null, summary, operator, clientIp, started); + return "已发送图纸到达通知 令号=" + orderNo; + } catch (Exception e) { + log.error("重发 PLM 图纸到达通知失败 令号={}", orderNo, e); + record(apiPath, projectId, orderNo, notifyUserIds, + IDrawingRouteOperLogService.STATUS_FAIL, e.getMessage(), summary, operator, clientIp, started); + return "发送失败:" + e.getMessage(); + } + } + + private void notifyNow(String orderNo, String apiPath, String source, Long processOrderProId, + Long pushRecordId, List> items, String clientIp) { + long started = System.currentTimeMillis(); + int itemCount = items.size(); + int changedCount = countChanged(items); + ProcessOrderPro order = resolveOrder(processOrderProId, orderNo); + Long projectId = order != null ? order.getId() : processOrderProId; + String notifyUserIds = dingTalkProperties.getNotifyUserId(); + Map summary = new LinkedHashMap<>(); + summary.put("source", source); + summary.put("itemCount", itemCount); + summary.put("changedCount", changedCount); + if (pushRecordId != null) { + summary.put("pushRecordId", pushRecordId); + } + + if (order != null && SkipProcessNotifyService.isSkipProcess(order.getNeedProcess())) { + log.info("项目无需做工艺,跳过 PLM 图纸到达通知(不发给工艺)令号={}", orderNo); + summary.put("needProcess", 0); + record(apiPath, projectId, orderNo, null, + IDrawingRouteOperLogService.STATUS_SKIP, "无需做工艺,未推送给工艺", + summary, clientIp, started); + return; + } + + if (StringUtils.isBlank(notifyUserIds)) { + log.warn("未配置钉钉 notify-user-id,PLM 图纸到达通知跳过 令号={}", orderNo); + record(apiPath, projectId, orderNo, notifyUserIds, + IDrawingRouteOperLogService.STATUS_FAIL, "未配置钉钉 notify-user-id", + summary, clientIp, started); + return; + } + + String markdown = buildMarkdown(orderNo, order, source, itemCount, changedCount, items); + try { + robotGroupService.sendMarkdownToUser(notifyUserIds, TITLE, markdown); + log.info("PLM 图纸到达通知已发送 notify-user-id 令号={} source={} count={} changed={}", + orderNo, source, itemCount, changedCount); + record(apiPath, projectId, orderNo, notifyUserIds, + IDrawingRouteOperLogService.STATUS_SUCCESS, null, summary, clientIp, started); + } catch (Exception e) { + log.error("PLM 图纸到达钉钉通知失败 令号={} source={}", orderNo, source, e); + record(apiPath, projectId, orderNo, notifyUserIds, + IDrawingRouteOperLogService.STATUS_FAIL, e.getMessage(), summary, clientIp, started); + } + } + + private ProcessOrderPro resolveOrder(Long processOrderProId, String orderNo) { + if (processOrderProId != null) { + ProcessOrderPro byId = processOrderProMapper.selectById(processOrderProId); + if (byId != null) { + return byId; + } + } + if (StringUtils.isNotBlank(orderNo)) { + return processOrderProMapper.selectByProjectNumber(orderNo); + } + return null; + } + + private String buildMarkdown(String orderNo, ProcessOrderPro order, String source, + int itemCount, int changedCount, List> items) { + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm"); + StringBuilder text = new StringBuilder(); + text.append("**PLM 图纸已到达,请及时处理**\n\n"); + text.append("> **时间**: ").append(sdf.format(new Date())).append("\n\n"); + text.append("> **项目令号**: ").append(orderNo).append("\n\n"); + String projectName = order == null ? "" : StringUtils.defaultIfEmpty(order.getProductionName(), ""); + if (StringUtils.isNotBlank(projectName)) { + text.append("> **项目名称**: ").append(projectName).append("\n\n"); + } + if (StringUtils.isNotBlank(source)) { + text.append("> **推送方式**: ").append(source).append("\n\n"); + } + text.append("> **图纸数量**: ").append(itemCount).append("\n\n"); + if (changedCount > 0) { + text.append("> **其中改图**: ").append(changedCount).append("\n\n"); + } + String preview = buildPreview(items); + if (StringUtils.isNotBlank(preview)) { + text.append("> **图纸预览**: ").append(preview).append("\n\n"); + } + text.append("\n请相关人员及时确认图纸。"); + return text.toString(); + } + + private String buildPreview(List> items) { + if (CollectionUtils.isEmpty(items)) { + return ""; + } + List labels = new ArrayList<>(); + int shown = 0; + for (Map item : items) { + if (item == null) { + continue; + } + String label = drawingLabel(item); + if (StringUtils.isBlank(label)) { + continue; + } + if (shown >= PREVIEW_LIMIT) { + labels.add("等共 " + items.size() + " 张"); + break; + } + labels.add(label); + shown++; + } + return String.join(";", labels); + } + + private static String drawingLabel(Map item) { + String code = StringUtils.trimToEmpty(item.get("materialCode")); + String name = StringUtils.trimToEmpty(item.get("materialName")); + if (StringUtils.isNotBlank(code) && StringUtils.isNotBlank(name)) { + return code + " " + name; + } + if (StringUtils.isNotBlank(code)) { + return code; + } + if (StringUtils.isNotBlank(name)) { + return name; + } + return StringUtils.trimToEmpty(item.get("savedFileName")); + } + + private static int countChanged(List> items) { + int count = 0; + for (Map item : items) { + if (item != null && isChangedFlag(item.get("isDrawingChanged"))) { + count++; + } + } + return count; + } + + private static boolean isChangedFlag(String raw) { + if (StringUtils.isBlank(raw)) { + return false; + } + String s = raw.trim(); + return "1".equals(s) || "true".equalsIgnoreCase(s) || "Y".equalsIgnoreCase(s); + } + + private void record(String apiPath, Long projectId, String orderNo, String notifyUserIds, + String status, String errorMsg, Map summary, + String clientIp, long started) { + record(apiPath, projectId, orderNo, notifyUserIds, status, errorMsg, summary, "PLM", clientIp, started); + } + + private void record(String apiPath, Long projectId, String orderNo, String notifyUserIds, + String status, String errorMsg, Map summary, + String operator, String clientIp, long started) { + drawingRouteOperLogService.record(IDrawingRouteOperLogService.BIZ_DRAWING_NOTIFY, row -> { + row.setChannel(IDrawingRouteOperLogService.CHANNEL_PLM); + row.setApiPath(apiPath); + row.setProjectId(projectId); + row.setProductionOrderNo(orderNo); + row.setNotifyType(IDrawingRouteOperLogService.NOTIFY_MARKDOWN); + row.setNotifyUserIds(notifyUserIds); + boolean skipped = IDrawingRouteOperLogService.STATUS_SKIP.equals(status); + row.setMessageTitle(skipped ? "出图后推送已跳过" : TITLE); + row.setMessageContent(skipped ? "项目无需做工艺,未推送给工艺" : "PLM 图纸已到达,请及时处理"); + if (skipped) { + row.setNotifyType(null); + } + row.setStatus(status); + row.setErrorMsg(errorMsg); + row.setSummaryJson(JSON.toJSONString(summary)); + row.setOperator(StringUtils.isNotBlank(operator) ? operator : "PLM"); + row.setClientIp(clientIp); + row.setDurationMs(System.currentTimeMillis() - started); + }); + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/PlmPlanCompleteNotifyService.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/PlmPlanCompleteNotifyService.java new file mode 100644 index 0000000..a3c4b91 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/PlmPlanCompleteNotifyService.java @@ -0,0 +1,338 @@ +package com.ruoyi.system.service; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.ruoyi.common.config.DingTalkProperties; +import com.ruoyi.common.dingding.robot.RobotGroupService; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.common.utils.spring.SpringUtils; +import com.ruoyi.system.constant.DingTodoSceneCodes; +import com.ruoyi.system.controller.ProcessOrderProController; +import com.ruoyi.system.domain.ProcessOrderPro; +import com.ruoyi.system.domain.ProcessRoute; +import com.ruoyi.system.domain.ProductionOrder; +import com.ruoyi.system.domain.bo.plm.PlmOrderMaterialMatchUtils; +import com.ruoyi.system.mapper.ProcessOrderProMapper; +import com.ruoyi.system.mapper.ProcessRouteMapper; +import com.ruoyi.system.mapper.ProductionOrderMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.io.File; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * PLM 总装 BOM 与工艺都到齐后,把工艺计划表发给 {@code dingtalk.plan-user-id}(生管)。 + *

+ * 判断只看出没到,不做自制件逐条匹配: + * 收到工艺时查本令号(子令再看父令)有没有总装,没有就跳过; + * 收到总装时再查本令号(总令再看子令)有没有工艺,两边都有才发。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class PlmPlanCompleteNotifyService { + + private static final String TITLE = "工艺完成通知"; + + private final ProductionOrderMapper productionOrderMapper; + private final ProcessRouteMapper processRouteMapper; + private final ProcessOrderProMapper processOrderProMapper; + private final ProcessPlanExcelPushService processPlanExcelPushService; + private final DingTalkProperties dingTalkProperties; + private final RobotGroupService robotGroupService; + private final IDrawingRouteOperLogService drawingRouteOperLogService; + private final IDingTodoDispatchService dingTodoDispatchService; + + /** + * 总装或工艺入库后调用。缺一边只打日志;两边都有则导出计划表并发给 plan-user-id。 + * + * @param alreadyGenerated 工艺推送路径上已导出的文件,避免重复生成;总装后到或手动重发时传 null + * @return 发送结果说明,供手动重发接口直接返回 + */ + public String notifyIfAssemblyAndRouteComplete(String productionOrderNo, File alreadyGenerated) { + String orderNo = StringUtils.trimToEmpty(productionOrderNo); + if (StringUtils.isBlank(orderNo)) { + return "令号为空,未发送"; + } + CompleteCheck check = evaluate(orderNo); + if (!check.complete) { + String msg = String.format("暂不发送:令号=%s 原因=%s 总装行=%d 工艺行=%d", + orderNo, check.reason, check.assemblyCount, check.routeCount); + log.info("PLM 工艺完成通知暂不发给 plan-user-id:令号={} 原因={} 总装行={} 工艺行={}", + orderNo, check.reason, check.assemblyCount, check.routeCount); + return msg; + } + log.info("PLM 总装BOM与工艺已到齐,开始发给 plan-user-id 令号={} 总装行={} 工艺行={}", + orderNo, check.assemblyCount, check.routeCount); + try { + return sendCompletePlanExcel(orderNo, alreadyGenerated); + } catch (Exception e) { + log.error("PLM 到齐后发送工艺计划表给 plan-user-id 失败 令号={}", orderNo, e); + return "发送失败:令号=" + orderNo + " " + e.getMessage(); + } + } + + private String sendCompletePlanExcel(String orderNo, File alreadyGenerated) throws Exception { + long started = System.currentTimeMillis(); + if (StringUtils.isBlank(dingTalkProperties.getPlanUserId())) { + log.warn("未配置钉钉 plan-user-id,PLM 工艺完成通知跳过 令号={}", orderNo); + drawingRouteOperLogService.record(IDrawingRouteOperLogService.BIZ_ROUTE_NOTIFY, row -> { + row.setChannel(IDrawingRouteOperLogService.CHANNEL_PLM); + row.setApiPath("/system/plm/routReceive"); + row.setProductionOrderNo(orderNo); + row.setNotifyType(IDrawingRouteOperLogService.NOTIFY_MARKDOWN_AND_FILE); + row.setStatus(IDrawingRouteOperLogService.STATUS_FAIL); + row.setErrorMsg("未配置钉钉 plan-user-id"); + row.setMessageTitle(TITLE); + row.setOperator("PLM"); + row.setDurationMs(System.currentTimeMillis() - started); + }); + return "未配置钉钉 plan-user-id,未发送"; + } + ProcessOrderPro order = processOrderProMapper.selectByProjectNumber(orderNo); + if (order == null || order.getId() == null) { + log.warn("PLM 工艺完成通知跳过:项目不存在 令号={}", orderNo); + drawingRouteOperLogService.record(IDrawingRouteOperLogService.BIZ_ROUTE_NOTIFY, row -> { + row.setChannel(IDrawingRouteOperLogService.CHANNEL_PLM); + row.setApiPath("/system/plm/routReceive"); + row.setProductionOrderNo(orderNo); + row.setNotifyType(IDrawingRouteOperLogService.NOTIFY_MARKDOWN_AND_FILE); + row.setStatus(IDrawingRouteOperLogService.STATUS_FAIL); + row.setErrorMsg("项目不存在"); + row.setMessageTitle(TITLE); + row.setOperator("PLM"); + row.setDurationMs(System.currentTimeMillis() - started); + }); + return "项目不存在,未发送"; + } + + File generated = alreadyGenerated; + boolean generatedHere = false; + if (generated == null || !generated.exists() || generated.length() <= 0) { + ProcessOrderProController controller = SpringUtils.getBean(ProcessOrderProController.class); + String path = controller.generateRoute3Excel(order.getId(), true); + generated = new File(path); + generatedHere = true; + } + if (!generated.exists() || generated.length() <= 0) { + log.warn("PLM 工艺完成导出计划表失败:文件为空 令号={} path={}", orderNo, generated.getAbsolutePath()); + drawingRouteOperLogService.record(IDrawingRouteOperLogService.BIZ_ROUTE_NOTIFY, row -> { + row.setChannel(IDrawingRouteOperLogService.CHANNEL_PLM); + row.setApiPath("/system/plm/routReceive"); + row.setProjectId(order.getId()); + row.setProductionOrderNo(orderNo); + row.setNotifyType(IDrawingRouteOperLogService.NOTIFY_MARKDOWN_AND_FILE); + row.setNotifyUserIds(dingTalkProperties.getPlanUserId()); + row.setStatus(IDrawingRouteOperLogService.STATUS_FAIL); + row.setErrorMsg("导出计划表失败:文件为空"); + row.setMessageTitle(TITLE); + row.setOperator("PLM"); + row.setDurationMs(System.currentTimeMillis() - started); + }); + return "导出计划表失败:文件为空"; + } + + File saved = processPlanExcelPushService.savePlanExcel(orderNo, generated, generated.getName()); + File toSend = saved != null ? saved : generated; + ProcessPlanExcelPushService.FilePushResult filePush = + processPlanExcelPushService.sendToPlanUsers(toSend, toSend.getName()); + String todoError = dingTodoDispatchService.sendIfEnabled( + DingTodoSceneCodes.PLM_PLAN_COMPLETE, + orderNo, + order.getProductionName(), + order.getRouteUptime(), + toSend, + dingTalkProperties.getPlanUserId()); + if (StringUtils.isNotBlank(todoError)) { + filePush.setErrorMsg(appendPushError(filePush.getErrorMsg(), "待办: " + todoError)); + if (filePush.getSuccessCount() > 0) { + filePush.setFailCount(filePush.getFailCount() + 1); + } + } + String textError = sendTextReminder(order); + if (StringUtils.isNotBlank(textError)) { + filePush.setErrorMsg(appendPushError(filePush.getErrorMsg(), "文字提醒: " + textError)); + if (filePush.getSuccessCount() > 0) { + filePush.setFailCount(filePush.getFailCount() + 1); + } + } + bumpRoutePlanSendCount(order.getId()); + drawingRouteOperLogService.record(IDrawingRouteOperLogService.BIZ_ROUTE_NOTIFY, row -> { + row.setChannel(IDrawingRouteOperLogService.CHANNEL_PLM); + row.setApiPath("/system/plm/routReceive"); + row.setProjectId(order.getId()); + row.setProductionOrderNo(orderNo); + row.setNotifyType(IDrawingRouteOperLogService.NOTIFY_MARKDOWN_AND_FILE); + row.setNotifyUserIds(dingTalkProperties.getPlanUserId()); + row.setMessageTitle(TITLE); + row.setMessageContent("PLM 总装配 BOM 与工艺已到齐,工艺计划表已下发"); + row.setFileName(toSend.getName()); + row.setStatus(filePush.resolveStatus()); + row.setErrorMsg(filePush.getErrorMsg()); + row.setOperator("PLM"); + row.setDurationMs(System.currentTimeMillis() - started); + }); + + if (generatedHere && saved != null + && generated.exists() + && !generated.getAbsolutePath().equals(saved.getAbsolutePath()) + && !generated.delete()) { + log.warn("删除临时计划表失败: {}", generated.getAbsolutePath()); + } + log.info("PLM 工艺完成计划表已发送 plan-user-id 令号={} file={}", orderNo, toSend.getName()); + String result = "已发给生管:令号=" + orderNo + " 文件=" + toSend.getName(); + if (StringUtils.isNotBlank(filePush.getErrorMsg())) { + result = result + ";部分失败:" + filePush.getErrorMsg(); + } + return result; + } + + /** + * 文件发出去之后再补一条 Markdown,版式与出图完成、无需做工艺通知一致。 + * + * @return 失败摘要;全部成功返回 null + */ + private String sendTextReminder(ProcessOrderPro po) { + String content = buildMarkdownReminder(po); + StringBuilder errors = new StringBuilder(); + int ok = 0; + for (String userId : StringUtils.defaultString(dingTalkProperties.getPlanUserId()).split(",")) { + String uid = userId == null ? "" : userId.trim(); + if (StringUtils.isBlank(uid)) { + continue; + } + try { + robotGroupService.sendMarkdownToUser(uid, TITLE, content); + ok++; + } catch (Exception e) { + log.error("PLM 工艺完成提醒失败 令号={} userId={}", po.getProductionOrderNo(), uid, e); + if (errors.length() > 0) { + errors.append("; "); + } + errors.append(uid).append(": ").append(e.getMessage()); + } + } + log.info("PLM 工艺完成 提醒已发送 令号={} 成功={}人", po.getProductionOrderNo(), ok); + return errors.length() == 0 ? null : errors.toString(); + } + + private static String buildMarkdownReminder(ProcessOrderPro po) { + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm"); + StringBuilder text = new StringBuilder(); + text.append("**来自PLM的工艺路线已完成,工艺计划表已下发**\n\n"); + text.append("> **时间**: ").append(sdf.format(new Date())).append("\n\n"); + text.append("> **项目令号**: ").append(StringUtils.defaultIfEmpty(po.getProductionOrderNo(), "")).append("\n\n"); + text.append("> **项目名称**: ").append(StringUtils.defaultIfEmpty(po.getProductionName(), "")).append("\n\n"); + text.append("> **说明**: 总装配BOM与工艺已到齐,请及时下载工艺计划表。\n\n"); + text.append("\n请相关人员及时处理。"); + return text.toString(); + } + + private static String appendPushError(String existing, String next) { + if (StringUtils.isBlank(next)) { + return existing; + } + if (StringUtils.isBlank(existing)) { + return next; + } + return existing + "; " + next; + } + + private void bumpRoutePlanSendCount(Long id) { + try { + int rows = processOrderProMapper.update(null, Wrappers.lambdaUpdate() + .eq(ProcessOrderPro::getId, id) + .setSql("route_plan_send_count = IFNULL(route_plan_send_count, 0) + 1, update_time = NOW()")); + if (rows != 1) { + log.warn("PLM 工艺完成发送次数累加影响行数异常 id={} rows={}", id, rows); + } + } catch (Exception e) { + log.error("PLM 工艺完成记录工艺计划表发送次数失败 id={}", id, e); + } + } + + private CompleteCheck evaluate(String orderNo) { + CompleteCheck result = new CompleteCheck(); + result.assemblyCount = countAssemblyRows(orderNo); + if (result.assemblyCount <= 0) { + result.reason = "总装配BOM尚未推送"; + return result; + } + result.routeCount = countRouteRows(orderNo); + if (result.routeCount <= 0) { + result.reason = "工艺路线尚未推送"; + return result; + } + result.complete = true; + result.reason = "总装与工艺都已到齐"; + return result; + } + + /** 本令号总装行;子令没有则看父令(总装通常在总令上)。 */ + private int countAssemblyRows(String orderNo) { + int n = countProductionOrder(orderNo); + if (n > 0) { + return n; + } + String parent = PlmOrderMaterialMatchUtils.resolveParentProductionOrderNo(orderNo); + if (StringUtils.isNotBlank(parent)) { + return countProductionOrder(parent); + } + return 0; + } + + private int countProductionOrder(String orderNo) { + Long n = productionOrderMapper.selectCount( + Wrappers.lambdaQuery() + .eq(ProductionOrder::getProductionOrderNo, orderNo)); + return n == null ? 0 : n.intValue(); + } + + /** 本令号工艺行;总令再把子令工艺算进去。 */ + private int countRouteRows(String orderNo) { + Set orderNos = new LinkedHashSet<>(); + orderNos.add(orderNo); + if (!PlmOrderMaterialMatchUtils.isChildProductionOrderNo(orderNo) + && !PlmOrderMaterialMatchUtils.isChangeOrder(orderNo)) { + orderNos.addAll(findChildProductionOrderNos(orderNo)); + } + Long n = processRouteMapper.selectCount( + Wrappers.lambdaQuery() + .in(ProcessRoute::getRouteDescription, orderNos)); + return n == null ? 0 : n.intValue(); + } + + private Set findChildProductionOrderNos(String parentOrderNo) { + String prefix = parentOrderNo + "-"; + List candidates = processOrderProMapper.selectList( + Wrappers.lambdaQuery() + .likeRight(ProcessOrderPro::getProductionOrderNo, prefix) + .select(ProcessOrderPro::getProductionOrderNo)); + Set children = new LinkedHashSet<>(); + if (candidates == null) { + return children; + } + for (ProcessOrderPro row : candidates) { + if (row == null) { + continue; + } + String no = StringUtils.trimToEmpty(row.getProductionOrderNo()); + if (no.startsWith(prefix) && PlmOrderMaterialMatchUtils.isChildProductionOrderNo(no)) { + children.add(no); + } + } + return children; + } + + private static final class CompleteCheck { + boolean complete; + String reason = ""; + int assemblyCount; + int routeCount; + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/SkipProcessNotifyService.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/SkipProcessNotifyService.java new file mode 100644 index 0000000..7a47fd4 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/SkipProcessNotifyService.java @@ -0,0 +1,250 @@ +package com.ruoyi.system.service; + +import com.alibaba.fastjson.JSON; +import com.ruoyi.common.config.DingTalkProperties; +import com.ruoyi.common.dingding.robot.RobotGroupService; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.system.domain.ProcessOrderPro; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.text.SimpleDateFormat; +import java.util.Collection; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +/** + * 无需做工艺 / 电气 BOM 上传通知。 + *

+ * 新增项目勾选「是否做工艺=否」后,等出图完成再发给 {@code dingtalk.plan-user-id}(生管); + * 电气 BOM 仍发给 {@code dingtalk.notify-user-id}。 + * 同时写入「出图 / 工艺 / 钉钉推送日志」。钉钉失败只记日志,不影响保存、出图或导入。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class SkipProcessNotifyService { + + /** 钉钉 Markdown 标题:无需做工艺且出图完成,通知生管 */ + private static final String TITLE_DRAWING_DONE = "无需制作工艺出图完成通知"; + /** 钉钉 Markdown 标题:电气 BOM 导入成功 */ + private static final String TITLE_ELECTRICAL = "电气BOM上传通知"; + /** 留痕摘要:无需做工艺出图完成 */ + private static final String CONTENT_DRAWING_DONE = "项目无需制作工艺,出图已完成,可直接下载图纸"; + /** 留痕摘要:电气 BOM */ + private static final String CONTENT_ELECTRICAL = "电气BOM已上传,无需制作工艺"; + /** 项目管理保存/编辑接口,用于日志 apiPath */ + private static final String API_ORDER_PRO = "/system/orderPro"; + /** 电气 BOM 导入接口,用于日志 apiPath */ + private static final String API_ELECTRICAL_BOM = "/system/details/importElectricalBom"; + + private final DingTalkProperties dingTalkProperties; + private final RobotGroupService robotGroupService; + private final IDrawingRouteOperLogService drawingRouteOperLogService; + + /** + * 是否标记为无需做工艺。 + * + * @param needProcess 0=否,1=是;null 视为需要工艺 + */ + public static boolean isSkipProcess(Integer needProcess) { + return needProcess != null && needProcess == 0; + } + + /** + * 无需做工艺且出图完成后,通知 {@code dingtalk.plan-user-id}(生管)。 + * 新增/编辑时只保存「是否做工艺」,不在这里发钉钉。 + */ + public void notifyDrawingCompleteIfSkipProcess(ProcessOrderPro order) { + if (order == null || !isSkipProcess(order.getNeedProcess())) { + return; + } + String operator = drawingRouteOperLogService.currentOperator(); + notifyAsync(order, IDrawingRouteOperLogService.BIZ_SKIP_PROCESS_NOTIFY, API_ORDER_PRO, + TITLE_DRAWING_DONE, CONTENT_DRAWING_DONE, buildDrawingDoneMarkdown(order, operator), + dingTalkProperties.getPlanUserId(), "plan-user-id", null); + } + + /** + * 电气 BOM 导入成功后,按匹配到的令号各发一条通知。 + * + * @param orders 已落库并标记无需做工艺的项目 + * @param savedCount 本次导入物料条数(写入每条消息摘要) + */ + public void notifyElectricalBomUploaded(Collection orders, int savedCount) { + if (orders == null || orders.isEmpty()) { + return; + } + String operator = drawingRouteOperLogService.currentOperator(); + Map extra = new LinkedHashMap<>(); + extra.put("savedCount", savedCount); + for (ProcessOrderPro order : orders) { + if (order == null) { + continue; + } + notifyAsync(order, IDrawingRouteOperLogService.BIZ_ELECTRICAL_BOM_NOTIFY, API_ELECTRICAL_BOM, + TITLE_ELECTRICAL, CONTENT_ELECTRICAL, + buildElectricalMarkdown(order, savedCount, operator), + dingTalkProperties.getNotifyUserId(), "notify-user-id", extra); + } + } + + /** + * 日志页重发:无需做工艺且出图完成,同步发给生管并返回结果。 + */ + public String resendDrawingComplete(ProcessOrderPro order) { + if (order == null) { + return "项目不存在,未发送"; + } + if (!isSkipProcess(order.getNeedProcess())) { + return "项目不是无需做工艺,未发送"; + } + String operator = drawingRouteOperLogService.currentOperator(); + return sendAndRecord(order, IDrawingRouteOperLogService.BIZ_SKIP_PROCESS_NOTIFY, API_ORDER_PRO, + TITLE_DRAWING_DONE, CONTENT_DRAWING_DONE, buildDrawingDoneMarkdown(order, operator), + dingTalkProperties.getPlanUserId(), "plan-user-id", null); + } + + /** + * 日志页重发:电气 BOM 上传通知,同步发给工艺并返回结果。 + */ + public String resendElectricalBom(ProcessOrderPro order, int savedCount) { + if (order == null) { + return "项目不存在,未发送"; + } + String operator = drawingRouteOperLogService.currentOperator(); + Map extra = new LinkedHashMap<>(); + extra.put("savedCount", savedCount); + extra.put("resend", true); + return sendAndRecord(order, IDrawingRouteOperLogService.BIZ_ELECTRICAL_BOM_NOTIFY, API_ELECTRICAL_BOM, + TITLE_ELECTRICAL, CONTENT_ELECTRICAL, + buildElectricalMarkdown(order, savedCount, operator), + dingTalkProperties.getNotifyUserId(), "notify-user-id", extra); + } + + /** + * 异步发钉钉,并在成功/失败/未配置接收人时写入推送日志。 + * IP、操作人必须在请求线程采集,异步线程无 Servlet 上下文。 + */ + private void notifyAsync(ProcessOrderPro order, String bizType, String apiPath, + String title, String contentSummary, String markdown, + String notifyUserIds, String recipientConfig, Map extraSummary) { + String clientIp = drawingRouteOperLogService.currentClientIp(); + String operator = drawingRouteOperLogService.currentOperator(); + CompletableFuture.runAsync(() -> sendAndRecord(order, bizType, apiPath, title, contentSummary, markdown, + notifyUserIds, recipientConfig, extraSummary, operator, clientIp)); + } + + private String sendAndRecord(ProcessOrderPro order, String bizType, String apiPath, + String title, String contentSummary, String markdown, + String notifyUserIds, String recipientConfig, Map extraSummary) { + return sendAndRecord(order, bizType, apiPath, title, contentSummary, markdown, + notifyUserIds, recipientConfig, extraSummary, + drawingRouteOperLogService.currentOperator(), + drawingRouteOperLogService.currentClientIp()); + } + + private String sendAndRecord(ProcessOrderPro order, String bizType, String apiPath, + String title, String contentSummary, String markdown, + String notifyUserIds, String recipientConfig, Map extraSummary, + String operator, String clientIp) { + String orderNo = order.getProductionOrderNo(); + Map summary = new LinkedHashMap<>(); + summary.put("productionName", order.getProductionName()); + summary.put("drawingBy", order.getDrawingBy()); + summary.put("needProcess", 0); + summary.put("recipientConfig", recipientConfig); + if (extraSummary != null) { + summary.putAll(extraSummary); + } + long started = System.currentTimeMillis(); + if (StringUtils.isBlank(notifyUserIds)) { + log.warn("未配置钉钉 {},跳过「{}」令号={}", recipientConfig, title, orderNo); + record(bizType, apiPath, order, notifyUserIds, title, contentSummary, + IDrawingRouteOperLogService.STATUS_FAIL, "未配置钉钉 " + recipientConfig, + summary, operator, clientIp, started); + return "未配置钉钉 " + recipientConfig + ",未发送"; + } + try { + robotGroupService.sendMarkdownToUser(notifyUserIds, title, markdown); + log.info("已发送「{}」至 {} 令号={}", title, recipientConfig, orderNo); + record(bizType, apiPath, order, notifyUserIds, title, contentSummary, + IDrawingRouteOperLogService.STATUS_SUCCESS, null, + summary, operator, clientIp, started); + return "已发送「" + title + "」令号=" + orderNo; + } catch (Exception e) { + log.error("发送「{}」失败 令号={}", title, orderNo, e); + record(bizType, apiPath, order, notifyUserIds, title, contentSummary, + IDrawingRouteOperLogService.STATUS_FAIL, e.getMessage(), + summary, operator, clientIp, started); + return "发送失败:" + e.getMessage(); + } + } + + /** 写入 drawing_route_oper_log;内部已吞异常,不影响主流程。 */ + private void record(String bizType, String apiPath, ProcessOrderPro order, String notifyUserIds, + String title, String contentSummary, String status, String errorMsg, + Map summary, String operator, String clientIp, long started) { + drawingRouteOperLogService.record(bizType, row -> { + row.setChannel(IDrawingRouteOperLogService.CHANNEL_MANUAL); + row.setApiPath(apiPath); + row.setProjectId(order == null ? null : order.getId()); + row.setProductionOrderNo(order == null ? null : order.getProductionOrderNo()); + row.setNotifyType(IDrawingRouteOperLogService.NOTIFY_MARKDOWN); + row.setNotifyUserIds(notifyUserIds); + row.setMessageTitle(title); + row.setMessageContent(contentSummary); + row.setStatus(status); + row.setErrorMsg(errorMsg); + row.setSummaryJson(JSON.toJSONString(summary)); + if (StringUtils.isNotBlank(operator)) { + row.setOperator(operator); + } + if (StringUtils.isNotBlank(clientIp)) { + row.setClientIp(clientIp); + } + row.setDurationMs(System.currentTimeMillis() - started); + }); + } + + /** 组装「无需做工艺、出图已完成」Markdown,发给生管。 */ + private String buildDrawingDoneMarkdown(ProcessOrderPro order, String operator) { + StringBuilder text = new StringBuilder(); + text.append("**项目无需制作工艺,出图已完成**\n\n"); + appendProjectFields(text, order, operator); + text.append("> **说明**: 本项目已经出图已完成,请直接下载图纸安排后续工作。\n\n"); + text.append("\n请相关人员及时处理。"); + return text.toString(); + } + + /** 组装「电气 BOM 已上传」Markdown。 */ + private String buildElectricalMarkdown(ProcessOrderPro order, int savedCount, String operator) { + StringBuilder text = new StringBuilder(); + text.append("**电气BOM已上传,无需制作工艺**\n\n"); + appendProjectFields(text, order, operator); + if (savedCount > 0) { + text.append("> **本次导入物料**: ").append(savedCount).append(" 条\n\n"); + } + text.append("> **说明**: 电气BOM已导入,本项目无需做工艺,可直接下载图纸。\n\n"); + text.append("\n请相关人员及时处理。"); + return text.toString(); + } + + /** 公共字段:时间、令号、名称、设计人、操作人。 */ + private void appendProjectFields(StringBuilder text, ProcessOrderPro order, String operator) { + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm"); + text.append("> **时间**: ").append(sdf.format(new Date())).append("\n\n"); + text.append("> **项目令号**: ").append(StringUtils.defaultIfEmpty(order.getProductionOrderNo(), "")).append("\n\n"); + text.append("> **项目名称**: ").append(StringUtils.defaultIfEmpty(order.getProductionName(), "")).append("\n\n"); + if (StringUtils.isNotBlank(order.getDrawingBy())) { + text.append("> **设计人**: ").append(order.getDrawingBy()).append("\n\n"); + } + // 无登录态时 currentOperator 为 system,正文里不展示 + if (StringUtils.isNotBlank(operator) && !"system".equals(operator)) { + text.append("> **操作人**: ").append(operator).append("\n\n"); + } + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DesignTaskServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DesignTaskServiceImpl.java new file mode 100644 index 0000000..fd7ef1e --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DesignTaskServiceImpl.java @@ -0,0 +1,895 @@ +package com.ruoyi.system.service.impl; + +import cn.dev33.satoken.secure.BCrypt; +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.http.HttpStatus; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.ruoyi.common.core.domain.PageQuery; +import com.ruoyi.common.core.domain.entity.SysDictData; +import com.ruoyi.common.core.domain.entity.SysRole; +import com.ruoyi.common.core.domain.entity.SysUser; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.enums.UserType; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.helper.LoginHelper; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.system.domain.ProcessDesignReport; +import com.ruoyi.system.domain.ProcessDesignTask; +import com.ruoyi.system.domain.ProcessOrderPro; +import com.ruoyi.system.domain.SysUserRole; +import com.ruoyi.system.domain.bo.DesignTaskDesignDimBo; +import com.ruoyi.system.domain.bo.DesignTaskQueryBo; +import com.ruoyi.system.domain.bo.DesignTaskReportBo; +import com.ruoyi.system.domain.vo.DesignAccountSyncVo; +import com.ruoyi.system.domain.vo.DesignTaskBoardVo; +import com.ruoyi.system.domain.vo.DesignTaskDayHoursVo; +import com.ruoyi.system.domain.vo.DesignTaskReportVo; +import com.ruoyi.system.domain.vo.DesignTaskSummaryVo; +import com.ruoyi.system.domain.vo.DesignTaskVo; +import com.ruoyi.system.mapper.ProcessDesignReportMapper; +import com.ruoyi.system.mapper.ProcessDesignTaskMapper; +import com.ruoyi.system.mapper.ProcessOrderProMapper; +import com.ruoyi.system.mapper.SysRoleMapper; +import com.ruoyi.system.mapper.SysUserRoleMapper; +import com.ruoyi.system.service.IDesignTaskService; +import com.ruoyi.system.service.ISysConfigService; +import com.ruoyi.system.service.ISysDictTypeService; +import com.ruoyi.system.service.ISysUserService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.Date; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 设计个人任务:派工、报工、账号同步。 + */ +@RequiredArgsConstructor +@Service +public class DesignTaskServiceImpl implements IDesignTaskService { + + static final String ROLE_KEY = "designer"; + static final String DICT_TYPE = "plan_proer"; + static final String VIRTUAL_DESIGNER = "程序图"; + static final String HISTORY_REMARK = "上线前已出图"; + static final ZoneId ZONE = ZoneId.of("Asia/Shanghai"); + static final BigDecimal HOUR_STEP = new BigDecimal("0.5"); + static final BigDecimal DAY_CAP = new BigDecimal("16"); + static final BigDecimal WEEK_TARGET = new BigDecimal("40"); + static final BigDecimal TODAY_TARGET = new BigDecimal("8"); + static final DateTimeFormatter DAY_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + static final String[] WEEKDAY = {"一", "二", "三", "四", "五", "六", "日"}; + + private final ProcessDesignTaskMapper taskMapper; + private final ProcessDesignReportMapper reportMapper; + private final ProcessOrderProMapper orderProMapper; + private final ISysUserService userService; + private final ISysDictTypeService dictTypeService; + private final ISysConfigService configService; + private final SysRoleMapper roleMapper; + private final SysUserRoleMapper userRoleMapper; + + @Override + public DesignTaskSummaryVo summaryMine() { + String username = currentUsername(); + List open = taskMapper.selectList(Wrappers.lambdaQuery() + .eq(ProcessDesignTask::getDesignerUserName, username) + .in(ProcessDesignTask::getStatus, ProcessDesignTask.STATUS_PENDING, ProcessDesignTask.STATUS_WORKING, + ProcessDesignTask.STATUS_DRAWING, ProcessDesignTask.STATUS_REVIEW) + .orderByDesc(ProcessDesignTask::getAssignTime) + .orderByDesc(ProcessDesignTask::getId)); + List openVos = toVos(open, false); + long overdue = openVos.stream().filter(item -> Boolean.TRUE.equals(item.getOverdue())).count(); + openVos.sort(Comparator + .comparing((DesignTaskVo item) -> Boolean.TRUE.equals(item.getOverdue())).reversed() + .thenComparing(DesignTaskVo::getDrawingTime, Comparator.nullsLast(Date::compareTo))); + if (openVos.size() > 8) { + openVos = new ArrayList<>(openVos.subList(0, 8)); + } + LocalDate monthStart = LocalDate.now(ZONE).withDayOfMonth(1); + Date start = toDate(monthStart); + Date end = toDate(monthStart.plusMonths(1)); + Long finished = taskMapper.selectCount(Wrappers.lambdaQuery() + .eq(ProcessDesignTask::getDesignerUserName, username) + .in(ProcessDesignTask::getStatus, ProcessDesignTask.STATUS_DONE, ProcessDesignTask.STATUS_REVIEW) + .ge(ProcessDesignTask::getFinishTime, start) + .lt(ProcessDesignTask::getFinishTime, end)); + DesignTaskSummaryVo summary = new DesignTaskSummaryVo(); + summary.setPendingCount(open.size()); + summary.setOverdueCount(overdue); + summary.setFinishedThisMonth(finished == null ? 0 : finished); + summary.setRecent(openVos); + return summary; + } + + @Override + public DesignTaskBoardVo boardMine(String scope) { + String username = currentUsername(); + boolean team = "all".equals(StringUtils.trim(scope)) && LoginHelper.isAdmin(); + LocalDate today = LocalDate.now(ZONE); + LocalDate weekStart = today.with(DayOfWeek.MONDAY); + LocalDate chartStart = today.minusDays(6); + LocalDate rangeStart = weekStart.isBefore(chartStart) ? weekStart : chartStart; + LambdaQueryWrapper reportQuery = Wrappers.lambdaQuery() + .ge(ProcessDesignReport::getReportDate, toDate(rangeStart)) + .lt(ProcessDesignReport::getReportDate, toDate(today.plusDays(1))); + if (!team) { + reportQuery.eq(ProcessDesignReport::getReportBy, username); + } + List reports = reportMapper.selectList(reportQuery); + Map hoursByDay = new HashMap<>(); + Map countByDay = new HashMap<>(); + if (reports != null) { + for (ProcessDesignReport report : reports) { + if (report.getReportDate() == null) { + continue; + } + LocalDate day = report.getReportDate().toInstant().atZone(ZONE).toLocalDate(); + BigDecimal hours = report.getWorkHours() == null ? BigDecimal.ZERO : report.getWorkHours(); + hoursByDay.merge(day, hours, BigDecimal::add); + countByDay.merge(day, 1L, Long::sum); + } + } + BigDecimal weekHours = BigDecimal.ZERO; + BigDecimal todayHours = hoursByDay.getOrDefault(today, BigDecimal.ZERO); + for (Map.Entry entry : hoursByDay.entrySet()) { + if (!entry.getKey().isBefore(weekStart) && !entry.getKey().isAfter(today)) { + weekHours = weekHours.add(entry.getValue()); + } + } + List last7 = new ArrayList<>(); + for (int i = 0; i < 7; i++) { + LocalDate day = chartStart.plusDays(i); + DesignTaskDayHoursVo item = new DesignTaskDayHoursVo(); + item.setDate(day.format(DAY_FMT)); + item.setLabel(WEEKDAY[day.getDayOfWeek().getValue() - 1]); + item.setHours(hoursByDay.getOrDefault(day, BigDecimal.ZERO).setScale(1, RoundingMode.HALF_UP)); + item.setToday(day.equals(today)); + last7.add(item); + } + List mine = taskMapper.selectList(Wrappers.lambdaQuery() + .eq(ProcessDesignTask::getDesignerUserName, username) + .ne(ProcessDesignTask::getStatus, ProcessDesignTask.STATUS_REVOKED)); + if (mine == null) { + mine = Collections.emptyList(); + } + List counted = mine; + if (team) { + List allTasks = taskMapper.selectList(Wrappers.lambdaQuery() + .ne(ProcessDesignTask::getStatus, ProcessDesignTask.STATUS_REVOKED)); + counted = allTasks == null ? Collections.emptyList() : allTasks; + } + long pending = counted.stream().filter(item -> item.getStatus() != null && item.getStatus() == ProcessDesignTask.STATUS_PENDING).count(); + long working = counted.stream().filter(item -> isWorking(item.getStatus())).count(); + Date weekStartDate = toDate(weekStart); + long weekNew = counted.stream().filter(item -> item.getAssignTime() != null && !item.getAssignTime().before(weekStartDate)).count(); + long overdue = toVos(counted, false).stream().filter(item -> Boolean.TRUE.equals(item.getOverdue())).count(); + Long all = team ? (long) counted.size() : taskMapper.selectCount(Wrappers.lambdaQuery() + .ne(ProcessDesignTask::getStatus, ProcessDesignTask.STATUS_REVOKED)); + long people = 1; + if (team) { + people = counted.stream().map(ProcessDesignTask::getDesignerUserName).filter(StringUtils::isNotBlank).distinct().count(); + if (people < 1) { + people = 1; + } + } + DesignTaskBoardVo board = new DesignTaskBoardVo(); + board.setWeekHours(weekHours.setScale(1, RoundingMode.HALF_UP)); + board.setWeekTarget(WEEK_TARGET.multiply(BigDecimal.valueOf(people))); + board.setTodayHours(todayHours.setScale(1, RoundingMode.HALF_UP)); + board.setTodayTarget(TODAY_TARGET.multiply(BigDecimal.valueOf(people))); + board.setTodayReportCount(countByDay.getOrDefault(today, 0L)); + board.setWorkingCount(working); + board.setPendingCount(pending); + board.setOverdueCount(overdue); + board.setMineCount(mine.size()); + board.setAllCount(all == null ? 0 : all); + board.setWeekNewCount(weekNew); + board.setLast7(last7); + return board; + } + + @Override + public TableDataInfo queryMine(DesignTaskQueryBo bo, PageQuery pageQuery) { + DesignTaskQueryBo query = bo == null ? new DesignTaskQueryBo() : bo; + String scope = StringUtils.trim(query.getScope()); + boolean pendingTab = "pending".equals(scope); + boolean seeAll = "all".equals(scope) || (pendingTab && LoginHelper.isAdmin()); + LambdaQueryWrapper wrapper = Wrappers.lambdaQuery(); + if (!seeAll) { + wrapper.eq(ProcessDesignTask::getDesignerUserName, currentUsername()); + } + if (pendingTab) { + wrapper.eq(ProcessDesignTask::getStatus, ProcessDesignTask.STATUS_PENDING); + } else if (query.getStatus() != null) { + wrapper.eq(ProcessDesignTask::getStatus, query.getStatus()); + } else { + wrapper.ne(ProcessDesignTask::getStatus, ProcessDesignTask.STATUS_REVOKED); + } + if (StringUtils.isNotBlank(query.getProductionOrderNo())) { + wrapper.like(ProcessDesignTask::getProductionOrderNo, query.getProductionOrderNo().trim()); + } + String dim = StringUtils.trim(query.getDesignDim()); + if ("2D".equalsIgnoreCase(dim) || "3D".equalsIgnoreCase(dim)) { + wrapper.eq(ProcessDesignTask::getDesignDim, dim.toUpperCase()); + } else if ("none".equalsIgnoreCase(dim)) { + wrapper.and(w -> w.isNull(ProcessDesignTask::getDesignDim).or().eq(ProcessDesignTask::getDesignDim, "")); + } + List vos = toVos(taskMapper.selectList(wrapper), true); + String keyword = StringUtils.trim(query.getKeyword()); + if (StringUtils.isNotBlank(keyword)) { + String needle = keyword.toLowerCase(); + vos = vos.stream().filter(item -> containsKeyword(item, needle)).collect(Collectors.toList()); + } + if (query.getOverdue() != null) { + vos = vos.stream() + .filter(item -> query.getOverdue().equals(Boolean.TRUE.equals(item.getOverdue()))) + .collect(Collectors.toList()); + } + String due = StringUtils.trim(query.getDue()); + if (StringUtils.isNotBlank(due)) { + LocalDate today = LocalDate.now(ZONE); + vos = vos.stream().filter(item -> matchDue(item, due, today)).collect(Collectors.toList()); + } + vos.sort(Comparator + .comparingInt(this::sortRank) + .thenComparing(DesignTaskVo::getDrawingTime, Comparator.nullsLast(Date::compareTo)) + .thenComparing(DesignTaskVo::getId, Comparator.nullsLast(Long::compareTo))); + return page(vos, pageQuery); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Long report(DesignTaskReportBo bo) { + ProcessDesignTask task = requireMine(bo.getTaskId()); + if (task.getStatus() == null || task.getStatus() == ProcessDesignTask.STATUS_PENDING) { + throw new ServiceException("请先接收任务"); + } + if (task.getStatus() == ProcessDesignTask.STATUS_DONE || task.getStatus() == ProcessDesignTask.STATUS_REVOKED) { + throw new ServiceException("已完成或已撤回的任务不能报工"); + } + BigDecimal hours = normalizeHours(bo.getWorkHours()); + int current = task.getProgress() == null ? 0 : task.getProgress(); + Integer progress = bo.getProgress(); + if (progress < 0 || progress > 100) { + throw new ServiceException("进度必须在0到100之间"); + } + if (progress < current) { + throw new ServiceException("进度不能低于已报进度" + current); + } + String content = StringUtils.trim(bo.getContent()); + if (StringUtils.isBlank(content)) { + throw new ServiceException("请填写工作说明"); + } + if (content.length() > 1000) { + throw new ServiceException("工作说明不能超过1000个字符"); + } + LocalDate reportDay = bo.getReportDate() == null + ? LocalDate.now(ZONE) + : bo.getReportDate().toInstant().atZone(ZONE).toLocalDate(); + if (reportDay.isAfter(LocalDate.now(ZONE))) { + throw new ServiceException("报工日期不能晚于今天"); + } + assertDailyCap(task.getId(), reportDay, hours); + Date now = new Date(); + BigDecimal total = task.getTotalHours() == null ? BigDecimal.ZERO : task.getTotalHours(); + int status = statusAfterProgress(progress, true); + taskMapper.update(null, Wrappers.lambdaUpdate() + .eq(ProcessDesignTask::getId, task.getId()) + .set(ProcessDesignTask::getTotalHours, total.add(hours).setScale(1, RoundingMode.HALF_UP)) + .set(ProcessDesignTask::getProgress, progress) + .set(ProcessDesignTask::getStatus, status) + .set(task.getStartTime() == null, ProcessDesignTask::getStartTime, now) + .set(progress >= 100 && task.getFinishTime() == null, ProcessDesignTask::getFinishTime, now)); + + ProcessDesignReport report = new ProcessDesignReport(); + report.setTaskId(task.getId()); + report.setReportDate(toDate(reportDay)); + report.setWorkHours(hours); + report.setProgress(progress); + report.setContent(content); + report.setReportBy(currentUsername()); + report.setReportTime(now); + reportMapper.insert(report); + return report.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void accept(Long taskId) { + ProcessDesignTask task = requireMine(taskId); + if (task.getStatus() == null || task.getStatus() != ProcessDesignTask.STATUS_PENDING) { + throw new ServiceException("只有待接收的任务可以接收"); + } + Date now = new Date(); + taskMapper.update(null, Wrappers.lambdaUpdate() + .eq(ProcessDesignTask::getId, task.getId()) + .set(ProcessDesignTask::getStatus, ProcessDesignTask.STATUS_WORKING) + .set(task.getStartTime() == null, ProcessDesignTask::getStartTime, now)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateDesignDim(DesignTaskDesignDimBo bo) { + ProcessDesignTask task = requireMine(bo.getTaskId()); + if (task.getStatus() != null && task.getStatus() == ProcessDesignTask.STATUS_REVOKED) { + throw new ServiceException("已撤回的任务不能修改设计类型"); + } + String dim = normalizeDim(bo.getDesignDim()); + taskMapper.update(null, Wrappers.lambdaUpdate() + .eq(ProcessDesignTask::getId, task.getId()) + .set(ProcessDesignTask::getDesignDim, dim)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void deleteReport(Long reportId) { + if (reportId == null) { + throw new ServiceException("报工记录不能为空"); + } + ProcessDesignReport report = reportMapper.selectById(reportId); + if (report == null) { + throw new ServiceException("报工记录不存在"); + } + ProcessDesignTask task = requireMine(report.getTaskId()); + if (task.getStatus() != null && (task.getStatus() == ProcessDesignTask.STATUS_DONE + || task.getStatus() == ProcessDesignTask.STATUS_REVOKED)) { + throw new ServiceException("已完成或已撤回的任务不能删除报工"); + } + if (!currentUsername().equals(report.getReportBy())) { + throw new ServiceException("只能删除自己的报工"); + } + reportMapper.deleteById(reportId); + rebuildTaskFromReports(task); + } + + @Override + public List listMyReports(Long taskId) { + requireVisible(taskId); + List reports = reportMapper.selectList(Wrappers.lambdaQuery() + .eq(ProcessDesignReport::getTaskId, taskId) + .orderByDesc(ProcessDesignReport::getReportTime) + .orderByDesc(ProcessDesignReport::getId)); + if (reports == null || reports.isEmpty()) { + return Collections.emptyList(); + } + return reports.stream().map(item -> BeanUtil.toBean(item, DesignTaskReportVo.class)).collect(Collectors.toList()); + } + + @Override + public List listByOrder(Long orderId) { + if (orderId == null) { + throw new ServiceException("项目不能为空"); + } + List tasks = taskMapper.selectList(Wrappers.lambdaQuery() + .eq(ProcessDesignTask::getOrderProId, orderId) + .orderByAsc(ProcessDesignTask::getStatus) + .orderByAsc(ProcessDesignTask::getId)); + return toVos(tasks, true); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public DesignAccountSyncVo syncAccounts() { + SysRole role = roleMapper.selectOne(Wrappers.lambdaQuery() + .eq(SysRole::getRoleKey, ROLE_KEY) + .last("limit 1")); + if (role == null || role.getRoleId() == null) { + throw new ServiceException("未找到设计人员角色,请先执行设计工作台 SQL 脚本"); + } + String initPassword = configService.selectConfigByKey("sys.user.initPassword"); + if (StringUtils.isBlank(initPassword)) { + throw new ServiceException("未配置系统参数 sys.user.initPassword"); + } + String encoded = BCrypt.hashpw(initPassword); + List dicts = dictTypeService.selectDictDataByType(DICT_TYPE); + if (dicts == null) { + dicts = Collections.emptyList(); + } + DesignAccountSyncVo result = new DesignAccountSyncVo(); + Set seen = new HashSet<>(); + boolean skippedVirtual = false; + for (SysDictData dict : dicts) { + String value = StringUtils.trim(dict.getDictValue()); + String label = StringUtils.trim(dict.getDictLabel()); + if (isVirtual(value, label)) { + skippedVirtual = true; + result.setSkipped(result.getSkipped() + 1); + continue; + } + if (StringUtils.isBlank(value)) { + result.setSkipped(result.getSkipped() + 1); + result.getMessages().add("存在空的设计人字典键值,已跳过"); + continue; + } + if (!seen.add(value)) { + continue; + } + if (value.length() > 30) { + result.setSkipped(result.getSkipped() + 1); + result.getMessages().add("设计人「" + value + "」登录名超过30个字符,未建账号"); + continue; + } + String nick = StringUtils.isBlank(label) ? value : label; + if (nick.length() > 30) { + nick = nick.substring(0, 30); + } + SysUser existing = userService.selectUserByUserName(value); + if (existing == null) { + SysUser created = new SysUser(); + created.setUserName(value); + created.setNickName(nick); + created.setUserType(UserType.SYS_USER.getUserType()); + created.setPassword(encoded); + created.setStatus("0"); + created.setSex("2"); + created.setRemark("设计人字典同步"); + created.setRoleIds(new Long[]{role.getRoleId()}); + userService.insertUser(created); + result.setCreatedUsers(result.getCreatedUsers() + 1); + } else if (!hasRole(existing.getUserId(), role.getRoleId())) { + SysUserRole userRole = new SysUserRole(); + userRole.setUserId(existing.getUserId()); + userRole.setRoleId(role.getRoleId()); + userRoleMapper.insert(userRole); + result.setAttachedRoles(result.getAttachedRoles() + 1); + } + } + if (skippedVirtual) { + result.getMessages().add("已跳过虚拟设计人「程序图」"); + } + result.setBackfillCreated(backfillHistory()); + return result; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public int syncOrder(ProcessOrderPro order, boolean backfill) { + if (order == null || order.getId() == null) { + return 0; + } + Set designers = splitDesigners(order.getDrawingBy(), excludedTokens()); + List existing = taskMapper.selectList(Wrappers.lambdaQuery() + .eq(ProcessDesignTask::getOrderProId, order.getId())); + Map left = new LinkedHashMap<>(); + if (existing != null) { + for (ProcessDesignTask task : existing) { + left.put(task.getDesignerUserName(), task); + } + } + Date now = new Date(); + int created = 0; + for (String designer : designers) { + ProcessDesignTask task = left.remove(designer); + if (task == null) { + insertTask(order, designer, backfill, now); + created++; + continue; + } + boolean revoked = task.getStatus() != null && task.getStatus() == ProcessDesignTask.STATUS_REVOKED; + boolean orderNoChanged = StringUtils.isNotBlank(order.getProductionOrderNo()) + && !order.getProductionOrderNo().equals(task.getProductionOrderNo()); + if (revoked) { + taskMapper.update(null, Wrappers.lambdaUpdate() + .eq(ProcessDesignTask::getId, task.getId()) + .set(ProcessDesignTask::getStatus, ProcessDesignTask.STATUS_PENDING) + .set(ProcessDesignTask::getProgress, 0) + .set(ProcessDesignTask::getTotalHours, BigDecimal.ZERO) + .set(ProcessDesignTask::getStartTime, null) + .set(ProcessDesignTask::getFinishTime, null) + .set(ProcessDesignTask::getRemark, null) + .set(ProcessDesignTask::getAssignTime, now) + .set(StringUtils.isNotBlank(order.getProductionOrderNo()), ProcessDesignTask::getProductionOrderNo, order.getProductionOrderNo())); + } else if (orderNoChanged) { + taskMapper.update(null, Wrappers.lambdaUpdate() + .eq(ProcessDesignTask::getId, task.getId()) + .set(ProcessDesignTask::getProductionOrderNo, order.getProductionOrderNo())); + } + } + for (ProcessDesignTask task : left.values()) { + if (isOpen(task.getStatus())) { + taskMapper.update(null, Wrappers.lambdaUpdate() + .eq(ProcessDesignTask::getId, task.getId()) + .set(ProcessDesignTask::getStatus, ProcessDesignTask.STATUS_REVOKED)); + } + } + return created; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void withdrawOpenByOrderIds(Collection orderIds) { + if (orderIds == null || orderIds.isEmpty()) { + return; + } + taskMapper.update(null, Wrappers.lambdaUpdate() + .in(ProcessDesignTask::getOrderProId, orderIds) + .in(ProcessDesignTask::getStatus, ProcessDesignTask.STATUS_PENDING, ProcessDesignTask.STATUS_WORKING, + ProcessDesignTask.STATUS_DRAWING) + .set(ProcessDesignTask::getStatus, ProcessDesignTask.STATUS_REVOKED)); + } + + @Override + public void updateOrderNo(Long orderId, String productionOrderNo) { + if (orderId == null || StringUtils.isBlank(productionOrderNo)) { + return; + } + taskMapper.update(null, Wrappers.lambdaUpdate() + .eq(ProcessDesignTask::getOrderProId, orderId) + .set(ProcessDesignTask::getProductionOrderNo, productionOrderNo)); + } + + private int backfillHistory() { + List orders = orderProMapper.selectList(Wrappers.lambdaQuery() + .isNotNull(ProcessOrderPro::getDrawingBy) + .ne(ProcessOrderPro::getDrawingBy, "")); + if (orders == null || orders.isEmpty()) { + return 0; + } + int created = 0; + for (ProcessOrderPro order : orders) { + created += syncOrder(order, true); + } + return created; + } + + private void insertTask(ProcessOrderPro order, String designer, boolean backfill, Date now) { + ProcessDesignTask task = new ProcessDesignTask(); + task.setOrderProId(order.getId()); + task.setProductionOrderNo(order.getProductionOrderNo()); + task.setDesignerUserName(designer); + task.setAssignTime(now); + task.setTotalHours(BigDecimal.ZERO); + boolean historicalDone = backfill && order.getBomStatus() != null && order.getBomStatus() != 0L; + if (historicalDone) { + task.setStatus(ProcessDesignTask.STATUS_DONE); + task.setProgress(100); + task.setFinishTime(order.getDrawingTime() != null ? order.getDrawingTime() : now); + task.setRemark(HISTORY_REMARK); + } else { + task.setStatus(ProcessDesignTask.STATUS_PENDING); + task.setProgress(0); + } + taskMapper.insert(task); + } + + private TableDataInfo page(List all, PageQuery pageQuery) { + int pageNum = pageQuery.getPageNum() == null || pageQuery.getPageNum() < 1 ? 1 : pageQuery.getPageNum(); + int pageSize = pageQuery.getPageSize() == null || pageQuery.getPageSize() < 1 ? 10 : pageQuery.getPageSize(); + int from = Math.min((pageNum - 1) * pageSize, all.size()); + int to = Math.min(from + pageSize, all.size()); + TableDataInfo result = new TableDataInfo<>(all.subList(from, to), all.size()); + result.setCode(HttpStatus.HTTP_OK); + result.setMsg("查询成功"); + return result; + } + + private List toVos(List tasks, boolean withNick) { + if (tasks == null || tasks.isEmpty()) { + return Collections.emptyList(); + } + Set orderIds = tasks.stream().map(ProcessDesignTask::getOrderProId).collect(Collectors.toSet()); + Map orders = new HashMap<>(); + List orderList = orderProMapper.selectBatchIds(orderIds); + if (orderList != null) { + for (ProcessOrderPro order : orderList) { + orders.put(order.getId(), order); + } + } + List siblings = taskMapper.selectList(Wrappers.lambdaQuery() + .in(ProcessDesignTask::getOrderProId, orderIds)); + Map allDone = new HashMap<>(); + if (siblings != null) { + Map> grouped = siblings.stream() + .collect(Collectors.groupingBy(ProcessDesignTask::getOrderProId)); + for (Map.Entry> entry : grouped.entrySet()) { + List active = entry.getValue().stream() + .filter(item -> item.getStatus() == null || item.getStatus() != ProcessDesignTask.STATUS_REVOKED) + .collect(Collectors.toList()); + boolean done = !active.isEmpty() && active.stream() + .allMatch(item -> isFinished(item.getStatus())); + allDone.put(entry.getKey(), done); + } + } + Map nicks = withNick ? loadNicks(tasks) : Collections.emptyMap(); + LocalDate today = LocalDate.now(ZONE); + List result = new ArrayList<>(tasks.size()); + for (ProcessDesignTask task : tasks) { + DesignTaskVo vo = BeanUtil.toBean(task, DesignTaskVo.class); + ProcessOrderPro order = orders.get(task.getOrderProId()); + if (order != null) { + vo.setProductionName(order.getProductionName()); + vo.setProjectType(order.getIsEnterpriseStandard()); + vo.setDrawingTime(order.getDrawingTime()); + vo.setProjectEndTime(order.getProjectEndTime()); + vo.setBomStatus(order.getBomStatus()); + vo.setWhetherDelay(order.getWhetherDelay()); + if (StringUtils.isNotBlank(order.getProductionOrderNo())) { + vo.setProductionOrderNo(order.getProductionOrderNo()); + } + } + vo.setOverdue(isOverdue(task, order, today)); + vo.setAllDesignersDone(Boolean.TRUE.equals(allDone.get(task.getOrderProId()))); + if (withNick) { + vo.setDesignerName(nicks.getOrDefault(task.getDesignerUserName(), task.getDesignerUserName())); + } + result.add(vo); + } + return result; + } + + private Map loadNicks(List tasks) { + Map nicks = new HashMap<>(); + for (ProcessDesignTask task : tasks) { + String username = task.getDesignerUserName(); + if (StringUtils.isBlank(username) || nicks.containsKey(username)) { + continue; + } + SysUser user = userService.selectUserByUserName(username); + String nick = user != null && StringUtils.isNotBlank(user.getNickName()) ? user.getNickName() : username; + nicks.put(username, nick); + } + return nicks; + } + + private boolean isOverdue(ProcessDesignTask task, ProcessOrderPro order, LocalDate today) { + if (task.getStatus() == null + || task.getStatus() == ProcessDesignTask.STATUS_DONE + || task.getStatus() == ProcessDesignTask.STATUS_REVOKED + || order == null + || order.getDrawingTime() == null) { + return false; + } + LocalDate drawingDay = order.getDrawingTime().toInstant().atZone(ZONE).toLocalDate(); + return drawingDay.isBefore(today); + } + + private ProcessDesignTask requireVisible(Long taskId) { + return loadTask(taskId); + } + + private ProcessDesignTask requireMine(Long taskId) { + ProcessDesignTask task = loadTask(taskId); + if (!currentUsername().equals(task.getDesignerUserName())) { + throw new ServiceException("只能操作自己的设计任务"); + } + return task; + } + + private ProcessDesignTask loadTask(Long taskId) { + if (taskId == null) { + throw new ServiceException("任务不能为空"); + } + ProcessDesignTask task = taskMapper.selectById(taskId); + if (task == null) { + throw new ServiceException("任务不存在"); + } + return task; + } + + private String currentUsername() { + String username = LoginHelper.getUsername(); + if (StringUtils.isBlank(username)) { + throw new ServiceException("请先登录"); + } + return username; + } + + private BigDecimal normalizeHours(BigDecimal hours) { + if (hours == null || hours.compareTo(HOUR_STEP) < 0) { + throw new ServiceException("工时必须不少于0.5小时"); + } + if (hours.compareTo(DAY_CAP) > 0) { + throw new ServiceException("单次工时不能超过16小时"); + } + BigDecimal doubled = hours.multiply(new BigDecimal("2")); + if (doubled.stripTrailingZeros().scale() > 0) { + throw new ServiceException("工时必须以0.5小时为单位"); + } + return hours.setScale(1, RoundingMode.HALF_UP); + } + + private void assertDailyCap(Long taskId, LocalDate day, BigDecimal hours) { + List rows = reportMapper.selectList(Wrappers.lambdaQuery() + .eq(ProcessDesignReport::getTaskId, taskId) + .ge(ProcessDesignReport::getReportDate, toDate(day)) + .lt(ProcessDesignReport::getReportDate, toDate(day.plusDays(1)))); + BigDecimal used = BigDecimal.ZERO; + if (rows != null) { + for (ProcessDesignReport row : rows) { + if (row.getWorkHours() != null) { + used = used.add(row.getWorkHours()); + } + } + } + if (used.add(hours).compareTo(DAY_CAP) > 0) { + throw new ServiceException("该任务当日累计不能超过16小时,今日已报" + used.setScale(1, RoundingMode.HALF_UP) + "小时"); + } + } + + private void rebuildTaskFromReports(ProcessDesignTask task) { + List reports = reportMapper.selectList(Wrappers.lambdaQuery() + .eq(ProcessDesignReport::getTaskId, task.getId()) + .orderByAsc(ProcessDesignReport::getReportTime) + .orderByAsc(ProcessDesignReport::getId)); + BigDecimal total = BigDecimal.ZERO; + int progress = 0; + if (reports != null && !reports.isEmpty()) { + for (ProcessDesignReport row : reports) { + if (row.getWorkHours() != null) { + total = total.add(row.getWorkHours()); + } + } + Integer latest = reports.get(reports.size() - 1).getProgress(); + progress = latest == null ? 0 : latest; + } + boolean received = task.getStartTime() != null || progress > 0; + int status = statusAfterProgress(progress, received); + Date finish = null; + if (progress >= 100) { + finish = task.getFinishTime() != null ? task.getFinishTime() : new Date(); + } + taskMapper.update(null, Wrappers.lambdaUpdate() + .eq(ProcessDesignTask::getId, task.getId()) + .set(ProcessDesignTask::getTotalHours, total.setScale(1, RoundingMode.HALF_UP)) + .set(ProcessDesignTask::getProgress, progress) + .set(ProcessDesignTask::getStatus, status) + .set(ProcessDesignTask::getFinishTime, finish)); + } + + private int sortRank(DesignTaskVo item) { + if (Boolean.TRUE.equals(item.getOverdue())) { + return 0; + } + if (item.getStatus() != null && item.getStatus() == ProcessDesignTask.STATUS_DONE) { + return 3; + } + if (item.getStatus() != null && item.getStatus() == ProcessDesignTask.STATUS_REVIEW) { + return 2; + } + return 1; + } + + private boolean matchDue(DesignTaskVo item, String due, LocalDate today) { + if ("overdue".equals(due)) { + return Boolean.TRUE.equals(item.getOverdue()); + } + if ("upcoming".equals(due)) { + return !Boolean.TRUE.equals(item.getOverdue()); + } + if ("today".equals(due)) { + if (item.getDrawingTime() == null) { + return false; + } + LocalDate drawingDay = item.getDrawingTime().toInstant().atZone(ZONE).toLocalDate(); + return drawingDay.equals(today); + } + return true; + } + + private boolean containsKeyword(DesignTaskVo item, String needle) { + return contains(item.getProductionOrderNo(), needle) + || contains(item.getProductionName(), needle) + || contains(item.getProjectType(), needle) + || contains(item.getDesignerName(), needle) + || contains(item.getDesignerUserName(), needle); + } + + private boolean contains(String value, String needle) { + return value != null && value.toLowerCase().contains(needle); + } + + static int statusAfterProgress(int progress, boolean received) { + if (progress >= 100) { + return ProcessDesignTask.STATUS_REVIEW; + } + if (progress >= 80) { + return ProcessDesignTask.STATUS_DRAWING; + } + if (progress > 0 || received) { + return ProcessDesignTask.STATUS_WORKING; + } + return ProcessDesignTask.STATUS_PENDING; + } + + private static boolean isWorking(Integer status) { + return status != null && (status == ProcessDesignTask.STATUS_WORKING + || status == ProcessDesignTask.STATUS_DRAWING + || status == ProcessDesignTask.STATUS_REVIEW); + } + + private static boolean isOpen(Integer status) { + return status != null && (status == ProcessDesignTask.STATUS_PENDING + || status == ProcessDesignTask.STATUS_WORKING + || status == ProcessDesignTask.STATUS_DRAWING); + } + + private static boolean isFinished(Integer status) { + return status != null && (status == ProcessDesignTask.STATUS_DONE + || status == ProcessDesignTask.STATUS_REVIEW); + } + + private static String normalizeDim(String designDim) { + String dim = StringUtils.trim(designDim); + if ("2D".equalsIgnoreCase(dim)) { + return "2D"; + } + if ("3D".equalsIgnoreCase(dim)) { + return "3D"; + } + throw new ServiceException("设计类型只能是二维或三维"); + } + + private Set excludedTokens() { + Set tokens = new HashSet<>(); + tokens.add(VIRTUAL_DESIGNER); + List dicts = dictTypeService.selectDictDataByType(DICT_TYPE); + if (dicts == null) { + return tokens; + } + for (SysDictData dict : dicts) { + String label = StringUtils.trim(dict.getDictLabel()); + String value = StringUtils.trim(dict.getDictValue()); + if (isVirtual(value, label)) { + if (StringUtils.isNotBlank(label)) { + tokens.add(label); + } + if (StringUtils.isNotBlank(value)) { + tokens.add(value); + } + } + } + return tokens; + } + + private LinkedHashSet splitDesigners(String drawingBy, Set excluded) { + LinkedHashSet designers = new LinkedHashSet<>(); + if (StringUtils.isBlank(drawingBy)) { + return designers; + } + for (String part : drawingBy.split("[,,;;]")) { + String name = StringUtils.trim(part); + if (StringUtils.isBlank(name) || excluded.contains(name)) { + continue; + } + designers.add(name); + } + return designers; + } + + private boolean isVirtual(String value, String label) { + return VIRTUAL_DESIGNER.equals(value) || VIRTUAL_DESIGNER.equals(label); + } + + private boolean hasRole(Long userId, Long roleId) { + Long count = userRoleMapper.selectCount(Wrappers.lambdaQuery() + .eq(SysUserRole::getUserId, userId) + .eq(SysUserRole::getRoleId, roleId)); + return count != null && count > 0; + } + + private Date toDate(LocalDate day) { + ZonedDateTime time = day.atStartOfDay(ZONE); + return Date.from(time.toInstant()); + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DingTodoDispatchServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DingTodoDispatchServiceImpl.java new file mode 100644 index 0000000..a3a4e1c --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DingTodoDispatchServiceImpl.java @@ -0,0 +1,226 @@ +package com.ruoyi.system.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.ruoyi.common.core.domain.PageQuery; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.dingding.relay.DingTodoRelayClient; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.system.constant.DingTodoSceneCodes; +import com.ruoyi.system.domain.DingTodoLog; +import com.ruoyi.system.domain.DingTodoScene; +import com.ruoyi.system.domain.ProcessOrderPro; +import com.ruoyi.system.mapper.DingTodoLogMapper; +import com.ruoyi.system.mapper.DingTodoSceneMapper; +import com.ruoyi.system.mapper.ProcessOrderProMapper; +import com.ruoyi.system.service.DrawingSuccess; +import com.ruoyi.system.service.IDingTodoDispatchService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.io.File; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +/** + * 只有 ding_todo_scene.enabled = 1 的方法才会创建待办,每次尝试写入 ding_todo_log。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class DingTodoDispatchServiceImpl implements IDingTodoDispatchService { + + private final DingTodoSceneMapper sceneMapper; + private final DingTodoLogMapper logMapper; + private final ProcessOrderProMapper processOrderProMapper; + private final DingTodoRelayClient dingTodoRelayClient; + + @Override + public List listScenes() { + ensureScenes(); + return sceneMapper.selectList(Wrappers.lambdaQuery().orderByAsc(DingTodoScene::getId)); + } + + @Override + public void updateScene(DingTodoScene scene) { + if (scene == null || scene.getId() == null) { + throw new ServiceException("待办方法不存在"); + } + DingTodoScene existing = sceneMapper.selectById(scene.getId()); + if (existing == null) { + throw new ServiceException("待办方法不存在"); + } + existing.setEnabled("1".equals(scene.getEnabled()) ? "1" : "0"); + existing.setRecipientType("code".equalsIgnoreCase(StringUtils.trim(scene.getRecipientType())) ? "code" : "name"); + existing.setUserIds(StringUtils.trim(scene.getUserIds())); + existing.setCreateUser(StringUtils.trim(scene.getCreateUser())); + int dueDays = scene.getDueDays() == null ? 3 : scene.getDueDays(); + existing.setDueDays(dueDays > 0 ? dueDays : 3); + if (StringUtils.isNotBlank(scene.getContentLead())) { + existing.setContentLead(scene.getContentLead().trim()); + } + sceneMapper.updateById(existing); + } + + @Override + public TableDataInfo queryLogs(DingTodoLog query, PageQuery pageQuery) { + LambdaQueryWrapper wrapper = Wrappers.lambdaQuery(); + if (query != null) { + wrapper.like(StringUtils.isNotBlank(query.getProductionOrderNo()), + DingTodoLog::getProductionOrderNo, query.getProductionOrderNo()); + wrapper.eq(StringUtils.isNotBlank(query.getSceneCode()), DingTodoLog::getSceneCode, query.getSceneCode()); + wrapper.eq(StringUtils.isNotBlank(query.getStatus()), DingTodoLog::getStatus, query.getStatus()); + } + wrapper.orderByDesc(DingTodoLog::getId); + Page page = logMapper.selectPage(pageQuery.build(), wrapper); + return TableDataInfo.build(page); + } + + @Override + public String sendIfEnabled(String sceneCode, String productionOrderNo, String projectName, Date routeUptime, + File excelFile, String fallbackUserIds) { + DingTodoScene scene; + try { + scene = findScene(sceneCode); + } catch (Exception e) { + log.error("读取待办方法失败,跳过创建待办 scene={}", sceneCode, e); + return null; + } + if (scene == null || !"1".equals(scene.getEnabled())) { + log.info("待办方法未勾选,跳过 scene={} 令号={}", sceneCode, productionOrderNo); + return null; + } + ProcessOrderPro order = findOrder(productionOrderNo); + String drawingDeny = DrawingSuccess.denyIfNotSuccess(order); + if (drawingDeny != null) { + log.info("出图未成功,不创建待办 scene={} 令号={} {}", sceneCode, productionOrderNo, drawingDeny); + insertLog(scene, productionOrderNo, projectName, null, scene.getCreateUser(), null, + "FAIL", drawingDeny, null); + return drawingDeny; + } + + String recipientType = "code".equalsIgnoreCase(StringUtils.trim(scene.getRecipientType())) ? "code" : "name"; + String userIds = StringUtils.trim(scene.getUserIds()); + String fileName = excelFile == null ? null : excelFile.getName(); + try { + if (StringUtils.isBlank(userIds)) { + if ("name".equals(recipientType)) { + throw new IllegalStateException("接收人类型是姓名,请在待办页面填写姓名"); + } + userIds = fallbackUserIds; + } + if (!dingTodoRelayClient.isConfigured()) { + throw new IllegalStateException("未配置待办中转账号 dingtalk.todo-username/password/uuid"); + } + String response = dingTodoRelayClient.createProcessPlanTodo( + productionOrderNo, + projectName, + routeUptime, + excelFile, + userIds, + scene.getCreateUser(), + scene.getDueDays() == null ? 3 : scene.getDueDays(), + scene.getContentLead(), + recipientType); + insertLog(scene, productionOrderNo, projectName, userIds, scene.getCreateUser(), fileName, + "SUCCESS", null, response); + return null; + } catch (Exception e) { + log.error("创建钉钉待办失败 scene={} 令号={}", sceneCode, productionOrderNo, e); + insertLog(scene, productionOrderNo, projectName, userIds, scene.getCreateUser(), fileName, + "FAIL", e.getMessage(), null); + return e.getMessage(); + } + } + + private ProcessOrderPro findOrder(String productionOrderNo) { + if (StringUtils.isBlank(productionOrderNo)) { + return null; + } + return processOrderProMapper.selectOne(Wrappers.lambdaQuery() + .eq(ProcessOrderPro::getProductionOrderNo, productionOrderNo.trim()) + .last("limit 1")); + } + + private DingTodoScene findScene(String sceneCode) { + ensureScenes(); + return sceneMapper.selectOne(Wrappers.lambdaQuery() + .eq(DingTodoScene::getSceneCode, sceneCode) + .last("limit 1")); + } + + private void ensureScenes() { + for (DingTodoScene seed : defaults()) { + Long count = sceneMapper.selectCount(Wrappers.lambdaQuery() + .eq(DingTodoScene::getSceneCode, seed.getSceneCode())); + if (count == null || count == 0) { + sceneMapper.insert(seed); + } + } + } + + private void insertLog(DingTodoScene scene, String productionOrderNo, String projectName, String userIds, + String createUser, String fileName, String status, String errorMsg, String response) { + try { + DingTodoLog row = new DingTodoLog(); + row.setSceneCode(scene.getSceneCode()); + row.setSceneName(scene.getSceneName()); + row.setProductionOrderNo(StringUtils.trim(productionOrderNo)); + row.setProjectName(StringUtils.trim(projectName)); + row.setTitle("工艺计划表待处理" + (StringUtils.isNotBlank(productionOrderNo) ? " " + productionOrderNo.trim() : "")); + row.setContent(scene.getContentLead()); + row.setUserIds(StringUtils.trim(userIds)); + row.setCreateUser(StringUtils.trim(createUser)); + row.setFileName(fileName); + row.setStatus(status); + row.setErrorMsg(abbreviate(errorMsg, 1000)); + row.setResponseBody(abbreviate(response, 2000)); + row.setCreateTime(new Date()); + logMapper.insert(row); + } catch (Exception e) { + log.error("写入待办记录失败 scene={}", scene.getSceneCode(), e); + } + } + + private List defaults() { + List list = new ArrayList<>(); + list.add(seed(DingTodoSceneCodes.DRAWING_PLAN_EXCEL, "出图完成后工艺收到计划表", "1", + DingTodoSceneCodes.USER_SOURCE_NOTIFY, + "工艺计划表已下发,请及时填写工艺。", + "方法 sendProcessRoutePlanExcel。接收人默认 dingtalk.notify-user-id")); + list.add(seed(DingTodoSceneCodes.ROUTE_UPLOAD_PLAN, "手动上传工艺计划表发给生管", "0", + DingTodoSceneCodes.USER_SOURCE_PLAN, + "工艺计划表已上传,请及时处理。", + "方法工艺导入。接收人默认 dingtalk.plan-user-id")); + list.add(seed(DingTodoSceneCodes.PLM_PLAN_COMPLETE, "PLM总装与工艺到齐后发给生管", "0", + DingTodoSceneCodes.USER_SOURCE_PLAN, + "总装配BOM与工艺已到齐,工艺计划表已下发,请及时处理。", + "方法 PLM 到齐通知。接收人默认 dingtalk.plan-user-id")); + return list; + } + + private DingTodoScene seed(String code, String name, String enabled, String userSource, String contentLead, String remark) { + DingTodoScene scene = new DingTodoScene(); + scene.setSceneCode(code); + scene.setSceneName(name); + scene.setEnabled(enabled); + scene.setUserSource(userSource); + scene.setRecipientType("name"); + scene.setDueDays(3); + scene.setContentLead(contentLead); + scene.setRemark(remark); + return scene; + } + + private static String abbreviate(String text, int max) { + if (text == null) { + return null; + } + String value = text.trim(); + return value.length() <= max ? value : value.substring(0, max); + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DrawingRouteOperLogServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DrawingRouteOperLogServiceImpl.java new file mode 100644 index 0000000..8f973da --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DrawingRouteOperLogServiceImpl.java @@ -0,0 +1,134 @@ +package com.ruoyi.system.service.impl; + +import cn.hutool.core.bean.BeanUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.ruoyi.common.core.domain.PageQuery; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.helper.LoginHelper; +import com.ruoyi.common.utils.ServletUtils; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.system.domain.DrawingRouteOperLog; +import com.ruoyi.system.domain.bo.DrawingRouteOperLogBo; +import com.ruoyi.system.domain.vo.DrawingRouteOperLogVo; +import com.ruoyi.system.mapper.DrawingRouteOperLogMapper; +import com.ruoyi.system.service.IDrawingRouteOperLogService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.Date; +import java.util.Map; +import java.util.function.Consumer; + +@Slf4j +@Service +@RequiredArgsConstructor +public class DrawingRouteOperLogServiceImpl implements IDrawingRouteOperLogService { + + private static final int TEXT_MAX = 1900; + private static final int TITLE_MAX = 256; + private static final int FILE_MAX = 256; + private static final int USER_IDS_MAX = 512; + private static final int SUMMARY_MAX = 2000; + + private final DrawingRouteOperLogMapper drawingRouteOperLogMapper; + + @Override + public void record(String bizType, Consumer filler) { + try { + DrawingRouteOperLog row = new DrawingRouteOperLog(); + row.setBizType(bizType); + row.setOperTime(new Date()); + row.setOperator(currentOperator()); + row.setClientIp(currentClientIp()); + if (filler != null) { + filler.accept(row); + } + row.setBizType(trimTo(StringUtils.isNotBlank(row.getBizType()) ? row.getBizType() : bizType, 32)); + row.setChannel(trimTo(row.getChannel(), 16)); + row.setApiPath(trimTo(row.getApiPath(), 128)); + row.setProductionOrderNo(trimTo(row.getProductionOrderNo(), 64)); + row.setDrawingMode(trimTo(row.getDrawingMode(), 16)); + row.setNotifyType(trimTo(row.getNotifyType(), 32)); + row.setNotifyUserIds(trimTo(row.getNotifyUserIds(), USER_IDS_MAX)); + row.setMessageTitle(trimTo(row.getMessageTitle(), TITLE_MAX)); + row.setMessageContent(trimTo(row.getMessageContent(), TEXT_MAX)); + row.setFileName(trimTo(row.getFileName(), FILE_MAX)); + row.setStatus(StringUtils.isBlank(row.getStatus()) ? STATUS_FAIL : trimTo(row.getStatus(), 16)); + row.setErrorMsg(trimTo(row.getErrorMsg(), TEXT_MAX)); + row.setSummaryJson(trimTo(row.getSummaryJson(), SUMMARY_MAX)); + row.setOperator(trimTo(row.getOperator(), 64)); + row.setClientIp(trimTo(row.getClientIp(), 64)); + if (row.getOperTime() == null) { + row.setOperTime(new Date()); + } + drawingRouteOperLogMapper.insert(row); + } catch (Exception e) { + log.error("出图/工艺推送留痕写入失败 bizType={}", bizType, e); + } + } + + @Override + public String currentOperator() { + try { + String name = LoginHelper.getUsername(); + return StringUtils.isNotBlank(name) ? name : "system"; + } catch (Exception e) { + return "system"; + } + } + + @Override + public String currentClientIp() { + try { + return ServletUtils.getClientIP(); + } catch (Exception e) { + return null; + } + } + + @Override + public TableDataInfo queryPageList(DrawingRouteOperLogBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildQueryWrapper(bo); + lqw.orderByDesc(DrawingRouteOperLog::getOperTime); + Page page = drawingRouteOperLogMapper.selectVoPage(pageQuery.build(), lqw); + return TableDataInfo.build(page); + } + + @Override + public DrawingRouteOperLogVo queryDetail(Long id) { + DrawingRouteOperLog row = drawingRouteOperLogMapper.selectById(id); + if (row == null) { + throw new ServiceException("记录不存在"); + } + return BeanUtil.toBean(row, DrawingRouteOperLogVo.class); + } + + private LambdaQueryWrapper buildQueryWrapper(DrawingRouteOperLogBo bo) { + Map params = bo.getParams(); + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.eq(bo.getId() != null, DrawingRouteOperLog::getId, bo.getId()); + lqw.eq(StringUtils.isNotBlank(bo.getBizType()), DrawingRouteOperLog::getBizType, bo.getBizType()); + lqw.eq(StringUtils.isNotBlank(bo.getChannel()), DrawingRouteOperLog::getChannel, bo.getChannel()); + lqw.eq(StringUtils.isNotBlank(bo.getStatus()), DrawingRouteOperLog::getStatus, bo.getStatus()); + lqw.like(StringUtils.isNotBlank(bo.getProductionOrderNo()), + DrawingRouteOperLog::getProductionOrderNo, bo.getProductionOrderNo()); + lqw.between(params.get("beginOperTime") != null && params.get("endOperTime") != null, + DrawingRouteOperLog::getOperTime, params.get("beginOperTime"), params.get("endOperTime")); + return lqw; + } + + private static String trimTo(String value, int max) { + if (value == null) { + return null; + } + String v = value.trim(); + if (v.isEmpty()) { + return null; + } + return v.length() <= max ? v : v.substring(0, max); + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/MesDrawingServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/MesDrawingServiceImpl.java new file mode 100644 index 0000000..3715951 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/MesDrawingServiceImpl.java @@ -0,0 +1,464 @@ +package com.ruoyi.system.service.impl; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.ServletUtils; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.system.domain.PlmPdfZipPush; +import com.ruoyi.system.domain.PlmPdfZipPushItem; +import com.ruoyi.system.domain.ProcessOrderPro; +import com.ruoyi.system.domain.vo.mes.MesDrawingItemVo; +import com.ruoyi.system.domain.vo.mes.MesDrawingListVo; +import com.ruoyi.system.mapper.PlmPdfZipPushItemMapper; +import com.ruoyi.system.mapper.PlmPdfZipPushMapper; +import com.ruoyi.system.mapper.ProcessOrderProMapper; +import com.ruoyi.system.service.IMesDrawingService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import javax.servlet.http.HttpServletRequest; +import java.io.File; +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.TreeMap; +import java.util.stream.Stream; + +/** + * MES 图纸开放:以 PLM 落盘目录为准,推送明细补齐图号/名称,不把本机绝对路径交给 MES。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class MesDrawingServiceImpl implements IMesDrawingService { + + private static final String PLM_DWG_SUBDIR = "dwg"; + private static final String DOWNLOAD_PATH = "/open/mes/drawing/download"; + + private final PlmPdfZipPushMapper pushMapper; + private final PlmPdfZipPushItemMapper pushItemMapper; + private final ProcessOrderProMapper processOrderProMapper; + + @Value("${plm.pdf.base-dir:D:/plm/pdf}") + private String plmPdfBaseDir; + + @Value("${plm.pdf.separator:_}") + private String plmPdfSeparator; + + @Value("${mes.auth.app-key:}") + private String mesAuthAppKey; + + @Value("${mes.auth.app-secret:}") + private String mesAuthAppSecret; + + @Override + public void assertAuth(String appKey, String appSecret) { + if (StringUtils.isBlank(mesAuthAppKey) || StringUtils.isBlank(mesAuthAppSecret)) { + throw new ServiceException("MES鉴权未配置"); + } + if (!safeEquals(mesAuthAppKey, StringUtils.trimToEmpty(appKey)) + || !safeEquals(mesAuthAppSecret, StringUtils.trimToEmpty(appSecret))) { + throw new ServiceException("MES鉴权失败"); + } + } + + @Override + public MesDrawingListVo listDrawings(String productionOrderNo, String materialCode) { + String orderNo = StringUtils.trimToEmpty(productionOrderNo); + if (StringUtils.isBlank(orderNo)) { + throw new ServiceException("生产令号不能为空"); + } + ProcessOrderPro order = findOrder(orderNo); + assertDownloadAllowed(order, orderNo); + + Map byCode = new TreeMap<>(String.CASE_INSENSITIVE_ORDER); + mergePushItems(orderNo, byCode); + mergeDiskFiles(orderNo, byCode); + + String filterCode = StringUtils.trimToEmpty(materialCode); + List items = new ArrayList<>(); + for (MesDrawingItemVo item : byCode.values()) { + if (StringUtils.isNotBlank(filterCode) && !filterCode.equalsIgnoreCase(item.getMaterialCode())) { + continue; + } + fillDownloadUrls(item); + items.add(item); + } + items.sort(Comparator.comparing(MesDrawingItemVo::getMaterialCode, + Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER))); + + MesDrawingListVo vo = new MesDrawingListVo(); + vo.setProductionOrderNo(orderNo); + if (order != null) { + vo.setProductionName(order.getProductionName()); + vo.setDrawingQtyIssued(order.getDrawingQtyIssued()); + vo.setIsDownload(order.getIsDownload()); + } + vo.setItemCount(items.size()); + vo.setItems(items); + return vo; + } + + @Override + public File resolveDownloadFile(String productionOrderNo, String materialCode, String fileType) { + String orderNo = StringUtils.trimToEmpty(productionOrderNo); + String code = StringUtils.trimToEmpty(materialCode); + if (StringUtils.isBlank(orderNo)) { + throw new ServiceException("生产令号不能为空"); + } + if (StringUtils.isBlank(code)) { + throw new ServiceException("物料编码不能为空"); + } + String type = normalizeFileType(fileType); + ProcessOrderPro order = findOrder(orderNo); + assertDownloadAllowed(order, orderNo); + + MesDrawingListVo list = listDrawings(orderNo, code); + if (list.getItems() == null || list.getItems().isEmpty()) { + throw new ServiceException("未找到图纸: " + orderNo + " / " + code); + } + MesDrawingItemVo item = list.getItems().get(0); + Path file = "dwg".equals(type) ? resolveExistingPath(orderNo, item, true) + : resolveExistingPath(orderNo, item, false); + if (file == null) { + throw new ServiceException("图纸文件不存在: " + orderNo + " / " + code + " (" + type + ")"); + } + return file.toFile(); + } + + private void mergePushItems(String orderNo, Map byCode) { + List pushes = pushMapper.selectList( + Wrappers.lambdaQuery() + .eq(PlmPdfZipPush::getProductionOrderNo, orderNo) + .orderByDesc(PlmPdfZipPush::getId)); + if (pushes == null || pushes.isEmpty()) { + return; + } + Map receiveTimeByPushId = new LinkedHashMap<>(); + List pushIds = new ArrayList<>(pushes.size()); + for (PlmPdfZipPush push : pushes) { + if (push.getId() == null) { + continue; + } + pushIds.add(push.getId()); + receiveTimeByPushId.put(push.getId(), push.getReceiveTime()); + } + if (pushIds.isEmpty()) { + return; + } + List rows = pushItemMapper.selectList( + Wrappers.lambdaQuery() + .in(PlmPdfZipPushItem::getPushId, pushIds) + .orderByDesc(PlmPdfZipPushItem::getId)); + if (rows == null) { + return; + } + for (PlmPdfZipPushItem row : rows) { + String code = StringUtils.trimToEmpty(row.getMaterialCode()); + if (StringUtils.isBlank(code)) { + code = parseCodeFromFileName(firstNonBlank(row.getSavedFileName(), row.getSavedDwgFileName())); + } + if (StringUtils.isBlank(code)) { + continue; + } + MesDrawingItemVo item = byCode.get(code); + if (item == null) { + item = new MesDrawingItemVo(); + item.setItemId(row.getId()); + item.setProductionOrderNo(orderNo); + item.setMaterialCode(code); + item.setMaterialName(StringUtils.trimToEmpty(row.getMaterialName())); + item.setUnitQty(row.getUnitQty()); + item.setBatchQty(row.getBatchQty()); + item.setIsDrawingChanged(row.getIsDrawingChanged()); + item.setReceiveTime(receiveTimeByPushId.get(row.getPushId())); + byCode.put(code, item); + } + applyFileMeta(item, orderNo, row, false); + applyFileMeta(item, orderNo, row, true); + } + } + + private void mergeDiskFiles(String orderNo, Map byCode) { + Path pdfDir = orderDir(orderNo); + Path dwgDir = pdfDir.resolve(PLM_DWG_SUBDIR); + scanDir(orderNo, pdfDir, false, byCode); + scanDir(orderNo, dwgDir, true, byCode); + } + + private void scanDir(String orderNo, Path dir, boolean dwg, Map byCode) { + if (!Files.isDirectory(dir)) { + return; + } + String suffix = dwg ? ".dwg" : ".pdf"; + try (Stream stream = Files.list(dir)) { + stream.filter(Files::isRegularFile) + .filter(p -> p.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(suffix)) + .filter(this::isPathUnderBaseDir) + .sorted() + .forEach(p -> applyDiskFile(orderNo, p, dwg, byCode)); + } catch (IOException e) { + log.warn("扫描MES图纸目录失败: {}", dir, e); + } + } + + private void applyDiskFile(String orderNo, Path file, boolean dwg, Map byCode) { + String fileName = file.getFileName().toString(); + String code = parseCodeFromFileName(fileName); + if (StringUtils.isBlank(code)) { + return; + } + MesDrawingItemVo item = byCode.get(code); + if (item == null) { + item = new MesDrawingItemVo(); + item.setProductionOrderNo(orderNo); + item.setMaterialCode(code); + item.setMaterialName(parseNameFromFileName(fileName)); + byCode.put(code, item); + } else if (StringUtils.isBlank(item.getMaterialName())) { + item.setMaterialName(parseNameFromFileName(fileName)); + } + fillFileFields(item, orderNo, file, fileName, dwg); + } + + private void applyFileMeta(MesDrawingItemVo item, String orderNo, PlmPdfZipPushItem row, boolean dwg) { + Path existing = resolveExistingPathFromRow(orderNo, row, dwg); + if (existing == null) { + return; + } + String fileName = dwg + ? firstNonBlank(row.getSavedDwgFileName(), existing.getFileName().toString()) + : firstNonBlank(row.getSavedFileName(), existing.getFileName().toString()); + fillFileFields(item, orderNo, existing, fileName, dwg); + } + + private void fillFileFields(MesDrawingItemVo item, String orderNo, Path file, String fileName, boolean dwg) { + String relative = toRelativePath(orderNo, fileName, dwg); + long size = 0L; + try { + size = Files.size(file); + } catch (IOException ignored) { + // 存在性已校验,大小读失败时仍允许下载 + } + if (dwg) { + if (Boolean.TRUE.equals(item.getDwgExists())) { + return; + } + item.setDwgFileName(fileName); + item.setDwgRelativePath(relative); + item.setDwgExists(true); + item.setDwgSize(size); + } else { + if (Boolean.TRUE.equals(item.getPdfExists())) { + return; + } + item.setPdfFileName(fileName); + item.setPdfRelativePath(relative); + item.setPdfExists(true); + item.setPdfSize(size); + } + } + + private Path resolveExistingPath(String orderNo, MesDrawingItemVo item, boolean dwg) { + String fileName = dwg ? item.getDwgFileName() : item.getPdfFileName(); + if (StringUtils.isBlank(fileName)) { + return null; + } + Path path = dwg + ? Paths.get(plmPdfBaseDir, sanitizePathPart(orderNo), PLM_DWG_SUBDIR, fileName) + : Paths.get(plmPdfBaseDir, sanitizePathPart(orderNo), fileName); + path = path.toAbsolutePath().normalize(); + if (isPathUnderBaseDir(path) && Files.isRegularFile(path)) { + return path; + } + return null; + } + + private Path resolveExistingPathFromRow(String orderNo, PlmPdfZipPushItem row, boolean dwg) { + String savedPath = dwg ? row.getSavedDwgPath() : row.getSavedPath(); + if (StringUtils.isNotBlank(savedPath)) { + Path p = Paths.get(savedPath).toAbsolutePath().normalize(); + if (isPathUnderBaseDir(p) && Files.isRegularFile(p)) { + return p; + } + } + String savedName = dwg ? row.getSavedDwgFileName() : row.getSavedFileName(); + if (StringUtils.isNotBlank(savedName)) { + Path fallback = dwg + ? Paths.get(plmPdfBaseDir, sanitizePathPart(orderNo), PLM_DWG_SUBDIR, savedName) + : Paths.get(plmPdfBaseDir, sanitizePathPart(orderNo), savedName); + fallback = fallback.toAbsolutePath().normalize(); + if (isPathUnderBaseDir(fallback) && Files.isRegularFile(fallback)) { + return fallback; + } + } + return null; + } + + private void fillDownloadUrls(MesDrawingItemVo item) { + if (!Boolean.TRUE.equals(item.getPdfExists())) { + item.setPdfExists(false); + } + if (!Boolean.TRUE.equals(item.getDwgExists())) { + item.setDwgExists(false); + } + String orderEnc = urlEncode(item.getProductionOrderNo()); + String codeEnc = urlEncode(item.getMaterialCode()); + String prefix = downloadPathWithContext(); + if (Boolean.TRUE.equals(item.getPdfExists())) { + item.setPdfDownloadUrl(prefix + "?productionOrderNo=" + orderEnc + + "&materialCode=" + codeEnc + "&fileType=pdf"); + } + if (Boolean.TRUE.equals(item.getDwgExists())) { + item.setDwgDownloadUrl(prefix + "?productionOrderNo=" + orderEnc + + "&materialCode=" + codeEnc + "&fileType=dwg"); + } + } + + private String downloadPathWithContext() { + try { + HttpServletRequest request = ServletUtils.getRequest(); + if (request != null) { + return StringUtils.trimToEmpty(request.getContextPath()) + DOWNLOAD_PATH; + } + } catch (Exception ignored) { + // 非 Web 线程时退回无 context-path + } + return DOWNLOAD_PATH; + } + + private ProcessOrderPro findOrder(String orderNo) { + return processOrderProMapper.selectOne( + Wrappers.lambdaQuery() + .eq(ProcessOrderPro::getProductionOrderNo, orderNo) + .last("LIMIT 1")); + } + + private void assertDownloadAllowed(ProcessOrderPro order, String orderNo) { + if (order == null) { + return; + } + if (order.getIsDownload() != null && order.getIsDownload() == 0) { + throw new ServiceException("当前项目不允许下载图纸: " + orderNo); + } + } + + private Path orderDir(String orderNo) { + return Paths.get(plmPdfBaseDir, sanitizePathPart(orderNo)).toAbsolutePath().normalize(); + } + + private String toRelativePath(String orderNo, String fileName, boolean dwg) { + String safeOrder = sanitizePathPart(orderNo); + if (dwg) { + return safeOrder + "/" + PLM_DWG_SUBDIR + "/" + fileName; + } + return safeOrder + "/" + fileName; + } + + private String parseCodeFromFileName(String fileName) { + String base = stripExtension(fileName); + if (StringUtils.isBlank(base)) { + return ""; + } + String sep = resolveSeparator(); + int idx = base.indexOf(sep); + if (idx > 0) { + return base.substring(0, idx).trim(); + } + return base.trim(); + } + + private String parseNameFromFileName(String fileName) { + String base = stripExtension(fileName); + if (StringUtils.isBlank(base)) { + return ""; + } + String sep = resolveSeparator(); + int idx = base.indexOf(sep); + if (idx > 0 && idx + sep.length() < base.length()) { + return base.substring(idx + sep.length()).trim(); + } + return ""; + } + + private String stripExtension(String fileName) { + String name = StringUtils.trimToEmpty(fileName); + int slash = Math.max(name.lastIndexOf('/'), name.lastIndexOf('\\')); + if (slash >= 0 && slash + 1 < name.length()) { + name = name.substring(slash + 1); + } + int dot = name.lastIndexOf('.'); + return dot > 0 ? name.substring(0, dot) : name; + } + + private String resolveSeparator() { + String sep = StringUtils.trimToEmpty(plmPdfSeparator); + return StringUtils.isNotBlank(sep) ? sep : "_"; + } + + private static String normalizeFileType(String fileType) { + String type = StringUtils.trimToEmpty(fileType).toLowerCase(Locale.ROOT); + if (StringUtils.isBlank(type) || "pdf".equals(type)) { + return "pdf"; + } + if ("dwg".equals(type)) { + return "dwg"; + } + throw new ServiceException("fileType仅支持 pdf 或 dwg"); + } + + private boolean isPathUnderBaseDir(Path path) { + if (path == null) { + return false; + } + Path base = Paths.get(plmPdfBaseDir).toAbsolutePath().normalize(); + Path target = path.toAbsolutePath().normalize(); + return target.startsWith(base); + } + + private static String sanitizePathPart(String input) { + String s = StringUtils.trimToEmpty(input); + s = s.replace("\\", "_").replace("/", "_"); + s = s.replaceAll("[\\\\/:*?\"<>|]", "_"); + s = s.replaceAll("\\s+", " ").trim(); + if (s.length() > 120) { + s = s.substring(0, 120); + } + return StringUtils.isBlank(s) ? "_" : s; + } + + private static String firstNonBlank(String a, String b) { + if (StringUtils.isNotBlank(a)) { + return a.trim(); + } + return StringUtils.trimToEmpty(b); + } + + private static String urlEncode(String raw) { + try { + return URLEncoder.encode(StringUtils.trimToEmpty(raw), "UTF-8").replace("+", "%20"); + } catch (java.io.UnsupportedEncodingException e) { + return StringUtils.trimToEmpty(raw); + } + } + + private static boolean safeEquals(String a, String b) { + if (a == null || b == null) { + return false; + } + return MessageDigest.isEqual(a.getBytes(StandardCharsets.UTF_8), b.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/OrderProDrawingDelayServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/OrderProDrawingDelayServiceImpl.java new file mode 100644 index 0000000..2b2d1ae --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/OrderProDrawingDelayServiceImpl.java @@ -0,0 +1,109 @@ +package com.ruoyi.system.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.ruoyi.common.utils.DrawingDelayCalculator; +import com.ruoyi.common.utils.DrawingDelayResult; +import com.ruoyi.system.domain.FigureSave; +import com.ruoyi.system.domain.ProcessOrderPro; +import com.ruoyi.system.mapper.FigureSaveMapper; +import com.ruoyi.system.mapper.ProcessOrderProMapper; +import com.ruoyi.system.service.IOrderProDrawingDelayService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.Collectors; + +/** + * 未完成出图的项目按整体出图时间滚动延期;全部产品有图纸路径后冻结标识并置为设计出图完成。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class OrderProDrawingDelayServiceImpl implements IOrderProDrawingDelayService { + + private final ProcessOrderProMapper processOrderProMapper; + private final FigureSaveMapper figureSaveMapper; + private final AtomicBoolean refreshing = new AtomicBoolean(false); + + @Override + public void refreshAll() { + if (!refreshing.compareAndSet(false, true)) { + log.info("出图延期刷新已在进行,跳过本次"); + return; + } + try { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(ProcessOrderPro::getBomStatus, DrawingDelayCalculator.BOM_STATUS_SCHEME_DONE); + wrapper.isNotNull(ProcessOrderPro::getDrawingTime); + List list = processOrderProMapper.selectList(wrapper); + if (list == null || list.isEmpty()) { + return; + } + Date now = new Date(); + int updated = 0; + for (ProcessOrderPro project : list) { + ProcessOrderPro patch = buildPatchIfChanged(project, now); + if (patch == null) { + continue; + } + processOrderProMapper.updateById(patch); + updated++; + } + if (updated > 0) { + log.info("已刷新 {} 条项目的出图延期状态", updated); + } + } finally { + refreshing.set(false); + } + } + + @Override + public void refreshByProjectId(Long projectId) { + if (projectId == null) { + return; + } + ProcessOrderPro project = processOrderProMapper.selectById(projectId); + ProcessOrderPro patch = buildPatchIfChanged(project, new Date()); + if (patch != null) { + processOrderProMapper.updateById(patch); + } + } + + private ProcessOrderPro buildPatchIfChanged(ProcessOrderPro project, Date now) { + if (project == null || project.getDrawingTime() == null) { + return null; + } + if (project.getBomStatus() != null + && project.getBomStatus() != DrawingDelayCalculator.BOM_STATUS_SCHEME_DONE) { + return null; + } + List products = figureSaveMapper.selectList( + new LambdaQueryWrapper().eq(FigureSave::getPid, project.getId())); + List paths = products == null ? new ArrayList() + : products.stream().map(FigureSave::getDrawPath).collect(Collectors.toList()); + boolean complete = DrawingDelayCalculator.allProductsHaveDrawPath(paths); + DrawingDelayResult result = DrawingDelayCalculator.evaluate( + project.getDrawingTime(), now, complete, project.getBomStatus()); + + Integer oldDelay = project.getWhetherDelay(); + Long oldDays = project.getDelayDay(); + Long oldStatus = project.getBomStatus(); + if (Objects.equals(oldDelay, result.getWhetherDelay()) + && Objects.equals(oldDays, result.getDelayDay()) + && Objects.equals(oldStatus, result.getBomStatus())) { + return null; + } + ProcessOrderPro patch = new ProcessOrderPro(); + patch.setId(project.getId()); + patch.setWhetherDelay(result.getWhetherDelay()); + patch.setDelayDay(result.getDelayDay()); + patch.setBomStatus(result.getBomStatus()); + return patch; + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/OrderProProgressReportServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/OrderProProgressReportServiceImpl.java new file mode 100644 index 0000000..c5c75db --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/OrderProProgressReportServiceImpl.java @@ -0,0 +1,714 @@ +package com.ruoyi.system.service.impl; + +import cn.hutool.core.bean.BeanUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.ruoyi.common.core.domain.PageQuery; +import com.ruoyi.common.core.domain.entity.SysDictData; +import com.ruoyi.common.core.domain.entity.SysUser; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.utils.DrawingDelayCalculator; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.system.constant.ProcessOrderProSource; +import com.ruoyi.system.domain.DrawingWorkload; +import com.ruoyi.system.domain.ProcessOrderPro; +import com.ruoyi.system.domain.bo.OrderProProgressReportBo; +import com.ruoyi.system.domain.dto.OrderProProgressTimeAgg; +import com.ruoyi.system.domain.vo.OrderProProgressDesignerSummaryVo; +import com.ruoyi.system.domain.vo.OrderProProgressReportOverviewVo; +import com.ruoyi.system.domain.vo.OrderProProgressReportVo; +import com.ruoyi.system.mapper.DrawingWorkloadMapper; +import com.ruoyi.system.mapper.OrderProProgressReportMapper; +import com.ruoyi.system.mapper.ProcessOrderProMapper; +import com.ruoyi.system.mapper.SysUserMapper; +import com.ruoyi.system.service.IOrderProProgressReportService; +import com.ruoyi.system.service.ISysDictTypeService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.Date; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class OrderProProgressReportServiceImpl implements IOrderProProgressReportService { + + private static final String EXCLUDE_CREATE_BY = "崔佳配"; + private static final String EXCLUDE_DESIGNER = "程序图"; + private static final String EXCLUDE_PROJECT_TYPE = "变更"; + private static final String EXCLUDE_ORDER_NO_TOKEN = "BG"; + private static final String UNSPECIFIED_DESIGNER = "未指定"; + + private static final Map PROJECT_TYPE_LABELS = new HashMap<>(); + + static { + PROJECT_TYPE_LABELS.put("CP", "产品"); + PROJECT_TYPE_LABELS.put("FB", "非标"); + PROJECT_TYPE_LABELS.put("BG", "变更"); + PROJECT_TYPE_LABELS.put("QB", "企标"); + PROJECT_TYPE_LABELS.put("EY", "研发"); + PROJECT_TYPE_LABELS.put("GZ", "固资"); + PROJECT_TYPE_LABELS.put("ZP", "展品"); + PROJECT_TYPE_LABELS.put("KC", "库存"); + PROJECT_TYPE_LABELS.put("产品", "产品"); + PROJECT_TYPE_LABELS.put("非标", "非标"); + PROJECT_TYPE_LABELS.put("变更", "变更"); + PROJECT_TYPE_LABELS.put("研发", "研发"); + PROJECT_TYPE_LABELS.put("企标", "企标"); + PROJECT_TYPE_LABELS.put("库存", "库存"); + PROJECT_TYPE_LABELS.put("固资", "固资"); + PROJECT_TYPE_LABELS.put("展品", "展品"); + } + + private final ProcessOrderProMapper processOrderProMapper; + private final OrderProProgressReportMapper orderProProgressReportMapper; + private final DrawingWorkloadMapper drawingWorkloadMapper; + private final SysUserMapper sysUserMapper; + private final ISysDictTypeService dictTypeService; + + @Override + public TableDataInfo queryPageList(OrderProProgressReportBo bo, PageQuery pageQuery) { + Page page = processOrderProMapper.selectPage(pageQuery.build(), buildQueryWrapper(bo)); + List rows = toReportRows(page.getRecords()); + Page voPage = new Page<>(page.getCurrent(), page.getSize(), page.getTotal()); + voPage.setRecords(rows); + return TableDataInfo.build(voPage); + } + + @Override + public List queryList(OrderProProgressReportBo bo) { + return toReportRows(processOrderProMapper.selectList(buildQueryWrapper(bo))); + } + + @Override + public OrderProProgressReportOverviewVo queryOverview(OrderProProgressReportBo bo) { + List rows = processOrderProMapper.selectList(buildQueryWrapper(bo)); + OrderProProgressReportOverviewVo vo = new OrderProProgressReportOverviewVo(); + if (rows == null || rows.isEmpty()) { + return vo; + } + vo.setTotal(rows.size()); + Map drawingByPid = loadDrawingTimesForProjects(rows); + long drawn = 0; + long delayed = 0; + long routeDone = 0; + long bomUpdated = 0; + for (ProcessOrderPro row : rows) { + if (row.getBomStatus() != null && row.getBomStatus() >= 1L) { + drawn++; + } + if (Boolean.TRUE.equals(isDrawingOverdue(row.getDrawingTime(), drawingByPid.get(row.getId())))) { + delayed++; + } + if (row.getRouteCount() != null && row.getRouteCount() > 0L) { + routeDone++; + } + if (row.getBomUptime() != null) { + bomUpdated++; + } + } + vo.setDrawn(drawn); + vo.setDelayed(delayed); + vo.setRouteDone(routeDone); + vo.setBomUpdated(bomUpdated); + fillOverviewWorkload(vo, rows); + return vo; + } + + @Override + public List queryDesignerSummary(OrderProProgressReportBo bo) { + List rows = processOrderProMapper.selectList(buildQueryWrapper(bo)); + if (rows == null || rows.isEmpty()) { + return Collections.emptyList(); + } + Map drawingByPid = loadDrawingTimesForProjects(rows); + List ids = rows.stream().map(ProcessOrderPro::getId).filter(id -> id != null).distinct().collect(Collectors.toList()); + Map workloadByProject = loadWorkloadByProjectIds(ids); + String designerFilter = StringUtils.trim(bo.getDrawingBy()); + Map grouped = new LinkedHashMap<>(); + for (ProcessOrderPro row : rows) { + List designers = splitDesigners(row.getDrawingBy()); + if (StringUtils.isNotBlank(designerFilter)) { + designers = designers.stream() + .filter(name -> designerMatches(name, designerFilter)) + .collect(Collectors.toList()); + } + if (designers.isEmpty()) { + continue; + } + DrawingWorkload workload = workloadByProject.get(row.getId()); + int a4 = workload == null || workload.getA4Count() == null ? 0 : workload.getA4Count(); + int equivalent = workload == null || workload.getEquivalentA4Total() == null ? 0 : workload.getEquivalentA4Total(); + boolean drawn = drawingByPid.get(row.getId()) != null; + Boolean overdue = isDrawingOverdue(row.getDrawingTime(), drawingByPid.get(row.getId())); + for (String designer : designers) { + OrderProProgressDesignerSummaryVo summary = grouped.computeIfAbsent(designer, key -> { + OrderProProgressDesignerSummaryVo created = new OrderProProgressDesignerSummaryVo(); + created.setDrawingBy(key); + return created; + }); + summary.setProjectCount(summary.getProjectCount() + 1); + if (drawn) { + summary.setDrawn(summary.getDrawn() + 1); + } + if (Boolean.TRUE.equals(overdue)) { + summary.setDelayed(summary.getDelayed() + 1); + } else if (Boolean.FALSE.equals(overdue)) { + summary.setOnTime(summary.getOnTime() + 1); + } + summary.setA4Total(summary.getA4Total() + a4); + summary.setEquivalentA4Total(summary.getEquivalentA4Total() + equivalent); + } + } + List result = new ArrayList<>(grouped.values()); + result.sort(Comparator.comparingLong(OrderProProgressDesignerSummaryVo::getProjectCount).reversed() + .thenComparing(Comparator.comparingLong(OrderProProgressDesignerSummaryVo::getEquivalentA4Total).reversed()) + .thenComparing(OrderProProgressDesignerSummaryVo::getDrawingBy, Comparator.nullsLast(String::compareTo))); + return result; + } + + private List toReportRows(List records) { + if (records == null || records.isEmpty()) { + return Collections.emptyList(); + } + List rows = records.stream() + .map(this::toReportVo) + .collect(Collectors.toList()); + fillActualTimes(rows); + fillDrawingWorkload(rows); + return rows; + } + + private OrderProProgressReportVo toReportVo(ProcessOrderPro entity) { + OrderProProgressReportVo vo = BeanUtil.toBean(entity, OrderProProgressReportVo.class); + vo.setSource(StringUtils.isNotBlank(entity.getSource()) ? entity.getSource().trim() : "手动"); + vo.setProjectTypeLabel(formatProjectType(entity.getIsEnterpriseStandard())); + return vo; + } + + private void fillActualTimes(List rows) { + List ids = rows.stream() + .map(OrderProProgressReportVo::getId) + .filter(id -> id != null) + .distinct() + .collect(Collectors.toList()); + List orderNos = rows.stream() + .map(OrderProProgressReportVo::getProductionOrderNo) + .filter(StringUtils::isNotBlank) + .distinct() + .collect(Collectors.toList()); + + List plmOrderNos = rows.stream() + .filter(row -> isPlm(row.getSource())) + .map(OrderProProgressReportVo::getProductionOrderNo) + .filter(StringUtils::isNotBlank) + .distinct() + .collect(Collectors.toList()); + Map drawingByPid = loadActualDrawingTimes(ids); + Map plmDrawingByOrderNo = loadPlmDrawingReceiveTimes(plmOrderNos); + Map plmRouteByOrderNo = loadPlmRouteReceiveTimes(plmOrderNos); + + Map routeByOrderNo = new HashMap<>(); + if (!orderNos.isEmpty()) { + try { + List logTimes = orderProProgressReportMapper.selectActualRouteLogTimes(orderNos); + if (logTimes != null) { + for (OrderProProgressTimeAgg item : logTimes) { + mergeLater(routeByOrderNo, item.getProductionOrderNo(), item.getEventTime()); + } + } + } catch (Exception e) { + log.warn("读取工艺上传日志作为实际完成时间失败,将回退到工艺路线创建时间", e); + } + List routeTimes = orderProProgressReportMapper.selectActualRouteCreateTimes(orderNos); + if (routeTimes != null) { + for (OrderProProgressTimeAgg item : routeTimes) { + mergeLater(routeByOrderNo, item.getProductionOrderNo(), item.getEventTime()); + } + } + } + + for (OrderProProgressReportVo row : rows) { + Date actualDrawingTime; + Date actualCompleteTime; + if (isPlm(row.getSource())) { + actualDrawingTime = plmDrawingByOrderNo.get(row.getProductionOrderNo()); + actualCompleteTime = plmRouteByOrderNo.get(row.getProductionOrderNo()); + } else { + actualDrawingTime = drawingByPid.get(row.getId()); + actualCompleteTime = routeByOrderNo.get(row.getProductionOrderNo()); + } + row.setActualDrawingTime(actualDrawingTime); + row.setDrawingOverdue(isDrawingOverdue(row.getDrawingTime(), actualDrawingTime)); + row.setActualCompleteTime(actualCompleteTime); + } + } + + private Map loadDrawingTimesForProjects(List rows) { + List ids = rows.stream() + .map(ProcessOrderPro::getId) + .filter(id -> id != null) + .distinct() + .collect(Collectors.toList()); + Map drawingByPid = loadActualDrawingTimes(ids); + List plmOrderNos = rows.stream() + .filter(row -> isPlm(row.getSource())) + .map(ProcessOrderPro::getProductionOrderNo) + .filter(StringUtils::isNotBlank) + .distinct() + .collect(Collectors.toList()); + Map plmDrawingByOrderNo = loadPlmDrawingReceiveTimes(plmOrderNos); + for (ProcessOrderPro row : rows) { + if (row.getId() == null || !isPlm(row.getSource())) { + continue; + } + Date plmTime = plmDrawingByOrderNo.get(row.getProductionOrderNo()); + if (plmTime != null) { + drawingByPid.put(row.getId(), plmTime); + } else { + drawingByPid.remove(row.getId()); + } + } + return drawingByPid; + } + + private Map loadPlmDrawingReceiveTimes(List orderNos) { + return loadOrderTimes(orderNos, () -> orderProProgressReportMapper.selectPlmDrawingReceiveTimes(orderNos), + "读取 PLM 图纸推送接收时间失败"); + } + + private Map loadPlmRouteReceiveTimes(List orderNos) { + return loadOrderTimes(orderNos, () -> orderProProgressReportMapper.selectPlmRouteReceiveTimes(orderNos), + "读取 PLM 工艺接收时间失败"); + } + + private Map loadOrderTimes(List orderNos, + java.util.function.Supplier> query, + String failMessage) { + Map byOrderNo = new HashMap<>(); + if (orderNos == null || orderNos.isEmpty()) { + return byOrderNo; + } + try { + List times = query.get(); + if (times != null) { + for (OrderProProgressTimeAgg item : times) { + mergeLater(byOrderNo, item.getProductionOrderNo(), item.getEventTime()); + } + } + } catch (Exception e) { + log.warn(failMessage, e); + } + return byOrderNo; + } + + private static boolean isPlm(String source) { + return ProcessOrderProSource.PLM.equals(StringUtils.trim(source)); + } + + private Map loadActualDrawingTimes(List ids) { + Map drawingByPid = new HashMap<>(); + if (ids == null || ids.isEmpty()) { + return drawingByPid; + } + List drawingTimes = orderProProgressReportMapper.selectActualDrawingTimes(ids); + if (drawingTimes != null) { + for (OrderProProgressTimeAgg item : drawingTimes) { + if (item.getId() != null && item.getEventTime() != null) { + drawingByPid.put(item.getId(), item.getEventTime()); + } + } + } + return drawingByPid; + } + + /** + * 预计出图当天及之前不算逾期。没有实际出图时间时,过了预计出图当天算逾期。 + */ + private static Boolean isDrawingOverdue(Date expected, Date actual) { + if (expected == null) { + return null; + } + LocalDate expectedDay = toLocalDate(expected); + LocalDate compareDay = actual != null ? toLocalDate(actual) : LocalDate.now(DrawingDelayCalculator.ZONE); + return compareDay.isAfter(expectedDay); + } + + private static LocalDate toLocalDate(Date date) { + if (date instanceof java.sql.Date) { + return ((java.sql.Date) date).toLocalDate(); + } + return date.toInstant().atZone(DrawingDelayCalculator.ZONE).toLocalDate(); + } + + private static List splitDesigners(String drawingBy) { + if (StringUtils.isBlank(drawingBy)) { + return Collections.singletonList(UNSPECIFIED_DESIGNER); + } + List names = new ArrayList<>(); + for (String part : drawingBy.split("[,,]")) { + String name = part == null ? "" : part.trim(); + if (StringUtils.isNotBlank(name) && !names.contains(name)) { + names.add(name); + } + } + return names.isEmpty() ? Collections.singletonList(UNSPECIFIED_DESIGNER) : names; + } + + private static boolean designerMatches(String designer, String filter) { + if (StringUtils.isBlank(filter)) { + return true; + } + String token = filter.trim(); + return designer.equals(token) || designer.contains(token); + } + + private void fillDrawingWorkload(List rows) { + Map byProject = loadWorkloadMap(rows); + for (OrderProProgressReportVo row : rows) { + DrawingWorkload workload = byProject.get(row.getId()); + if (workload == null && StringUtils.isNotBlank(row.getProductionOrderNo())) { + workload = byProject.values().stream() + .filter(item -> row.getProductionOrderNo().equals(item.getProductionOrderNo())) + .findFirst() + .orElse(null); + } + if (workload == null) { + row.setA4Count(0); + row.setEquivalentA4Total(0); + continue; + } + row.setA4Count(workload.getA4Count() == null ? 0 : workload.getA4Count()); + row.setEquivalentA4Total(workload.getEquivalentA4Total() == null ? 0 : workload.getEquivalentA4Total()); + } + } + + private Map loadWorkloadMap(List rows) { + List ids = rows.stream() + .map(OrderProProgressReportVo::getId) + .filter(id -> id != null) + .distinct() + .collect(Collectors.toList()); + List orderNos = rows.stream() + .map(OrderProProgressReportVo::getProductionOrderNo) + .filter(StringUtils::isNotBlank) + .distinct() + .collect(Collectors.toList()); + Map map = new HashMap<>(); + map.putAll(loadWorkloadByProjectIds(ids)); + if (!orderNos.isEmpty()) { + try { + List byOrderNo = drawingWorkloadMapper.selectList( + Wrappers.lambdaQuery() + .in(DrawingWorkload::getProductionOrderNo, orderNos) + .select( + DrawingWorkload::getOrderProId, + DrawingWorkload::getProductionOrderNo, + DrawingWorkload::getA4Count, + DrawingWorkload::getEquivalentA4Total)); + if (byOrderNo != null) { + for (DrawingWorkload workload : byOrderNo) { + if (workload.getOrderProId() != null) { + map.putIfAbsent(workload.getOrderProId(), workload); + } + } + for (OrderProProgressReportVo row : rows) { + if (row.getId() == null || map.containsKey(row.getId()) || StringUtils.isBlank(row.getProductionOrderNo())) { + continue; + } + for (DrawingWorkload workload : byOrderNo) { + if (row.getProductionOrderNo().equals(workload.getProductionOrderNo())) { + map.put(row.getId(), workload); + break; + } + } + } + } + } catch (Exception e) { + log.warn("按令号读取图纸工作量失败", e); + } + } + return map; + } + + private void fillOverviewWorkload(OrderProProgressReportOverviewVo vo, List rows) { + Map byProject = loadWorkloadByProjectIds(rows.stream() + .map(ProcessOrderPro::getId) + .filter(id -> id != null) + .distinct() + .collect(Collectors.toList())); + if (byProject.isEmpty()) { + return; + } + long workloadReady = 0; + long a4Total = 0; + long equivalentA4Total = 0; + for (DrawingWorkload workload : byProject.values()) { + workloadReady++; + if (workload.getA4Count() != null) { + a4Total += workload.getA4Count(); + } + if (workload.getEquivalentA4Total() != null) { + equivalentA4Total += workload.getEquivalentA4Total(); + } + } + vo.setWorkloadReady(workloadReady); + vo.setA4Total(a4Total); + vo.setEquivalentA4Total(equivalentA4Total); + } + + private Map loadWorkloadByProjectIds(List ids) { + if (ids == null || ids.isEmpty()) { + return Collections.emptyMap(); + } + try { + List workloads = drawingWorkloadMapper.selectList( + Wrappers.lambdaQuery() + .in(DrawingWorkload::getOrderProId, ids) + .select( + DrawingWorkload::getOrderProId, + DrawingWorkload::getProductionOrderNo, + DrawingWorkload::getA4Count, + DrawingWorkload::getEquivalentA4Total)); + if (workloads == null || workloads.isEmpty()) { + return Collections.emptyMap(); + } + Map map = new HashMap<>(); + for (DrawingWorkload workload : workloads) { + if (workload.getOrderProId() != null) { + map.putIfAbsent(workload.getOrderProId(), workload); + } + } + return map; + } catch (Exception e) { + log.warn("读取图纸工作量失败,报表将不展示 A4/等效A4", e); + return Collections.emptyMap(); + } + } + + private static void mergeLater(Map map, String key, Date value) { + if (StringUtils.isBlank(key) || value == null) { + return; + } + Date current = map.get(key); + if (current == null || value.after(current)) { + map.put(key, value); + } + } + + private LambdaQueryWrapper buildQueryWrapper(OrderProProgressReportBo bo) { + Map params = bo.getParams(); + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.like(StringUtils.isNotBlank(bo.getProductionOrderNo()), + ProcessOrderPro::getProductionOrderNo, bo.getProductionOrderNo()); + lqw.like(StringUtils.isNotBlank(bo.getProductionName()), + ProcessOrderPro::getProductionName, bo.getProductionName()); + if (Boolean.TRUE.equals(bo.getUnassignedDesigner())) { + lqw.and(w -> w.isNull(ProcessOrderPro::getDrawingBy).or().eq(ProcessOrderPro::getDrawingBy, "")); + } else { + lqw.like(StringUtils.isNotBlank(bo.getDrawingBy()), + ProcessOrderPro::getDrawingBy, bo.getDrawingBy()); + } + lqw.like(StringUtils.isNotBlank(bo.getCreateBy()), + ProcessOrderPro::getCreateBy, bo.getCreateBy()); + applyDefaultExcludes(lqw); + lqw.eq(StringUtils.isNotBlank(bo.getIsEnterpriseStandard()), + ProcessOrderPro::getIsEnterpriseStandard, bo.getIsEnterpriseStandard()); + List typeList = expandProjectTypes(bo.getIsEnterpriseStandardList()); + if (typeList != null && !typeList.isEmpty()) { + lqw.in(ProcessOrderPro::getIsEnterpriseStandard, typeList); + } + if (StringUtils.isNotBlank(bo.getSource())) { + String source = bo.getSource().trim(); + if (ProcessOrderProSource.MANUAL.equals(source)) { + lqw.and(w -> w.eq(ProcessOrderPro::getSource, ProcessOrderProSource.MANUAL) + .or().isNull(ProcessOrderPro::getSource) + .or().eq(ProcessOrderPro::getSource, "")); + } else { + lqw.eq(ProcessOrderPro::getSource, source); + } + } + if (params != null) { + if (params.get("beginDrawingTime") != null) { + lqw.ge(ProcessOrderPro::getDrawingTime, params.get("beginDrawingTime")); + } + if (params.get("endDrawingTime") != null) { + lqw.le(ProcessOrderPro::getDrawingTime, endOfDay(params.get("endDrawingTime"))); + } + } + lqw.select( + ProcessOrderPro::getId, + ProcessOrderPro::getProductionOrderNo, + ProcessOrderPro::getDrawingTime, + ProcessOrderPro::getProductionName, + ProcessOrderPro::getDrawingBy, + ProcessOrderPro::getSource, + ProcessOrderPro::getIsEnterpriseStandard, + ProcessOrderPro::getProjectEndTime, + ProcessOrderPro::getRouteUptime, + ProcessOrderPro::getPlanUptime, + ProcessOrderPro::getBomUptime, + ProcessOrderPro::getCreateBy, + ProcessOrderPro::getCreateTime, + ProcessOrderPro::getWhetherDelay, + ProcessOrderPro::getBomStatus, + ProcessOrderPro::getRouteCount + ); + lqw.orderByDesc(ProcessOrderPro::getCreateTime); + return lqw; + } + + private void applyDefaultExcludes(LambdaQueryWrapper lqw) { + List excludedCreateBy = resolveExcludedCreateBy(); + lqw.and(w -> w.isNull(ProcessOrderPro::getCreateBy).or(inner -> { + for (String name : excludedCreateBy) { + inner.ne(ProcessOrderPro::getCreateBy, name) + .notLike(ProcessOrderPro::getCreateBy, name); + } + })); + + List excludedDesigners = resolveExcludedDesigners(); + lqw.and(w -> w.isNull(ProcessOrderPro::getDrawingBy) + .or().eq(ProcessOrderPro::getDrawingBy, "") + .or(inner -> { + for (String token : excludedDesigners) { + inner.notLike(ProcessOrderPro::getDrawingBy, token); + } + })); + + lqw.and(w -> w.isNull(ProcessOrderPro::getIsEnterpriseStandard) + .or().eq(ProcessOrderPro::getIsEnterpriseStandard, "") + .or(inner -> inner.ne(ProcessOrderPro::getIsEnterpriseStandard, EXCLUDE_PROJECT_TYPE) + .ne(ProcessOrderPro::getIsEnterpriseStandard, EXCLUDE_ORDER_NO_TOKEN))); + lqw.and(w -> w.isNull(ProcessOrderPro::getProductionOrderNo) + .or().eq(ProcessOrderPro::getProductionOrderNo, "") + .or(inner -> inner.notLike(ProcessOrderPro::getProductionOrderNo, EXCLUDE_ORDER_NO_TOKEN) + .notLike(ProcessOrderPro::getProductionOrderNo, EXCLUDE_ORDER_NO_TOKEN.toLowerCase()))); + } + + private List resolveExcludedCreateBy() { + Set names = new LinkedHashSet<>(); + names.add(EXCLUDE_CREATE_BY); + try { + List users = sysUserMapper.selectList( + Wrappers.lambdaQuery() + .eq(SysUser::getNickName, EXCLUDE_CREATE_BY) + .or() + .eq(SysUser::getUserName, EXCLUDE_CREATE_BY) + .select(SysUser::getUserName, SysUser::getNickName)); + if (users != null) { + for (SysUser user : users) { + if (user == null) { + continue; + } + if (StringUtils.isNotBlank(user.getUserName())) { + names.add(user.getUserName().trim()); + } + if (StringUtils.isNotBlank(user.getNickName())) { + names.add(user.getNickName().trim()); + } + } + } + } catch (Exception e) { + log.warn("按创建人「{}」解析用户失败,仅按姓名过滤", EXCLUDE_CREATE_BY, e); + } + return new ArrayList<>(names); + } + + private List resolveExcludedDesigners() { + Set tokens = new LinkedHashSet<>(); + tokens.add(EXCLUDE_DESIGNER); + try { + List dicts = dictTypeService.selectDictDataByType("plan_proer"); + if (dicts != null) { + for (SysDictData dict : dicts) { + if (dict == null) { + continue; + } + String label = StringUtils.trim(dict.getDictLabel()); + String value = StringUtils.trim(dict.getDictValue()); + if (EXCLUDE_DESIGNER.equals(label) || EXCLUDE_DESIGNER.equals(value)) { + if (StringUtils.isNotBlank(value)) { + tokens.add(value); + } + if (StringUtils.isNotBlank(label)) { + tokens.add(label); + } + } + } + } + } catch (Exception e) { + log.warn("按设计人「{}」解析字典失败,仅按名称过滤", EXCLUDE_DESIGNER, e); + } + return new ArrayList<>(tokens); + } + + private static Object endOfDay(Object value) { + if (value == null) { + return null; + } + String text = String.valueOf(value).trim(); + if (text.length() == 10) { + return text + " 23:59:59"; + } + return value; + } + + private static String formatProjectType(String code) { + if (StringUtils.isBlank(code)) { + return "-"; + } + return PROJECT_TYPE_LABELS.getOrDefault(code.trim(), code.trim()); + } + + private static List expandProjectTypes(List list) { + if (list == null || list.isEmpty()) { + return list; + } + Map aliases = new HashMap<>(); + aliases.put("产品", "CP"); + aliases.put("非标", "FB"); + aliases.put("变更", "BG"); + aliases.put("企标", "QB"); + aliases.put("研发", "EY"); + aliases.put("固资", "GZ"); + aliases.put("展品", "ZP"); + aliases.put("库存", "KC"); + aliases.put("CP", "产品"); + aliases.put("FB", "非标"); + aliases.put("BG", "变更"); + aliases.put("QB", "企标"); + aliases.put("EY", "研发"); + aliases.put("GZ", "固资"); + aliases.put("ZP", "展品"); + aliases.put("KC", "库存"); + List expanded = new ArrayList<>(); + for (String item : list) { + if (StringUtils.isBlank(item)) { + continue; + } + String value = item.trim(); + if (!expanded.contains(value)) { + expanded.add(value); + } + String alias = aliases.get(value); + if (alias != null && !expanded.contains(alias)) { + expanded.add(alias); + } + } + return expanded; + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/ProcessOrderProServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/ProcessOrderProServiceImpl.java index d4a0fbc..8b39bfb 100644 --- a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/ProcessOrderProServiceImpl.java +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/ProcessOrderProServiceImpl.java @@ -1197,6 +1197,28 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { } } + @Override + public ProcessOrderProVo captureCurrentDrawingState(Long id) { + if (id == null) { + throw new ServiceException("项目ID不能为空"); + } + ProcessOrderPro order = baseMapper.selectById(id); + if (order == null) { + throw new ServiceException("项目不存在"); + } + try { + JSONObject json = readDrawingState(); + int processState = drawingProcessState(json); + if (json != null && processState >= 1 && processState <= 5 + && outputPathMatchesOrder(json, order.getProductionOrderNo())) { + saveDrawingSnapshot(id, json); + } + } catch (Exception e) { + log.warn("读取出图服务状态失败,仍返回项目已保存的出图结果, id={}", id, e); + } + return baseMapper.selectVoById(id); + } + @Override public R sendProcessRoutePlanExcel(Long id) { if (id == null) { @@ -1334,6 +1356,12 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { recordDrawingNotifySkipped(orderPro, started); return R.ok("项目无需做工艺,已跳过推送给工艺"); } + String drawingDeny = DrawingSuccess.denyIfNotSuccess(orderPro); + if (drawingDeny != null) { + log.info("出图未成功,不发送计划表、不创建待办,令号: {} {}", productionOrderNo, drawingDeny); + recordDrawingNotify(orderPro, null, null, IDrawingRouteOperLogService.STATUS_FAIL, drawingDeny, started); + return R.fail(drawingDeny); + } log.info("开始发送出图完成通知并下发工艺计划表Excel,令号: {}", productionOrderNo); String notifyUserIds = dingTalkProperties.getNotifyUserId(); String fileName = null; @@ -1399,7 +1427,7 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { DingTodoSceneCodes.DRAWING_PLAN_EXCEL, productionOrderNo, orderPro.getProductionName(), - orderPro.getProjectEndTime(), + orderPro.getRouteUptime(), excelFile, notifyUserIds); } catch (Exception e) { @@ -2043,44 +2071,232 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { String drawingDate = JSONObject.toJSONString(pwProductionBill); log.info("请求出图报文=====>{}", drawingDate); - // 1. 先发起生成请求 + // 发起前记下当前状态,避免把上一单残留的 idle+4 当成这次成功 + String beforeFingerprint = readDrawingStateFingerprint(); String response = HttpUtils.sendPost(apiUrl, drawingDate); log.info("出图接口响应=====>{}", response); int productCount = pwProductionBill.getProduct() == null ? 1 : pwProductionBill.getProduct().size(); long start = System.currentTimeMillis(); long maxWaitMs = Math.min(30 * 60_000L, 8 * 60_000L + productCount * 60_000L); int tick = 0; + boolean seenBusy = false; + String orderNo = orderPro == null ? null : orderPro.getProductionOrderNo(); while (System.currentTimeMillis() - start < maxWaitMs) { - String statusResp = HttpUtils.sendGet(statusUrl, "UTF-8"); - JSONObject json = JSONObject.parseObject(statusResp); - String currentStatus = json.getString("status"); - int processState = json.getIntValue("processstate"); - log.info("进度状态=====>status={}, processstate={}", currentStatus, processState); - if ("idle".equals(currentStatus) && processState == 4) { - ProcessOrderPro processOrderPro = new ProcessOrderPro(); - BeanUtils.copyProperties(orderPro, processOrderPro); - processOrderPro.setBomStatus(1L); - baseMapper.updateById(processOrderPro); - log.info("图纸处理完成!"); - ProcessOrderPro latest = processOrderPro.getId() != null - ? baseMapper.selectById(processOrderPro.getId()) : processOrderPro; - skipProcessNotifyService.notifyDrawingCompleteIfSkipProcess( - latest != null ? latest : processOrderPro); - return response; + JSONObject json = readDrawingState(); + String currentStatus = json == null ? null : json.getString("status"); + int processState = drawingProcessState(json); + boolean changed = drawingStateChanged(beforeFingerprint, json); + if (isDrawingBusy(json)) { + seenBusy = true; + } + log.info("进度状态=====>status={}, processstate={}, seenBusy={}, msg={}", + currentStatus, processState, seenBusy, json == null ? null : json.getString("msg")); + if (seenBusy || changed) { + Long orderId = orderPro == null ? null : orderPro.getId(); + if (isDrawingTerminalFailure(json)) { + if (shouldRememberDrawingState(json, orderNo, seenBusy)) { + saveDrawingSnapshot(orderId, json); + } + throw new RuntimeException(drawingFailureReason(json) + ",项目状态未更新"); + } + if (isCleanDrawingSuccess(json) && (seenBusy || outputPathMatchesOrder(json, orderNo))) { + saveDrawingSnapshot(orderId, json); + markDrawingCompleted(orderId); + log.info("图纸处理完成,项目状态已更新为设计出图完成, id={}", orderId); + ProcessOrderPro latest = orderId != null ? baseMapper.selectById(orderId) : null; + if (latest != null) { + skipProcessNotifyService.notifyDrawingCompleteIfSkipProcess(latest); + } + return response; + } } long sleepMs = tick < 60 ? 1000L : (tick < 240 ? 2000L : 5000L); Thread.sleep(sleepMs); tick++; } - throw new RuntimeException("图纸生成超时未完成"); + if (!seenBusy) { + throw new RuntimeException("出图服务未开始执行,项目状态未更新"); + } + throw new RuntimeException("图纸生成超时未完成,项目状态未更新"); + } catch (RuntimeException e) { + log.error("调用图纸生成API失败: {}", e.getMessage()); + throw e; } catch (Exception e) { log.error("调用图纸生成API失败", e); - throw new RuntimeException("图纸生成失败", e); + throw new RuntimeException("图纸生成失败:" + e.getMessage(), e); } } + /** + * 只有这次出图真正跑完、且没有失败信息,才把项目改成设计出图完成。 + * 上一单残留的 idle + processstate=4,或 processstate=4 但仍带错误码、失败张数、失败图纸、失败提示,都不改状态。 + */ + private void markDrawingCompleted(Long id) { + if (id == null) { + log.warn("出图完成但项目ID为空,不更新状态"); + return; + } + ProcessOrderPro patch = new ProcessOrderPro(); + patch.setId(id); + patch.setBomStatus(ProjectStatusLock.DRAWING_DONE); + baseMapper.updateById(patch); + } + + /** 把这次出图的 processstate、提示和路径记到项目上,供页面固定展示。不改项目状态。 */ + private void saveDrawingSnapshot(Long id, JSONObject json) { + if (id == null || json == null) { + return; + } + String msg = StringUtils.trimToEmpty(json.getString("msg")); + if (msg.length() > 500) { + msg = msg.substring(0, 500); + } + String path = StringUtils.defaultString(json.getString("outputpath")); + if (path.length() > 500) { + path = path.substring(0, 500); + } + LambdaUpdateWrapper uw = Wrappers.lambdaUpdate(); + uw.eq(ProcessOrderPro::getId, id) + .set(ProcessOrderPro::getDrawingState, drawingProcessState(json)) + .set(ProcessOrderPro::getDrawingStateMsg, msg) + .set(ProcessOrderPro::getDrawingStatePath, path) + .set(ProcessOrderPro::getDrawingStateTime, new Date()); + baseMapper.update(null, uw); + } + + /** 输出路径能对上令号才记;路径还没出来时,只有确认这次任务已经在跑才记。 */ + private boolean shouldRememberDrawingState(JSONObject json, String orderNo, boolean seenBusy) { + if (json == null || StringUtils.isBlank(orderNo)) { + return false; + } + String path = StringUtils.defaultString(json.getString("outputpath")); + if (StringUtils.isNotBlank(path)) { + return outputPathMatchesOrder(json, orderNo); + } + return seenBusy; + } + + private JSONObject readDrawingState() { + String statusResp = HttpUtils.sendGet(statusUrl, "UTF-8"); + if (StringUtils.isBlank(statusResp)) { + return null; + } + return JSONObject.parseObject(statusResp); + } + + private String readDrawingStateFingerprint() { + try { + return drawingStateFingerprint(readDrawingState()); + } catch (Exception e) { + log.warn("读取出图初始状态失败,将等待本次任务进入执行: {}", e.getMessage()); + return ""; + } + } + + private boolean drawingStateChanged(String beforeFingerprint, JSONObject json) { + return StringUtils.isNotBlank(beforeFingerprint) + && !beforeFingerprint.equals(drawingStateFingerprint(json)); + } + + private String drawingStateFingerprint(JSONObject json) { + if (json == null) { + return ""; + } + return String.join("|", + StringUtils.defaultString(json.getString("status")), + String.valueOf(drawingProcessState(json)), + StringUtils.defaultString(json.getString("msg")), + StringUtils.defaultString(json.getString("outputpath")), + String.valueOf(json.getIntValue("errcode")), + String.valueOf(json.getIntValue("outputdrawingfailednumber")), + blankFailedDrawings(json.getString("printfaileddrawings"))); + } + + private boolean isDrawingBusy(JSONObject json) { + return json != null && "busy".equalsIgnoreCase(StringUtils.trim(json.getString("status"))); + } + + private int drawingProcessState(JSONObject json) { + if (json == null || !json.containsKey("processstate")) { + return -1; + } + return json.getIntValue("processstate"); + } + + private boolean outputPathMatchesOrder(JSONObject json, String productionOrderNo) { + if (json == null || StringUtils.isBlank(productionOrderNo)) { + return false; + } + String path = StringUtils.defaultString(json.getString("outputpath")).replace('\\', '/'); + return path.contains(productionOrderNo.trim()); + } + + /** processstate=5,或已结束但仍带失败信息。进行中的 1/2/3 不在这里判失败。 */ + private boolean isDrawingTerminalFailure(JSONObject json) { + int processState = drawingProcessState(json); + if (processState == 5) { + return true; + } + if (json == null || processState != 4) { + return false; + } + return !"busy".equalsIgnoreCase(StringUtils.trim(json.getString("status"))) + && drawingFailureReason(json) != null; + } + + /** idle 且 processstate=4,并且没有错误码、失败张数、失败图纸和失败提示。 */ + private boolean isCleanDrawingSuccess(JSONObject json) { + if (json == null || drawingProcessState(json) != 4) { + return false; + } + if (!"idle".equalsIgnoreCase(StringUtils.trim(json.getString("status")))) { + return false; + } + return drawingFailureReason(json) == null; + } + + private String drawingFailureReason(JSONObject json) { + if (json == null) { + return "出图失败"; + } + int processState = drawingProcessState(json); + int errcode = json.getIntValue("errcode"); + int failedNumber = json.getIntValue("outputdrawingfailednumber"); + String failedDrawings = blankFailedDrawings(json.getString("printfaileddrawings")); + String msg = StringUtils.trimToEmpty(json.getString("msg")); + boolean msgFailed = msg.contains("失败") || msg.contains("未找到") || msg.contains("错误") || msg.contains("异常"); + boolean failed = processState == 5 || errcode != 0 || failedNumber > 0 + || StringUtils.isNotBlank(failedDrawings) || msgFailed; + if (!failed) { + return null; + } + StringBuilder reason = new StringBuilder(); + reason.append(StringUtils.isNotBlank(msg) ? msg : "出图失败"); + if (failedNumber > 0) { + reason.append(",失败 ").append(failedNumber).append(" 张"); + } + if (StringUtils.isNotBlank(failedDrawings)) { + reason.append(",失败图纸:").append(failedDrawings); + } + if (errcode != 0) { + reason.append(",错误码 ").append(errcode); + } + return reason.toString(); + } + + private static String blankFailedDrawings(String raw) { + if (raw == null) { + return ""; + } + String text = raw.trim(); + if (text.isEmpty() || "[]".equals(text) || "0".equals(text) || "null".equalsIgnoreCase(text)) { + return ""; + } + return text; + } + @Override public SseEmitter startDrawingSse(Long id) { SseEmitter emitter = new SseEmitter(0L); @@ -2101,39 +2317,66 @@ public class ProcessOrderProServiceImpl implements IProcessOrderProService { List infos = buildProductInfos(order.getId()); productCount = infos == null ? 0 : infos.size(); bill.setProduct(infos); + String beforeFingerprint = readDrawingStateFingerprint(); String startResp = HttpUtils.sendPost(apiUrl, JSONObject.toJSONString(bill)); emitter.send(SseEmitter.event().name("start").data(startResp)); int maxRetry = 360; int count = 0; + boolean seenBusy = false; while (count < maxRetry) { - String statusResp = HttpUtils.sendGet(statusUrl, "UTF-8"); - JSONObject json = JSONObject.parseObject(statusResp); - String currentStatus = json.getString("status"); - int processState = json.getIntValue("processstate"); - emitter.send(SseEmitter.event().name("progress").id(String.valueOf(count)).data(json.toJSONString())); - if ("idle".equals(currentStatus) && processState == 4) { - ProcessOrderPro update = new ProcessOrderPro(); - BeanUtils.copyProperties(order, update); - update.setBomStatus(1L); - baseMapper.updateById(update); - refreshLocalDrawingFilesFromFtp(order.getProductionOrderNo()); - ProcessOrderPro latest = baseMapper.selectById(id); - skipProcessNotifyService.notifyDrawingCompleteIfSkipProcess( - latest != null ? latest : update); - emitter.send(SseEmitter.event().name("completed").data(startResp)); - recordDrawingExec(order, bo, order.getDrawingMode(), productCount, - IDrawingRouteOperLogService.STATUS_SUCCESS, null, startResp, started, - "/system/orderPro/executDrawing/progress", operator, clientIp); - emitter.complete(); - return; + JSONObject json = readDrawingState(); + boolean changed = drawingStateChanged(beforeFingerprint, json); + if (isDrawingBusy(json)) { + seenBusy = true; + } + if (json != null) { + emitter.send(SseEmitter.event().name("progress").id(String.valueOf(count)).data(json.toJSONString())); + } + if (seenBusy || changed) { + if (isDrawingTerminalFailure(json)) { + if (shouldRememberDrawingState(json, order.getProductionOrderNo(), seenBusy)) { + saveDrawingSnapshot(order.getId(), json); + } + String reason = drawingFailureReason(json) + ",项目状态未更新"; + recordDrawingExec(order, bo, order.getDrawingMode(), productCount, + IDrawingRouteOperLogService.STATUS_FAIL, reason, null, started, + "/system/orderPro/executDrawing/progress", operator, clientIp); + emitter.send(SseEmitter.event().name("error").data(reason)); + emitter.complete(); + return; + } + if (isCleanDrawingSuccess(json) + && (seenBusy || outputPathMatchesOrder(json, order.getProductionOrderNo()))) { + saveDrawingSnapshot(order.getId(), json); + markDrawingCompleted(order.getId()); + refreshLocalDrawingFilesFromFtp(order.getProductionOrderNo()); + ProcessOrderPro latest = baseMapper.selectById(id); + if (latest != null) { + skipProcessNotifyService.notifyDrawingCompleteIfSkipProcess(latest); + } + emitter.send(SseEmitter.event().name("completed").data(startResp)); + recordDrawingExec(order, bo, order.getDrawingMode(), productCount, + IDrawingRouteOperLogService.STATUS_SUCCESS, null, startResp, started, + "/system/orderPro/executDrawing/progress", operator, clientIp); + emitter.complete(); + return; + } } Thread.sleep(1000); count++; } + if (!seenBusy) { + recordDrawingExec(order, null, order == null ? null : order.getDrawingMode(), productCount, + IDrawingRouteOperLogService.STATUS_FAIL, "出图服务未开始执行,项目状态未更新", null, started, + "/system/orderPro/executDrawing/progress", operator, clientIp); + emitter.send(SseEmitter.event().name("error").data("出图服务未开始执行,项目状态未更新")); + emitter.complete(); + return; + } recordDrawingExec(order, null, order == null ? null : order.getDrawingMode(), productCount, - IDrawingRouteOperLogService.STATUS_FAIL, "timeout", null, started, + IDrawingRouteOperLogService.STATUS_FAIL, "图纸生成超时未完成,项目状态未更新", null, started, "/system/orderPro/executDrawing/progress", operator, clientIp); - emitter.send(SseEmitter.event().name("error").data("timeout")); + emitter.send(SseEmitter.event().name("error").data("图纸生成超时未完成,项目状态未更新")); emitter.complete(); } catch (Exception ex) { recordDrawingExec(order, null, order == null ? null : order.getDrawingMode(), productCount, diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/RatioBomImportServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/RatioBomImportServiceImpl.java new file mode 100644 index 0000000..9b3ac58 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/RatioBomImportServiceImpl.java @@ -0,0 +1,193 @@ +package com.ruoyi.system.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.ruoyi.common.core.domain.model.LoginUser; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.helper.LoginHelper; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.system.domain.RatioAccessory; +import com.ruoyi.system.domain.RatioConfig; +import com.ruoyi.system.domain.RatioModel; +import com.ruoyi.system.domain.dto.ratio.RatioBomParseResult; +import com.ruoyi.system.domain.dto.ratio.RatioBomParseResult.LineBom; +import com.ruoyi.system.domain.dto.ratio.RatioBomParseResult.ModelBom; +import com.ruoyi.system.domain.dto.ratio.RatioBomParseResult.SheetBom; +import com.ruoyi.system.domain.dto.ratio.RatioImportResultDTO; +import com.ruoyi.system.mapper.RatioAccessoryMapper; +import com.ruoyi.system.mapper.RatioConfigMapper; +import com.ruoyi.system.mapper.RatioModelMapper; +import com.ruoyi.system.service.IRatioBomImportService; +import com.ruoyi.system.service.support.RatioBomExcelParser; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class RatioBomImportServiceImpl implements IRatioBomImportService { + + private final RatioConfigMapper ratioConfigMapper; + private final RatioModelMapper ratioModelMapper; + private final RatioAccessoryMapper ratioAccessoryMapper; + private final RatioBomExcelParser ratioBomExcelParser; + + @Override + @Transactional(rollbackFor = Exception.class) + public RatioImportResultDTO importExcel(MultipartFile file, String seriesType) { + seriesType = normalizeSeries(seriesType); + if (file == null || file.isEmpty()) { + throw new ServiceException("请上传 Excel 文件"); + } + String filename = file.getOriginalFilename(); + if (filename != null) { + String lower = filename.toLowerCase(); + if (!lower.endsWith(".xlsx") && !lower.endsWith(".xls")) { + throw new ServiceException("请上传 xls 或 xlsx 文件"); + } + } + RatioBomParseResult parsed; + try { + parsed = ratioBomExcelParser.parse(file.getInputStream()); + } catch (IOException e) { + throw new ServiceException("读取 Excel 失败:" + e.getMessage()); + } + + String operator = currentOperator(); + RatioImportResultDTO result = new RatioImportResultDTO(); + result.setSkippedSheets(parsed.getSkippedSheets()); + int modelCount = 0; + int commonCount = 0; + int specialCount = 0; + List details = new ArrayList<>(); + for (SheetBom sheet : parsed.getSheets()) { + RatioConfig config = findOrCreate(sheet, seriesType, operator); + int specialOnSheet = replaceBom(config.getId(), sheet, operator); + modelCount += sheet.getModels().size(); + commonCount += sheet.getCommonParts().size(); + specialCount += specialOnSheet; + details.add(seriesType + " 速比 " + sheet.getRatioValue() + + ":通用件 " + sheet.getCommonParts().size() + + ",型号 " + sheet.getModels().size() + + ",专用件 " + specialOnSheet); + } + result.setSheetCount(parsed.getSheets().size()); + result.setModelCount(modelCount); + result.setCommonCount(commonCount); + result.setSpecialCount(specialCount); + result.setDetails(details); + + StringBuilder message = new StringBuilder("导入完成,机型 " + seriesType + "。"); + for (String detail : details) { + message.append(detail).append("。"); + } + if (!parsed.getSkippedSheets().isEmpty()) { + message.append("跳过:").append(String.join(",", parsed.getSkippedSheets())).append("。"); + } + message.append("已覆盖该机型下这些速比原有的型号和物料明细。"); + result.setMessage(message.toString()); + return result; + } + + private RatioConfig findOrCreate(SheetBom sheet, String seriesType, String operator) { + RatioConfig config = ratioConfigMapper.selectOne(new LambdaQueryWrapper() + .eq(RatioConfig::getSeriesType, seriesType) + .eq(RatioConfig::getRatioValue, sheet.getRatioValue()) + .last("limit 1")); + if (config != null) { + return config; + } + config = new RatioConfig(); + config.setSeriesType(seriesType); + config.setRatioValue(sheet.getRatioValue()); + config.setStatus(0); + config.setCreateBy(operator); + config.setUpdateBy(operator); + ratioConfigMapper.insert(config); + return config; + } + + private int replaceBom(Long configId, SheetBom sheet, String operator) { + ratioAccessoryMapper.delete(new LambdaQueryWrapper() + .eq(RatioAccessory::getRatioConfigId, configId)); + ratioModelMapper.delete(new LambdaQueryWrapper() + .eq(RatioModel::getRatioConfigId, configId)); + + List accessories = new ArrayList<>(); + for (LineBom line : sheet.getCommonParts()) { + accessories.add(toAccessory(configId, null, line, sheet.getSheetName(), operator)); + } + int specialCount = 0; + for (ModelBom modelBom : sheet.getModels()) { + checkLength(sheet.getSheetName(), 0, "减速机型号", modelBom.getModelCode(), 100); + RatioModel model = new RatioModel(); + model.setRatioConfigId(configId); + model.setModelCode(modelBom.getModelCode()); + model.setStatus(0); + model.setCreateBy(operator); + model.setUpdateBy(operator); + ratioModelMapper.insert(model); + for (LineBom line : modelBom.getParts()) { + accessories.add(toAccessory(configId, model.getId(), line, sheet.getSheetName(), operator)); + specialCount++; + } + } + for (RatioAccessory accessory : accessories) { + ratioAccessoryMapper.insert(accessory); + } + return specialCount; + } + + private RatioAccessory toAccessory(Long configId, Long modelId, LineBom line, String sheetName, String operator) { + checkLength(sheetName, line.getExcelRow(), "物料编码", line.getMaterialCode(), 100); + checkLength(sheetName, line.getExcelRow(), "物料名称", line.getMaterialName(), 200); + checkLength(sheetName, line.getExcelRow(), "材料", line.getMaterial(), 200); + checkLength(sheetName, line.getExcelRow(), "配件类别", line.getPartCategory(), 50); + RatioAccessory accessory = new RatioAccessory(); + accessory.setRatioConfigId(configId); + accessory.setRatioModelId(modelId); + accessory.setMaterialCode(line.getMaterialCode()); + accessory.setMaterialName(StringUtils.isBlank(line.getMaterialName()) ? "" : line.getMaterialName()); + accessory.setCanzhi(StringUtils.isBlank(line.getMaterial()) ? "" : line.getMaterial()); + accessory.setPartCategory(line.getPartCategory()); + accessory.setBomQty(line.getBomQty()); + accessory.setStatus(0); + accessory.setCreateBy(operator); + accessory.setUpdateBy(operator); + return accessory; + } + + private void checkLength(String sheetName, int excelRow, String label, String value, int max) { + if (value != null && value.length() > max) { + String where = excelRow > 0 ? "第" + excelRow + "行" : ""; + throw new ServiceException("Sheet「" + sheetName + "」" + where + label + "超过" + max + "个字符"); + } + } + + private String normalizeSeries(String seriesType) { + if (seriesType == null) { + throw new ServiceException("请选择机型 KP08 或 KP14"); + } + String value = seriesType.trim().toUpperCase(); + if (!"KP08".equals(value) && !"KP14".equals(value)) { + throw new ServiceException("机型只能是 KP08 或 KP14"); + } + return value; + } + + private String currentOperator() { + try { + LoginUser loginUser = LoginHelper.getLoginUser(); + if (loginUser != null && StringUtils.isNotBlank(loginUser.getUsername())) { + return loginUser.getUsername(); + } + } catch (Exception ignored) { + // 未登录时落系统账号,避免创建人非空约束失败 + } + return "system"; + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/VmiMaterialServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/VmiMaterialServiceImpl.java new file mode 100644 index 0000000..d57810e --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/VmiMaterialServiceImpl.java @@ -0,0 +1,891 @@ +package com.ruoyi.system.service.impl; + +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.kingdee.bos.webapi.sdk.K3CloudApi; +import com.ruoyi.common.core.domain.PageQuery; +import com.ruoyi.common.core.page.TableDataInfo; +import com.ruoyi.common.dingding.media.MediaService; +import com.ruoyi.common.dingding.robot.RobotGroupService; +import com.ruoyi.common.core.domain.entity.SysUser; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.helper.LoginHelper; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.system.controller.BomDetailsController; +import com.ruoyi.system.domain.BomDetails; +import com.ruoyi.system.domain.VmiMaterial; +import com.ruoyi.system.domain.VmiPurchaseReqLog; +import com.ruoyi.system.domain.VmiStockAlertLog; +import com.ruoyi.system.domain.bo.VmiMaterialBo; +import com.ruoyi.system.domain.bo.VmiPurchaseReqLogBo; +import com.ruoyi.system.domain.bo.VmiReplenishConfirmBo; +import com.ruoyi.system.domain.bo.VmiReplenishLineBo; +import com.ruoyi.system.domain.bo.VmiReplenishPreviewBo; +import com.ruoyi.system.domain.bo.VmiStockAlertLogBo; +import com.ruoyi.system.domain.vo.VmiLowStockAlertExportVo; +import com.ruoyi.system.domain.vo.VmiMaterialVo; +import com.ruoyi.system.domain.vo.VmiPurchaseReqLogVo; +import com.ruoyi.system.domain.vo.VmiReplenishPreviewItemVo; +import com.ruoyi.system.domain.vo.VmiStockAlertLogVo; +import com.ruoyi.system.mapper.SysUserMapper; +import com.ruoyi.system.mapper.VmiMaterialMapper; +import com.ruoyi.system.mapper.VmiPurchaseReqLogMapper; +import com.ruoyi.system.mapper.VmiStockAlertLogMapper; +import com.ruoyi.system.service.IVmiMaterialService; +import com.ruoyi.system.service.support.VmiInventoryQuery; +import com.alibaba.excel.EasyExcel; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.File; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +@Slf4j +@RequiredArgsConstructor +@Service +public class VmiMaterialServiceImpl implements IVmiMaterialService { + + private final VmiMaterialMapper materialMapper; + private final VmiStockAlertLogMapper alertMapper; + private final VmiPurchaseReqLogMapper reqMapper; + private final SysUserMapper sysUserMapper; + private final RobotGroupService robotGroupService; + private final MediaService mediaService; + private final BomDetailsController bomDetailsController; + + /** 金蝶采购申请-申请部门(仓储部 009) */ + @Value("${vmi.kingdee.application-dept:009}") + private String applicationDept; + + /** 金蝶采购申请-申请组织 */ + @Value("${vmi.kingdee.application-org:100}") + private String applicationOrg; + + /** 仓储部VMI负责人钉钉userId默认值(物料未配置时使用) */ + @Value("${vmi.ding-user-ids:}") + private String defaultDingUserIds; + + @Override + public TableDataInfo queryPageList(VmiMaterialBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = buildMaterialWrapper(bo); + Page page = materialMapper.selectVoPage(pageQuery.build(), lqw); + return TableDataInfo.build(page); + } + + @Override + public VmiMaterialVo queryById(Long id) { + return materialMapper.selectVoById(id); + } + + @Override + public Boolean insertByBo(VmiMaterialBo bo) { + VmiMaterial add = BeanUtil.toBean(bo, VmiMaterial.class); + validCode(add.getMaterialCode(), null); + if (StringUtils.isBlank(add.getEnabled())) { + add.setEnabled("1"); + } + if (StringUtils.isBlank(add.getStockStatus())) { + add.setStockStatus("NORMAL"); + } + pushNewMaterialToKingdee(add); + return materialMapper.insert(add) > 0; + } + + @Override + public Boolean updateByBo(VmiMaterialBo bo) { + VmiMaterial update = BeanUtil.toBean(bo, VmiMaterial.class); + validCode(update.getMaterialCode(), update.getId()); + return materialMapper.updateById(update) > 0; + } + + @Override + public Boolean deleteWithValidByIds(Collection ids) { + return materialMapper.deleteBatchIds(ids) > 0; + } + + @Override + public int scanLowStock() { + List list = materialMapper.selectList( + Wrappers.lambdaQuery().eq(VmiMaterial::getEnabled, "1")); + List hits = new ArrayList<>(); + for (VmiMaterial item : list) { + try { + AlertHit hit = collectLowStockHit(item); + if (hit != null) { + hits.add(hit); + } + } catch (Exception e) { + log.error("VMI扫描失败 material={}", item.getMaterialCode(), e); + } + } + if (!hits.isEmpty()) { + pushAggregatedDingAlerts(hits); + } + return hits.size(); + } + + @Override + public List previewReplenish(VmiReplenishPreviewBo bo) { + List alertIds = resolvePreviewAlertIds(bo); + if (alertIds.isEmpty()) { + throw new ServiceException("请勾选待确认的触发记录"); + } + List alerts = alertMapper.selectBatchIds(alertIds); + if (alerts == null || alerts.isEmpty()) { + throw new ServiceException("触发记录不存在"); + } + Map alertMap = new LinkedHashMap<>(); + for (VmiStockAlertLog alert : alerts) { + if (!"PENDING".equals(alert.getAlertStatus())) { + throw new ServiceException("存在已处理记录,不能确认:" + nvlStr(alert.getMaterialCode())); + } + alertMap.put(alert.getId(), alert); + } + Map materialMap = loadMaterialMap(alerts); + List rows = new ArrayList<>(); + for (Long alertId : alertIds) { + VmiStockAlertLog alert = alertMap.get(alertId); + if (alert == null) { + throw new ServiceException("触发记录不存在:" + alertId); + } + VmiMaterial material = materialMap.get(alert.getMaterialId()); + if (material == null) { + throw new ServiceException("VMI物料不存在:" + nvlStr(alert.getMaterialCode())); + } + BigDecimal suggest = calcReplenishQty(material, alert.getCurrentQty()); + VmiReplenishPreviewItemVo vo = new VmiReplenishPreviewItemVo(); + vo.setAlertId(alert.getId()); + vo.setMaterialId(material.getId()); + vo.setMaterialCode(material.getMaterialCode()); + vo.setMaterialName(material.getMaterialName()); + vo.setSupplierCode(material.getSupplierCode()); + vo.setSupplierName(material.getSupplierName()); + vo.setCurrentQty(alert.getCurrentQty()); + vo.setAvailableQty(alert.getAvailableQty()); + vo.setMinSafeQty(material.getMinSafeQty()); + vo.setMaxSafeQty(material.getMaxSafeQty()); + vo.setMinPackQty(material.getMinPackQty()); + vo.setSuggestQty(suggest); + vo.setReqQty(suggest); + rows.add(vo); + } + return rows; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public int confirmReplenish(VmiReplenishConfirmBo bo) { + Map qtyByAlert = resolveQtyByAlert(bo); + List alertIds = new ArrayList<>(qtyByAlert.keySet()); + if (alertIds.isEmpty()) { + throw new ServiceException("请勾选待确认的触发记录"); + } + List alerts = alertMapper.selectBatchIds(alertIds); + if (alerts == null || alerts.isEmpty()) { + throw new ServiceException("触发记录不存在"); + } + Map alertMap = new LinkedHashMap<>(); + for (VmiStockAlertLog alert : alerts) { + if (!"PENDING".equals(alert.getAlertStatus())) { + throw new ServiceException("存在已处理记录,不能重复确认:" + nvlStr(alert.getMaterialCode())); + } + alertMap.put(alert.getId(), alert); + } + + Map materialMap = loadMaterialMap(alerts); + // 按供应商分组:优先供应商编码,其次供应商名称 + Map> groups = new LinkedHashMap<>(); + for (Long alertId : alertIds) { + VmiStockAlertLog alert = alertMap.get(alertId); + if (alert == null) { + throw new ServiceException("触发记录不存在:" + alertId); + } + VmiMaterial material = materialMap.get(alert.getMaterialId()); + if (material == null) { + throw new ServiceException("VMI物料不存在:" + nvlStr(alert.getMaterialCode())); + } + BigDecimal qty = qtyByAlert.get(alertId); + if (qty == null || qty.compareTo(BigDecimal.ZERO) <= 0) { + qty = calcReplenishQty(material, alert.getCurrentQty()); + } + if (qty == null || qty.compareTo(BigDecimal.ZERO) <= 0) { + throw new ServiceException("物料 " + material.getMaterialCode() + " 申请数量无效"); + } + String arrivalDate = resolveArrivalDate(bo, alertId); + if (StringUtils.isBlank(arrivalDate)) { + throw new ServiceException("物料 " + material.getMaterialCode() + " 未填写到货时间"); + } + String groupKey = supplierGroupKey(material); + groups.computeIfAbsent(groupKey, k -> new ArrayList<>()) + .add(new ConfirmLine(alert, material, qty, arrivalDate)); + } + + int billCount = 0; + String operator = currentUser(); + Date now = new Date(); + for (Map.Entry> entry : groups.entrySet()) { + List lines = entry.getValue(); + VmiMaterial first = lines.get(0).material; + try { + KingdeeSaveResult save = saveKingdeeRequisition(first, lines); + for (ConfirmLine line : lines) { + VmiPurchaseReqLog reqLog = new VmiPurchaseReqLog(); + reqLog.setAlertLogId(line.alert.getId()); + reqLog.setMaterialId(line.material.getId()); + reqLog.setMaterialCode(line.material.getMaterialCode()); + reqLog.setMaterialName(line.material.getMaterialName()); + reqLog.setSupplierCode(line.material.getSupplierCode()); + reqLog.setReqQty(line.qty); + reqLog.setKingdeeBillNo(save.billNo); + reqLog.setKingdeeFid(save.fid); + reqLog.setPushStatus("SUCCESS"); + reqLog.setPushMsg("同供应商合并生成采购申请,共 " + lines.size() + " 行明细"); + reqMapper.insert(reqLog); + + line.alert.setAlertStatus("CONFIRMED"); + line.alert.setConfirmBy(operator); + line.alert.setConfirmTime(now); + alertMapper.updateById(line.alert); + } + billCount++; + } catch (Exception e) { + log.error("推送金蝶采购申请失败 supplier={}", supplierGroupKey(first), e); + for (ConfirmLine line : lines) { + VmiPurchaseReqLog reqLog = new VmiPurchaseReqLog(); + reqLog.setAlertLogId(line.alert.getId()); + reqLog.setMaterialId(line.material.getId()); + reqLog.setMaterialCode(line.material.getMaterialCode()); + reqLog.setMaterialName(line.material.getMaterialName()); + reqLog.setSupplierCode(line.material.getSupplierCode()); + reqLog.setReqQty(line.qty); + reqLog.setPushStatus("FAIL"); + reqLog.setPushMsg(StrUtil.sub(e.getMessage(), 0, 1900)); + reqMapper.insert(reqLog); + } + throw new ServiceException("供应商【" + nvlStr(first.getSupplierName()) + "】推送金蝶失败:" + e.getMessage()); + } + } + return billCount; + } + + private List resolvePreviewAlertIds(VmiReplenishPreviewBo bo) { + List ids = new ArrayList<>(); + if (bo == null) { + return ids; + } + if (bo.getAlertIds() != null) { + for (Long id : bo.getAlertIds()) { + if (id != null && !ids.contains(id)) { + ids.add(id); + } + } + } + if (bo.getAlertId() != null && !ids.contains(bo.getAlertId())) { + ids.add(bo.getAlertId()); + } + return ids; + } + + /** + * 解析每条触发记录对应的申请数量:优先 lines,其次单条 alertId+reqQty,再次仅 alertIds(数量后算)。 + */ + private Map resolveQtyByAlert(VmiReplenishConfirmBo bo) { + Map map = new LinkedHashMap<>(); + if (bo.getLines() != null) { + for (VmiReplenishLineBo line : bo.getLines()) { + if (line == null || line.getAlertId() == null) { + continue; + } + map.put(line.getAlertId(), line.getReqQty()); + } + } + List ids = resolveAlertIds(bo); + for (Long id : ids) { + map.putIfAbsent(id, null); + } + if (bo.getAlertId() != null && bo.getReqQty() != null && map.containsKey(bo.getAlertId())) { + if (map.get(bo.getAlertId()) == null) { + map.put(bo.getAlertId(), bo.getReqQty()); + } + } + return map; + } + + private List resolveAlertIds(VmiReplenishConfirmBo bo) { + List ids = new ArrayList<>(); + if (bo.getLines() != null) { + for (VmiReplenishLineBo line : bo.getLines()) { + if (line != null && line.getAlertId() != null && !ids.contains(line.getAlertId())) { + ids.add(line.getAlertId()); + } + } + } + if (bo.getAlertIds() != null) { + for (Long id : bo.getAlertIds()) { + if (id != null && !ids.contains(id)) { + ids.add(id); + } + } + } + if (bo.getAlertId() != null && !ids.contains(bo.getAlertId())) { + ids.add(bo.getAlertId()); + } + return ids; + } + + private Map loadMaterialMap(List alerts) { + List materialIds = alerts.stream() + .map(VmiStockAlertLog::getMaterialId) + .filter(Objects::nonNull) + .distinct() + .collect(Collectors.toList()); + if (materialIds.isEmpty()) { + return Collections.emptyMap(); + } + List materials = materialMapper.selectBatchIds(materialIds); + Map map = new LinkedHashMap<>(); + if (materials != null) { + for (VmiMaterial m : materials) { + map.put(m.getId(), m); + } + } + return map; + } + + private String supplierGroupKey(VmiMaterial material) { + if (StringUtils.isNotBlank(material.getSupplierCode())) { + return "C:" + material.getSupplierCode().trim(); + } + if (StringUtils.isNotBlank(material.getSupplierName())) { + return "N:" + material.getSupplierName().trim(); + } + // 未维护供应商时不合并,各自单独一张申请单 + return "M:" + (material.getId() != null ? material.getId() : material.getMaterialCode()); + } + + @Override + public void ignoreAlert(Long alertId) { + VmiStockAlertLog alert = alertMapper.selectById(alertId); + if (alert == null) { + throw new ServiceException("触发记录不存在"); + } + if (!"PENDING".equals(alert.getAlertStatus())) { + throw new ServiceException("该记录已处理"); + } + alert.setAlertStatus("IGNORED"); + alert.setConfirmBy(currentUser()); + alert.setConfirmTime(new Date()); + alertMapper.updateById(alert); + } + + @Override + public void notifyForecast(Long id) { + VmiMaterial material = materialMapper.selectById(id); + if (material == null) { + throw new ServiceException("VMI物料不存在"); + } + if (StringUtils.isBlank(material.getDingUserId())) { + throw new ServiceException("未配置仓储部VMI负责人钉钉userId"); + } + String text = "### VMI需求预测调整\n\n" + + "- 物料:**" + nvlStr(material.getMaterialCode()) + "** " + nvlStr(material.getMaterialName()) + "\n" + + "- 供应商:" + nvlStr(material.getSupplierName()) + "\n" + + "- 预测数量:" + nvl(material.getForecastQty()) + "\n" + + "- 说明:" + nvlStr(material.getForecastRemark()) + "\n\n" + + "请提前告知供应商调整备货量,避免缺料停线或超额囤积。"; + robotGroupService.sendMarkdownToUser(material.getDingUserId(), "VMI需求预测", text); + } + + @Override + public TableDataInfo queryAlertPage(VmiStockAlertLogBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.like(StringUtils.isNotBlank(bo.getMaterialCode()), VmiStockAlertLog::getMaterialCode, bo.getMaterialCode()); + lqw.eq(StringUtils.isNotBlank(bo.getAlertStatus()), VmiStockAlertLog::getAlertStatus, bo.getAlertStatus()); + lqw.orderByDesc(VmiStockAlertLog::getId); + Page page = alertMapper.selectVoPage(pageQuery.build(), lqw); + fillAlertSupplier(page.getRecords()); + return TableDataInfo.build(page); + } + + private void fillAlertSupplier(List list) { + if (list == null || list.isEmpty()) { + return; + } + List materialIds = list.stream() + .map(VmiStockAlertLogVo::getMaterialId) + .filter(Objects::nonNull) + .distinct() + .collect(Collectors.toList()); + if (materialIds.isEmpty()) { + return; + } + List materials = materialMapper.selectBatchIds(materialIds); + Map map = new LinkedHashMap<>(); + if (materials != null) { + for (VmiMaterial m : materials) { + map.put(m.getId(), m); + } + } + for (VmiStockAlertLogVo vo : list) { + VmiMaterial m = map.get(vo.getMaterialId()); + if (m != null) { + vo.setSupplierCode(m.getSupplierCode()); + vo.setSupplierName(m.getSupplierName()); + } + } + } + + @Override + public TableDataInfo queryReqPage(VmiPurchaseReqLogBo bo, PageQuery pageQuery) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.like(StringUtils.isNotBlank(bo.getMaterialCode()), VmiPurchaseReqLog::getMaterialCode, bo.getMaterialCode()); + lqw.eq(StringUtils.isNotBlank(bo.getPushStatus()), VmiPurchaseReqLog::getPushStatus, bo.getPushStatus()); + lqw.like(StringUtils.isNotBlank(bo.getKingdeeBillNo()), VmiPurchaseReqLog::getKingdeeBillNo, bo.getKingdeeBillNo()); + lqw.orderByDesc(VmiPurchaseReqLog::getId); + Page page = reqMapper.selectVoPage(pageQuery.build(), lqw); + return TableDataInfo.build(page); + } + + /** + * 扫描单条:更新库存;若低于最低安全库存且无待确认记录,则落库触发记录(钉钉稍后汇总推送)。 + */ + private AlertHit collectLowStockHit(VmiMaterial item) { + VmiInventoryQuery.Snapshot snap = VmiInventoryQuery.query(item.getMaterialCode()); + item.setLastStockQty(snap.getStockQty()); + item.setLastAvailableQty(snap.getAvailableQty()); + item.setLastCheckTime(new Date()); + boolean low = snap.getStockQty().compareTo(nvl(item.getMinSafeQty())) < 0; + item.setStockStatus(low ? "LOW" : "NORMAL"); + materialMapper.updateById(item); + if (!low) { + return null; + } + Long pending = alertMapper.selectCount( + Wrappers.lambdaQuery() + .eq(VmiStockAlertLog::getMaterialId, item.getId()) + .eq(VmiStockAlertLog::getAlertStatus, "PENDING")); + if (pending != null && pending > 0) { + return null; + } + VmiStockAlertLog alert = new VmiStockAlertLog(); + alert.setMaterialId(item.getId()); + alert.setMaterialCode(item.getMaterialCode()); + alert.setMaterialName(item.getMaterialName()); + alert.setMinSafeQty(item.getMinSafeQty()); + alert.setCurrentQty(snap.getStockQty()); + alert.setAvailableQty(snap.getAvailableQty()); + alert.setAlertStatus("PENDING"); + alert.setDingPushStatus("PENDING"); + alertMapper.insert(alert); + + BigDecimal suggestQty = calcReplenishQty(item, snap.getStockQty()); + return new AlertHit(item, alert, snap, suggestQty); + } + + /** + * 按负责人汇总:同一批低库存物料生成 Excel,钉钉私发文件提醒。 + */ + private void pushAggregatedDingAlerts(List hits) { + Map> groups = new LinkedHashMap<>(); + for (AlertHit hit : hits) { + String receivers = resolveDingReceivers(hit.material); + String key = StringUtils.isBlank(receivers) ? "" : normalizeDingKey(receivers); + groups.computeIfAbsent(key, k -> new ArrayList<>()).add(hit); + } + for (Map.Entry> entry : groups.entrySet()) { + List group = entry.getValue(); + String receivers = entry.getKey(); + if (StringUtils.isBlank(receivers)) { + for (AlertHit hit : group) { + hit.alert.setDingPushStatus("FAIL"); + hit.alert.setDingPushMsg("未配置钉钉userId(物料 dingUserId / vmi.ding-user-ids)"); + alertMapper.updateById(hit.alert); + } + continue; + } + File excelFile = null; + try { + excelFile = buildLowStockExcel(group); + String mediaId = mediaService.uploadMedia("file", excelFile.getAbsolutePath()); + robotGroupService.sendFileToUser(receivers, mediaId, excelFile.getName()); + + String text = "### VMI低库存补货确认\n\n" + + "本次扫描共 **" + group.size() + "** 项即时库存低于建议最低安全库存。\n\n" + + "明细见附件 Excel,请登录系统 **VMI物料安全库存** 页勾选确认补货。\n\n" + + "确认后将按供应商合并生成金蝶采购申请(需人工审批)。"; + robotGroupService.sendMarkdownToUser(receivers, "VMI低库存补货确认", text); + + for (AlertHit hit : group) { + hit.alert.setDingPushStatus("SUCCESS"); + hit.alert.setDingPushMsg("已汇总推送Excel,本批共 " + group.size() + " 条"); + alertMapper.updateById(hit.alert); + } + } catch (Exception e) { + log.error("VMI汇总钉钉Excel推送失败 receivers={} size={}", receivers, group.size(), e); + for (AlertHit hit : group) { + hit.alert.setDingPushStatus("FAIL"); + hit.alert.setDingPushMsg(StrUtil.sub(e.getMessage(), 0, 900)); + alertMapper.updateById(hit.alert); + } + } finally { + if (excelFile != null && excelFile.exists() && !excelFile.delete()) { + log.warn("VMI低库存临时Excel删除失败: {}", excelFile.getAbsolutePath()); + } + } + } + } + + private File buildLowStockExcel(List hits) { + List rows = new ArrayList<>(hits.size()); + for (AlertHit hit : hits) { + VmiMaterial m = hit.material; + VmiLowStockAlertExportVo row = new VmiLowStockAlertExportVo(); + row.setMaterialCode(m.getMaterialCode()); + row.setMaterialName(m.getMaterialName()); + row.setMaterialQuality(m.getMaterialQuality()); + row.setSupplierName(m.getSupplierName()); + row.setCurrentQty(hit.snap.getStockQty()); + row.setAvailableQty(hit.snap.getAvailableQty()); + row.setMinSafeQty(m.getMinSafeQty()); + row.setMaxSafeQty(m.getMaxSafeQty()); + row.setMinPackQty(m.getMinPackQty()); + row.setSuggestQty(hit.suggestQty); + row.setRemark(m.getRemark()); + rows.add(row); + } + String fileName = "VMI低库存补货提醒_" + + new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date()) + + "_" + hits.size() + "条.xlsx"; + File file = new File(System.getProperty("java.io.tmpdir"), fileName); + EasyExcel.write(file, VmiLowStockAlertExportVo.class) + .sheet("低库存明细") + .doWrite(rows); + return file; + } + + private String resolveDingReceivers(VmiMaterial material) { + if (material != null && StringUtils.isNotBlank(material.getDingUserId())) { + return material.getDingUserId().trim(); + } + if (StringUtils.isNotBlank(defaultDingUserIds)) { + return defaultDingUserIds.trim(); + } + return null; + } + + private String normalizeDingKey(String receivers) { + return Arrays.stream(receivers.split("[,,;;\\s]+")) + .map(String::trim) + .filter(StringUtils::isNotBlank) + .distinct() + .sorted() + .collect(Collectors.joining(",")); + } + + private KingdeeSaveResult saveKingdeeRequisition(VmiMaterial supplierSample, List lines) throws Exception { + K3CloudApi api = new K3CloudApi(); + JsonObject json = new JsonObject(); + json.add("NeedUpDateFields", new JsonArray()); + JsonArray needReturn = new JsonArray(); + needReturn.add("FBillNo"); + needReturn.add("FID"); + json.add("NeedReturnFields", needReturn); + json.addProperty("IsDeleteEntry", "true"); + // 必须 true:否则 FStaffNumber 不会被解析,申请人会变成空并报“必填项” + json.addProperty("IsVerifyBaseDataField", "true"); + json.addProperty("IsEntryBatchFill", "true"); + json.addProperty("ValidateFlag", "true"); + json.addProperty("NumberSearch", "true"); + json.addProperty("IsAutoAdjustField", "true"); + // 只保存采购申请,不自动提交、不自动审核,留给金蝶人工审批 + json.addProperty("IsAutoSubmitAndAudit", "false"); + + JsonObject model = new JsonObject(); + json.add("Model", model); + model.addProperty("FID", 0); + + JsonObject billType = new JsonObject(); + billType.addProperty("FNUMBER", "CGSQD01_SYS"); + model.add("FBillTypeID", billType); + + JsonObject org = new JsonObject(); + org.addProperty("FNumber", applicationOrg); + model.add("FApplicationOrgId", org); + + model.addProperty("FApplicationDate", new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date())); + model.addProperty("FRequestType", "Material"); + + // 申请人:本环境须用工号 FNumber(FStaffNumber 无效);且申请部门不能乱填,否则申请人会解析失败 + String applicant = resolveApplicantNumber(); + JsonObject applicantId = new JsonObject(); + applicantId.addProperty("FNumber", applicant); + model.add("FApplicantId", applicantId); + + if (StringUtils.isNotBlank(applicationDept)) { + JsonObject dept = new JsonObject(); + dept.addProperty("FNumber", applicationDept.trim()); + model.add("FApplicationDeptId", dept); + } + + JsonObject currency = new JsonObject(); + currency.addProperty("FNumber", "PRE001"); + model.add("FCurrencyId", currency); + model.addProperty("FISPRICEEXCLUDETAX", true); + + JsonObject exchangeType = new JsonObject(); + exchangeType.addProperty("FNumber", "HLTX01_SYS"); + model.add("FExchangeTypeId", exchangeType); + model.addProperty("FIsConvert", false); + model.addProperty("FACCTYPE", "Q"); + model.addProperty("FIsCreateStraightOutIN", false); + + JsonArray entity = new JsonArray(); + for (ConfirmLine line : lines) { + JsonObject row = new JsonObject(); + JsonObject mat = new JsonObject(); + mat.addProperty("FNumber", line.material.getMaterialCode()); + row.add("FMaterialId", mat); + if (StringUtils.isNotBlank(line.material.getMaterialName())) { + row.addProperty("FMaterialDesc", line.material.getMaterialName()); + } + row.addProperty("FReqQty", line.qty); + row.addProperty("FApproveQty", line.qty); + row.addProperty("FArrivalDate", line.arrivalDate); + row.addProperty("FIsVmiBusiness", true); + if (StringUtils.isNotBlank(line.material.getSupplierCode())) { + JsonObject supplier = new JsonObject(); + supplier.addProperty("FNumber", line.material.getSupplierCode()); + row.add("FSuggestSupplierId", supplier); + } + entity.add(row); + } + model.add("FEntity", entity); + + String payload = json.toString(); + log.info("VMI采购申请保存请求 supplier={} applicant={} payload={}", + supplierGroupKey(supplierSample), applicant, payload); + String result = api.save("PUR_Requisition", payload); + log.info("VMI采购申请保存结果 supplier={} applicant={} lines={} result={}", + supplierGroupKey(supplierSample), applicant, lines.size(), result); + JsonObject root = new JsonParser().parse(result).getAsJsonObject(); + JsonObject resp = root.getAsJsonObject("Result").getAsJsonObject("ResponseStatus"); + boolean ok = resp.get("IsSuccess").getAsBoolean(); + if (!ok) { + throw new ServiceException(resp.toString()); + } + KingdeeSaveResult out = new KingdeeSaveResult(); + if (root.getAsJsonObject("Result").has("Number")) { + out.billNo = root.getAsJsonObject("Result").get("Number").getAsString(); + } + if (root.getAsJsonObject("Result").has("Id")) { + out.fid = root.getAsJsonObject("Result").get("Id").getAsString(); + } + if (resp.has("SuccessEntitys") && resp.getAsJsonArray("SuccessEntitys").size() > 0) { + JsonObject ent = resp.getAsJsonArray("SuccessEntitys").get(0).getAsJsonObject(); + if (ent.has("Number")) { + out.billNo = ent.get("Number").getAsString(); + } + if (ent.has("Id")) { + out.fid = ent.get("Id").getAsString(); + } + } + return out; + } + + /** + * 申请人取当前登录用户的员工编号(sys_user.employee_number,对应金蝶员工工号)。 + */ + private String resolveApplicantNumber() { + Long userId; + String username; + try { + userId = LoginHelper.getUserId(); + username = LoginHelper.getUsername(); + } catch (Exception e) { + throw new ServiceException("未登录,无法确定金蝶申请人"); + } + if (userId == null) { + throw new ServiceException("未登录,无法确定金蝶申请人"); + } + SysUser user = sysUserMapper.selectUserById(userId); + if (user == null) { + throw new ServiceException("当前用户不存在,无法确定金蝶申请人"); + } + if (StringUtils.isBlank(user.getEmployeeNumber())) { + throw new ServiceException("当前用户【" + + (StringUtils.isNotBlank(user.getNickName()) ? user.getNickName() : username) + + "】未维护员工编号,请在用户管理中填写金蝶员工工号后再确认补货"); + } + return user.getEmployeeNumber().trim(); + } + + /** + * 建议补货量 = floor((建议最高安全库存 - 即时库存) / 最小包装量) * 最小包装量。 + * 缺口大于 0 但不足一个最小包装量时,按 1 个最小包装量补。 + * 未维护最小包装量时按缺口原值返回。 + */ + private BigDecimal calcReplenishQty(VmiMaterial material, BigDecimal stockQty) { + BigDecimal gap = nvl(material.getMaxSafeQty()).subtract(nvl(stockQty)); + if (gap.compareTo(BigDecimal.ZERO) <= 0) { + return BigDecimal.ZERO; + } + BigDecimal pack = nvl(material.getMinPackQty()); + if (pack.compareTo(BigDecimal.ZERO) <= 0) { + return gap; + } + BigDecimal packs = gap.divide(pack, 0, RoundingMode.DOWN); + if (packs.compareTo(BigDecimal.ZERO) <= 0) { + return pack; + } + return packs.multiply(pack); + } + + private void validCode(String code, Long id) { + Long cnt = materialMapper.selectCount( + Wrappers.lambdaQuery() + .eq(VmiMaterial::getMaterialCode, code) + .ne(id != null, VmiMaterial::getId, id)); + if (cnt != null && cnt > 0) { + throw new ServiceException("物料编码已存在"); + } + } + + private LambdaQueryWrapper buildMaterialWrapper(VmiMaterialBo bo) { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.like(StringUtils.isNotBlank(bo.getMaterialCode()), VmiMaterial::getMaterialCode, bo.getMaterialCode()); + lqw.like(StringUtils.isNotBlank(bo.getMaterialName()), VmiMaterial::getMaterialName, bo.getMaterialName()); + lqw.like(StringUtils.isNotBlank(bo.getMaterialQuality()), VmiMaterial::getMaterialQuality, bo.getMaterialQuality()); + lqw.like(StringUtils.isNotBlank(bo.getSupplierName()), VmiMaterial::getSupplierName, bo.getSupplierName()); + lqw.eq(StringUtils.isNotBlank(bo.getEnabled()), VmiMaterial::getEnabled, bo.getEnabled()); + lqw.eq(StringUtils.isNotBlank(bo.getStockStatus()), VmiMaterial::getStockStatus, bo.getStockStatus()); + lqw.orderByDesc(VmiMaterial::getId); + return lqw; + } + + /** + * 新增 VMI 物料时按外购建档推送金蝶,复用 BOM 明细的子项物料新增。 + * 金蝶已有相同编码和名称时不再重复建档。 + */ + private void pushNewMaterialToKingdee(VmiMaterial material) { + String code = material.getMaterialCode() == null ? "" : material.getMaterialCode().trim(); + String name = material.getMaterialName() == null ? "" : material.getMaterialName().trim(); + if (StringUtils.isBlank(name)) { + throw new ServiceException("物料名称不能为空,无法推送金蝶"); + } + int exists = BomDetailsController.isMaterialVerification(code, name); + if (exists == 1) { + log.info("金蝶已存在相同物料,跳过建档 code={} name={}", code, name); + return; + } + if (exists == 3) { + throw new ServiceException("金蝶已有物料编码「" + code + "」,但名称与「" + name + "」不一致,请核对后再新增"); + } + BomDetails details = new BomDetails(); + details.setPartNumber(code); + details.setName(name); + details.setMaterial(StringUtils.defaultString(material.getMaterialQuality())); + details.setStats("外购"); + int result = bomDetailsController.loadMaterialPreservation(details, "1", 0.0); + if (result != 1) { + throw new ServiceException("推送金蝶物料失败:" + code + ",请检查编码是否已存在或必填项是否完整"); + } + log.info("VMI物料已推送金蝶 code={} name={}", code, name); + } + + private String resolveArrivalDate(VmiReplenishConfirmBo bo, Long alertId) { + if (bo.getLines() == null || alertId == null) { + return null; + } + for (VmiReplenishLineBo line : bo.getLines()) { + if (line != null && alertId.equals(line.getAlertId())) { + return normalizeArrivalDate(line.getArrivalDate()); + } + } + return null; + } + + private static String normalizeArrivalDate(String raw) { + if (StringUtils.isBlank(raw)) { + return null; + } + String value = raw.trim().replace('T', ' '); + if (value.length() >= 19) { + return value.substring(0, 19); + } + if (value.length() >= 10) { + return value.substring(0, 10) + " 00:00:00"; + } + return null; + } + + private static BigDecimal nvl(BigDecimal v) { + return v == null ? BigDecimal.ZERO : v; + } + + private static String nvlStr(String v) { + return v == null ? "" : v; + } + + private String currentUser() { + try { + return LoginHelper.getUsername(); + } catch (Exception e) { + return "system"; + } + } + + private static class KingdeeSaveResult { + private String billNo; + private String fid; + } + + private static class AlertHit { + private final VmiMaterial material; + private final VmiStockAlertLog alert; + private final VmiInventoryQuery.Snapshot snap; + private final BigDecimal suggestQty; + + private AlertHit(VmiMaterial material, VmiStockAlertLog alert, + VmiInventoryQuery.Snapshot snap, BigDecimal suggestQty) { + this.material = material; + this.alert = alert; + this.snap = snap; + this.suggestQty = suggestQty; + } + } + + private static class ConfirmLine { + private final VmiStockAlertLog alert; + private final VmiMaterial material; + private final BigDecimal qty; + private final String arrivalDate; + + private ConfirmLine(VmiStockAlertLog alert, VmiMaterial material, BigDecimal qty, String arrivalDate) { + this.alert = alert; + this.material = material; + this.qty = qty; + this.arrivalDate = arrivalDate; + } + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/WorkAlertGroupPushConfigServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/WorkAlertGroupPushConfigServiceImpl.java new file mode 100644 index 0000000..d7b1d92 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/WorkAlertGroupPushConfigServiceImpl.java @@ -0,0 +1,138 @@ +package com.ruoyi.system.service.impl; + +import cn.hutool.core.bean.BeanUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.system.domain.WorkAlertGroupPushConfig; +import com.ruoyi.system.domain.bo.WorkAlertGroupPushConfigBo; +import com.ruoyi.system.domain.vo.WorkAlertGroupPushConfigVo; +import com.ruoyi.system.mapper.WorkAlertGroupPushConfigMapper; +import com.ruoyi.system.service.IWorkAlertGroupPushConfigService; +import com.ruoyi.system.service.WorkAlertPushScheduleService; +import com.ruoyi.system.service.support.WorkAlertPushTypeRegistry; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 指定群预警定时推送配置 + */ +@RequiredArgsConstructor +@Service +public class WorkAlertGroupPushConfigServiceImpl implements IWorkAlertGroupPushConfigService { + + private final WorkAlertGroupPushConfigMapper baseMapper; + private final WorkAlertPushScheduleService workAlertPushScheduleService; + + @Override + public List queryList() { + LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); + lqw.orderByAsc(WorkAlertGroupPushConfig::getPushTime).orderByAsc(WorkAlertGroupPushConfig::getId); + List list = baseMapper.selectVoList(lqw); + if (list == null) { + return new ArrayList<>(); + } + list.forEach(this::fillPushCodeNames); + return list; + } + + @Override + public WorkAlertGroupPushConfigVo queryById(Long id) { + WorkAlertGroupPushConfigVo vo = baseMapper.selectVoById(id); + fillPushCodeNames(vo); + return vo; + } + + @Override + public Boolean insertByBo(WorkAlertGroupPushConfigBo bo) { + WorkAlertGroupPushConfig add = BeanUtil.toBean(bo, WorkAlertGroupPushConfig.class); + validEntityBeforeSave(add); + fillDefaultName(add); + return baseMapper.insert(add) > 0; + } + + @Override + public Boolean updateByBo(WorkAlertGroupPushConfigBo bo) { + WorkAlertGroupPushConfig update = BeanUtil.toBean(bo, WorkAlertGroupPushConfig.class); + validEntityBeforeSave(update); + fillDefaultName(update); + return baseMapper.updateById(update) > 0; + } + + @Override + public Boolean deleteWithValidByIds(Collection ids) { + return baseMapper.deleteBatchIds(ids) > 0; + } + + @Override + public void trigger(Long id) { + WorkAlertGroupPushConfig config = baseMapper.selectById(id); + if (config == null) { + throw new ServiceException("定时配置不存在"); + } + workAlertPushScheduleService.executeGroupSchedule(config, true); + } + + private void validEntityBeforeSave(WorkAlertGroupPushConfig entity) { + if (entity == null) { + throw new ServiceException("配置为空"); + } + List codes = parsePushCodes(entity.getPushCodes()); + if (codes.isEmpty()) { + throw new ServiceException("请至少选择一个推送任务"); + } + for (String code : codes) { + if (!WorkAlertPushTypeRegistry.supportsGroupConversation(code)) { + throw new ServiceException("不支持指定群的推送任务:" + code); + } + } + entity.setPushCodes(String.join(",", codes)); + if (StringUtils.isNotBlank(entity.getOpenConversationId())) { + entity.setOpenConversationId(entity.getOpenConversationId().trim()); + } + if (StringUtils.isNotBlank(entity.getRobotCode())) { + entity.setRobotCode(entity.getRobotCode().trim()); + } + if (StringUtils.isBlank(entity.getEnabled())) { + entity.setEnabled("0"); + } + } + + private void fillDefaultName(WorkAlertGroupPushConfig entity) { + if (entity == null || StringUtils.isNotBlank(entity.getConfigName())) { + return; + } + String time = StringUtils.blankToDefault(entity.getPushTime(), ""); + entity.setConfigName("群定时 " + time); + } + + private void fillPushCodeNames(WorkAlertGroupPushConfigVo vo) { + if (vo == null) { + return; + } + List names = new ArrayList<>(); + for (String code : parsePushCodes(vo.getPushCodes())) { + WorkAlertPushTypeRegistry.PushType type = WorkAlertPushTypeRegistry.find(code); + names.add(type == null ? code : type.getName()); + } + vo.setPushCodeNames(String.join("、", names)); + } + + static List parsePushCodes(String raw) { + if (StringUtils.isBlank(raw)) { + return new ArrayList<>(); + } + return Arrays.stream(raw.split("[,,;;\\s]+")) + .map(String::trim) + .filter(StringUtils::isNotBlank) + .distinct() + .collect(Collectors.toList()); + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/support/RatioBomExcelParser.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/support/RatioBomExcelParser.java new file mode 100644 index 0000000..a17c4f7 --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/support/RatioBomExcelParser.java @@ -0,0 +1,343 @@ +package com.ruoyi.system.service.support; + +import com.ruoyi.common.exception.ServiceException; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.system.domain.dto.ratio.RatioBomParseResult; +import com.ruoyi.system.domain.dto.ratio.RatioBomParseResult.LineBom; +import com.ruoyi.system.domain.dto.ratio.RatioBomParseResult.ModelBom; +import com.ruoyi.system.domain.dto.ratio.RatioBomParseResult.SheetBom; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.CellType; +import org.apache.poi.ss.usermodel.CellValue; +import org.apache.poi.ss.usermodel.DateUtil; +import org.apache.poi.ss.usermodel.FormulaEvaluator; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.ss.usermodel.WorkbookFactory; +import org.apache.poi.ss.util.CellRangeAddress; +import org.springframework.stereotype.Component; + +import java.io.InputStream; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 速比 BOM Excel。 + * 工作簿里的每个 Sheet 都会读取。Sheet 名是速比值,按字符串保存,例如 91B。 + * 表头是含「减速机型号」或「减速器型号」的那一行,数据从下一行开始。标准表是第 1 行标题、第 2 行表头、第 3 行起为数据。 + * 型号列前 4 列是该速比的通用件。型号右侧按表头「数量」分组,编码列认「编码」或「编号」,中间空列不影响。 + * 通用件和型号件虽然写在同一行,但是两份独立清单,不按行互相配对。 + */ +@Component +public class RatioBomExcelParser { + + public RatioBomParseResult parse(InputStream in) { + try (Workbook workbook = WorkbookFactory.create(in)) { + FormulaEvaluator evaluator = workbook.getCreationHelper().createFormulaEvaluator(); + RatioBomParseResult result = new RatioBomParseResult(); + List ratioKeys = new ArrayList<>(); + for (int i = 0; i < workbook.getNumberOfSheets(); i++) { + String sheetName = workbook.getSheetName(i) == null ? "" : workbook.getSheetName(i).trim(); + if (StringUtils.isBlank(sheetName)) { + result.getSkippedSheets().add("(Sheet名为空)"); + continue; + } + if (sheetName.length() > 64) { + throw new ServiceException("Sheet「" + sheetName + "」速比值超过64个字符"); + } + Sheet sheet = workbook.getSheetAt(i); + int headerRow = findHeaderRow(sheet, evaluator); + if (headerRow < 0) { + result.getSkippedSheets().add(sheetName + "(没有减速机型号或减速器型号表头)"); + continue; + } + String key = sheetName.toLowerCase(); + if (ratioKeys.contains(key)) { + throw new ServiceException("速比 " + sheetName + " 出现在多个 Sheet"); + } + SheetBom bom = parseSheet(sheet, sheetName, evaluator, headerRow); + if (bom.getCommonParts().isEmpty() && bom.getModels().isEmpty()) { + result.getSkippedSheets().add(sheetName + "(没有数据)"); + continue; + } + ratioKeys.add(key); + result.getSheets().add(bom); + } + if (result.getSheets().isEmpty()) { + throw new ServiceException("没有可导入的速比 Sheet,Sheet 名就是速比值,例如 91B"); + } + return result; + } catch (ServiceException e) { + throw e; + } catch (Exception e) { + throw new ServiceException("读取 Excel 失败:" + e.getMessage()); + } + } + + private SheetBom parseSheet(Sheet sheet, String ratio, FormulaEvaluator evaluator, int headerRow) { + int modelCol = findModelColumn(sheet, headerRow, evaluator); + if (modelCol < 4) { + throw new ServiceException("Sheet「" + sheet.getSheetName() + "」减速机型号列之前需要物料编码、物料名称、材料、数量四列"); + } + List groups = readPartGroups(sheet, headerRow, modelCol, evaluator); + SheetBom bom = new SheetBom(); + bom.setSheetName(sheet.getSheetName()); + bom.setRatioValue(ratio); + Map models = new LinkedHashMap<>(); + // A-D 固定:物料编码、物料名称、材料、数量 + int codeCol = 0; + for (int r = headerRow + 1; r <= sheet.getLastRowNum(); r++) { + String commonCode = cellText(sheet, r, codeCol, evaluator); + String modelCode = cellText(sheet, r, modelCol, evaluator); + boolean hasPart = false; + for (PartGroup group : groups) { + if (StringUtils.isNotBlank(cellText(sheet, r, group.codeCol, evaluator))) { + hasPart = true; + break; + } + } + if (StringUtils.isBlank(commonCode) && StringUtils.isBlank(modelCode) && !hasPart) { + continue; + } + int excelRow = r + 1; + if (StringUtils.isNotBlank(commonCode)) { + bom.getCommonParts().add(readLine(sheet, r, excelRow, evaluator, "通用件", codeCol, "通用件")); + } + if (StringUtils.isBlank(modelCode)) { + continue; + } + ModelBom model = models.get(modelCode); + if (model == null) { + model = new ModelBom(); + model.setModelCode(modelCode); + models.put(modelCode, model); + } + for (PartGroup group : groups) { + if (StringUtils.isBlank(cellText(sheet, r, group.codeCol, evaluator))) { + continue; + } + model.getParts().add(readGroupLine(sheet, r, excelRow, evaluator, group)); + } + } + bom.setModels(new ArrayList<>(models.values())); + return bom; + } + + private LineBom readLine(Sheet sheet, int rowIndex, int excelRow, FormulaEvaluator evaluator, + String category, int codeCol, String qtyLabel) { + LineBom line = new LineBom(); + line.setExcelRow(excelRow); + line.setPartCategory(category); + line.setMaterialCode(cellText(sheet, rowIndex, codeCol, evaluator)); + line.setMaterialName(cellText(sheet, rowIndex, codeCol + 1, evaluator)); + line.setMaterial(cellText(sheet, rowIndex, codeCol + 2, evaluator)); + line.setBomQty(cellQty(sheet, rowIndex, codeCol + 3, evaluator, sheet.getSheetName(), excelRow, qtyLabel)); + return line; + } + + private LineBom readGroupLine(Sheet sheet, int rowIndex, int excelRow, FormulaEvaluator evaluator, PartGroup group) { + LineBom line = new LineBom(); + line.setExcelRow(excelRow); + line.setPartCategory(group.category); + line.setMaterialCode(cellText(sheet, rowIndex, group.codeCol, evaluator)); + line.setMaterialName(cellText(sheet, rowIndex, group.nameCol, evaluator)); + line.setMaterial(cellText(sheet, rowIndex, group.materialCol, evaluator)); + line.setBomQty(cellQty(sheet, rowIndex, group.qtyCol, evaluator, sheet.getSheetName(), excelRow, group.category)); + return line; + } + + /** + * 型号右侧每一组都是「物料编码、名称、材料、数量」。 + * 以表头「数量」定位该组,避免把材料列当成数量。 + */ + private List readPartGroups(Sheet sheet, int headerRow, int modelCol, FormulaEvaluator evaluator) { + Row row = sheet.getRow(headerRow); + int lastCol = row == null ? 0 : row.getLastCellNum(); + List groups = new ArrayList<>(); + for (int qtyCol = modelCol + 1; qtyCol < lastCol; qtyCol++) { + if (!"数量".equals(normalizeHeader(cellText(sheet, headerRow, qtyCol, evaluator)))) { + continue; + } + int materialCol = qtyCol - 1; + int codeCol = findCodeColumn(sheet, headerRow, evaluator, modelCol, materialCol); + if (codeCol < 0) { + continue; + } + String category = categoryOf(cellText(sheet, headerRow, codeCol, evaluator)); + if (StringUtils.isBlank(category)) { + category = "专用件"; + } + int nameCol = codeCol + 1; + if (nameCol >= materialCol) { + nameCol = codeCol; + } + groups.add(new PartGroup(codeCol, nameCol, materialCol, qtyCol, category)); + } + return groups; + } + + /** 数量列左边最近的「编码」或「编号」列,避开名称和材料之间的空列。 */ + private int findCodeColumn(Sheet sheet, int headerRow, FormulaEvaluator evaluator, int modelCol, int materialCol) { + for (int c = materialCol - 1; c > modelCol; c--) { + String header = normalizeHeader(cellText(sheet, headerRow, c, evaluator)); + if (header.contains("编码") || header.contains("编号")) { + return c; + } + } + return -1; + } + + private String categoryOf(String header) { + String text = normalizeHeader(header); + if (text.endsWith("物料编号")) { + text = text.substring(0, text.length() - 4); + } else if (text.endsWith("物料编码")) { + text = text.substring(0, text.length() - 4); + } else if (text.endsWith("编号")) { + text = text.substring(0, text.length() - 2); + } else if (text.endsWith("编码")) { + text = text.substring(0, text.length() - 2); + } + return text; + } + + private String normalizeHeader(String header) { + if (header == null) { + return ""; + } + return header.replace(" ", "").replace("\u3000", "").replace("\n", "").replace("\r", ""); + } + + /** 在前几行里找型号表头。文件里有的 Sheet 写成「减速器型号」。 */ + private int findHeaderRow(Sheet sheet, FormulaEvaluator evaluator) { + int last = Math.min(sheet.getLastRowNum(), 5); + for (int r = 0; r <= last; r++) { + if (findModelColumn(sheet, r, evaluator) >= 0) { + return r; + } + } + return -1; + } + + private int findModelColumn(Sheet sheet, int rowIndex, FormulaEvaluator evaluator) { + Row row = sheet.getRow(rowIndex); + if (row == null) { + return -1; + } + short lastCell = row.getLastCellNum(); + for (int c = 0; c < lastCell; c++) { + if (isModelHeader(cellText(sheet, rowIndex, c, evaluator))) { + return c; + } + } + return -1; + } + + private boolean isModelHeader(String header) { + String text = normalizeHeader(header); + return text.contains("减速机型号") || text.contains("减速器型号"); + } + + private BigDecimal cellQty(Sheet sheet, int rowIndex, int colIndex, FormulaEvaluator evaluator, + String sheetName, int excelRow, String label) { + String text = cellText(sheet, rowIndex, colIndex, evaluator); + if (StringUtils.isBlank(text)) { + return BigDecimal.ONE.setScale(4, RoundingMode.UNNECESSARY); + } + try { + BigDecimal qty = new BigDecimal(text); + if (qty.compareTo(BigDecimal.ZERO) <= 0) { + throw new ServiceException("Sheet「" + sheetName + "」第" + excelRow + "行" + label + "数量必须大于 0"); + } + return qty.setScale(4, RoundingMode.HALF_UP); + } catch (NumberFormatException e) { + throw new ServiceException("Sheet「" + sheetName + "」第" + excelRow + "行" + label + "数量不是数字:" + text); + } + } + + private String cellText(Sheet sheet, int rowIndex, int colIndex, FormulaEvaluator evaluator) { + Row row = sheet.getRow(rowIndex); + if (row == null) { + return ""; + } + Cell cell = row.getCell(colIndex, Row.MissingCellPolicy.RETURN_BLANK_AS_NULL); + if (cell == null) { + return ""; + } + cell = mergedTopLeft(sheet, cell); + CellType type = cell.getCellType(); + if (type == CellType.FORMULA) { + CellValue value = evaluator.evaluate(cell); + if (value == null) { + return ""; + } + if (value.getCellType() == CellType.STRING) { + return value.getStringValue() == null ? "" : value.getStringValue().trim(); + } + if (value.getCellType() == CellType.NUMERIC) { + return plainNumber(value.getNumberValue()); + } + if (value.getCellType() == CellType.BOOLEAN) { + return String.valueOf(value.getBooleanValue()); + } + return ""; + } + if (type == CellType.STRING) { + return cell.getStringCellValue() == null ? "" : cell.getStringCellValue().trim(); + } + if (type == CellType.NUMERIC) { + if (DateUtil.isCellDateFormatted(cell)) { + return ""; + } + return plainNumber(cell.getNumericCellValue()); + } + if (type == CellType.BOOLEAN) { + return String.valueOf(cell.getBooleanCellValue()); + } + return ""; + } + + private String plainNumber(double value) { + if (Double.isNaN(value) || Double.isInfinite(value)) { + return ""; + } + return BigDecimal.valueOf(value).stripTrailingZeros().toPlainString(); + } + + private Cell mergedTopLeft(Sheet sheet, Cell cell) { + int row = cell.getRowIndex(); + int col = cell.getColumnIndex(); + for (CellRangeAddress region : sheet.getMergedRegions()) { + if (region.isInRange(row, col)) { + Row top = sheet.getRow(region.getFirstRow()); + if (top == null) { + return cell; + } + Cell topCell = top.getCell(region.getFirstColumn()); + return topCell == null ? cell : topCell; + } + } + return cell; + } + + private static final class PartGroup { + private final int codeCol; + private final int nameCol; + private final int materialCol; + private final int qtyCol; + private final String category; + + private PartGroup(int codeCol, int nameCol, int materialCol, int qtyCol, String category) { + this.codeCol = codeCol; + this.nameCol = nameCol; + this.materialCol = materialCol; + this.qtyCol = qtyCol; + this.category = category; + } + } +} diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/support/VmiInventoryQuery.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/support/VmiInventoryQuery.java new file mode 100644 index 0000000..5be4c9d --- /dev/null +++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/support/VmiInventoryQuery.java @@ -0,0 +1,63 @@ +package com.ruoyi.system.service.support; + +import com.ruoyi.system.domain.dto.JDInventoryDTO; +import com.ruoyi.system.domain.dto.JDProductionDTO; +import com.ruoyi.system.domain.dto.ProMoDTO; +import com.ruoyi.system.domain.dto.PurchaseOrderDTO; +import com.ruoyi.system.domain.dto.SalesOrderDTO; +import com.ruoyi.system.runner.JdUtil; +import lombok.Data; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.Optional; + +/** + * 复用首页库存查询口径:即时库存、预计可用。 + */ +public final class VmiInventoryQuery { + + private VmiInventoryQuery() { + } + + @Data + public static class Snapshot { + private BigDecimal stockQty; + private BigDecimal availableQty; + private BigDecimal noPickedQty; + private BigDecimal productionQty; + private BigDecimal purchaseQty; + private BigDecimal salesQty; + } + + public static Snapshot query(String materialCode) { + Snapshot snap = new Snapshot(); + double inventoryQty = Optional.ofNullable(JdUtil.getKuCun(materialCode)) + .map(list -> list.stream().mapToDouble(JDInventoryDTO::getFBaseQty).sum()) + .orElse(0.0); + double fNoPickedQty = Optional.ofNullable(JdUtil.getWeiLingliao(materialCode)) + .map(list -> list.stream().mapToDouble(JDProductionDTO::getFNoPickedQty).sum()) + .orElse(0.0); + double productionQty = Optional.ofNullable(JdUtil.getProMoList(materialCode)) + .map(list -> list.stream().mapToDouble(ProMoDTO::getFQty).sum()) + .orElse(0.0); + double purchaseQty = Optional.ofNullable(JdUtil.getPurchaseOrderList(materialCode)) + .map(list -> list.stream().mapToDouble(PurchaseOrderDTO::getFRemainStockINQty).sum()) + .orElse(0.0); + double salesOrder = Optional.ofNullable(JdUtil.getSalesOrderList(materialCode)) + .map(list -> list.stream().mapToDouble(SalesOrderDTO::getFBaseRemainOutQty).sum()) + .orElse(0.0); + double keyong = inventoryQty + productionQty + purchaseQty - fNoPickedQty - salesOrder; + snap.setStockQty(bd(inventoryQty)); + snap.setAvailableQty(bd(keyong)); + snap.setNoPickedQty(bd(fNoPickedQty)); + snap.setProductionQty(bd(productionQty)); + snap.setPurchaseQty(bd(purchaseQty)); + snap.setSalesQty(bd(salesOrder)); + return snap; + } + + private static BigDecimal bd(double v) { + return BigDecimal.valueOf(v).setScale(4, RoundingMode.HALF_UP); + } +} diff --git a/ruoyi-system/src/main/resources/mapper/system/OrderProProgressReportMapper.xml b/ruoyi-system/src/main/resources/mapper/system/OrderProProgressReportMapper.xml new file mode 100644 index 0000000..f500c57 --- /dev/null +++ b/ruoyi-system/src/main/resources/mapper/system/OrderProProgressReportMapper.xml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + diff --git a/ruoyi-system/src/test/java/com/ruoyi/system/service/impl/ProcessOrderProDwgFileNameTest.java b/ruoyi-system/src/test/java/com/ruoyi/system/service/impl/ProcessOrderProDwgFileNameTest.java new file mode 100644 index 0000000..d23ddd5 --- /dev/null +++ b/ruoyi-system/src/test/java/com/ruoyi/system/service/impl/ProcessOrderProDwgFileNameTest.java @@ -0,0 +1,37 @@ +package com.ruoyi.system.service.impl; + +import org.junit.Assert; +import org.junit.Test; + +public class ProcessOrderProDwgFileNameTest { + + @Test + public void matchExactFigureNumber() { + Assert.assertTrue(ProcessOrderProServiceImpl.matchesDrawingDwgFileName( + "FW26070D.00.dwg", "FW26070D.00")); + Assert.assertTrue(ProcessOrderProServiceImpl.matchesDrawingDwgFileName( + "fw26070d.00.DWG", "FW26070D.00")); + } + + @Test + public void matchCadSequencePrefix() { + Assert.assertTrue(ProcessOrderProServiceImpl.matchesDrawingDwgFileName( + "1-FW26070D.00.dwg", "FW26070D.00")); + } + + @Test + public void matchSlashInFigureNumber() { + Assert.assertTrue(ProcessOrderProServiceImpl.matchesDrawingDwgFileName( + "5-80R.13.0-1440--D.dwg", "80R.13.0/1440/D")); + Assert.assertTrue(ProcessOrderProServiceImpl.matchesDrawingDwgFileName( + "80R.13.0-1440-D.dwg", "80R.13.0/1440/D")); + } + + @Test + public void rejectOtherFigure() { + Assert.assertFalse(ProcessOrderProServiceImpl.matchesDrawingDwgFileName( + "FW26070D.01.dwg", "FW26070D.00")); + Assert.assertFalse(ProcessOrderProServiceImpl.matchesDrawingDwgFileName( + "FW26070D.00.pdf", "FW26070D.00")); + } +} diff --git a/ruoyi-system/src/test/java/com/ruoyi/system/service/support/RatioBomExcelParserTest.java b/ruoyi-system/src/test/java/com/ruoyi/system/service/support/RatioBomExcelParserTest.java new file mode 100644 index 0000000..817ad2d --- /dev/null +++ b/ruoyi-system/src/test/java/com/ruoyi/system/service/support/RatioBomExcelParserTest.java @@ -0,0 +1,173 @@ +package com.ruoyi.system.service.support; + +import com.ruoyi.system.domain.dto.ratio.RatioBomParseResult; +import com.ruoyi.system.domain.dto.ratio.RatioBomParseResult.ModelBom; +import com.ruoyi.system.domain.dto.ratio.RatioBomParseResult.SheetBom; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.junit.Assert; +import org.junit.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.math.BigDecimal; + +public class RatioBomExcelParserTest { + + private final RatioBomExcelParser parser = new RatioBomExcelParser(); + + @Test + public void commonPartsStayIndependentFromModels() throws Exception { + XSSFWorkbook workbook = new XSSFWorkbook(); + Sheet sheet = workbook.createSheet("91B"); + writeHeader(sheet); + Row row1 = sheet.createRow(2); + row1.createCell(0).setCellValue("08E.11(M)"); + row1.createCell(1).setCellValue("KP08E箱体"); + row1.createCell(2).setCellValue("6061-T6"); + row1.createCell(3).setCellValue(1); + row1.createCell(4).setCellValue("KP08E-118-100A-28-R"); + row1.createCell(5).setCellValue("EP08-2T-24B-4238"); + row1.createCell(6).setCellValue("KP08输入轴套24B-4238"); + row1.createCell(7).setCellValue(45); + row1.createCell(9).setCellValue("KP08D-XXJ-28A"); + row1.createCell(10).setCellValue("KP08D行星架28A"); + row1.createCell(11).setCellValue("45"); + row1.createCell(12).setCellValue(1); + row1.createCell(13).setCellValue("KP08-PL-130B"); + row1.createCell(14).setCellValue("KP08电机法兰130B"); + row1.createCell(15).setCellValue("Q235-B"); + row1.createCell(16).setCellValue(1); + + Row row2 = sheet.createRow(3); + row2.createCell(0).setCellValue("08E.12"); + row2.createCell(1).setCellValue("KP08D行星片"); + row2.createCell(2).setCellValue("20CrMnTi"); + row2.createCell(3).setCellValue(6); + + Row row3 = sheet.createRow(4); + row3.createCell(4).setCellValue("KP08E-118-130B-28-R"); + row3.createCell(5).setCellValue("EP08-2T-28B-4338"); + row3.createCell(6).setCellValue("KP08输入轴套28B-4338"); + row3.createCell(7).setCellValue("45"); + row3.createCell(8).setCellValue(2); + + workbook.createSheet("说明"); + + RatioBomParseResult result = parser.parse(toStream(workbook)); + Assert.assertEquals(1, result.getSheets().size()); + Assert.assertEquals(1, result.getSkippedSheets().size()); + + SheetBom bom = result.getSheets().get(0); + Assert.assertEquals("91B", bom.getRatioValue()); + Assert.assertEquals(2, bom.getCommonParts().size()); + Assert.assertEquals("08E.11(M)", bom.getCommonParts().get(0).getMaterialCode()); + Assert.assertEquals("通用件", bom.getCommonParts().get(0).getPartCategory()); + Assert.assertEquals(new BigDecimal("6.0000"), bom.getCommonParts().get(1).getBomQty()); + + Assert.assertEquals(2, bom.getModels().size()); + ModelBom first = bom.getModels().get(0); + Assert.assertEquals("KP08E-118-100A-28-R", first.getModelCode()); + Assert.assertEquals(3, first.getParts().size()); + Assert.assertEquals("轴套", first.getParts().get(0).getPartCategory()); + Assert.assertEquals("45", first.getParts().get(0).getMaterial()); + Assert.assertEquals(new BigDecimal("1.0000"), first.getParts().get(0).getBomQty()); + Assert.assertEquals("行星架", first.getParts().get(1).getPartCategory()); + Assert.assertEquals("法兰", first.getParts().get(2).getPartCategory()); + Assert.assertEquals("Q235-B", first.getParts().get(2).getMaterial()); + Assert.assertEquals(new BigDecimal("1.0000"), first.getParts().get(2).getBomQty()); + + ModelBom second = bom.getModels().get(1); + Assert.assertEquals(1, second.getParts().size()); + Assert.assertEquals("轴套", second.getParts().get(0).getPartCategory()); + Assert.assertEquals(new BigDecimal("2.0000"), second.getParts().get(0).getBomQty()); + workbook.close(); + } + + @Test + public void reducerHeaderAndBlankColumnStillReadCode() throws Exception { + XSSFWorkbook workbook = new XSSFWorkbook(); + Sheet sheet = workbook.createSheet("160A"); + Row title = sheet.createRow(0); + title.createCell(0).setCellValue("减速机通用件"); + Row header = sheet.createRow(1); + header.createCell(0).setCellValue("物料编码"); + header.createCell(1).setCellValue("物料名称"); + header.createCell(2).setCellValue("材料"); + header.createCell(3).setCellValue("数量"); + header.createCell(4).setCellValue("减速器型号"); + header.createCell(5).setCellValue("轴套物料编号"); + header.createCell(6).setCellValue("轴套名称"); + header.createCell(8).setCellValue("材料"); + header.createCell(9).setCellValue("数量"); + Row data = sheet.createRow(2); + data.createCell(0).setCellValue("KP08E.11(M)"); + data.createCell(4).setCellValue("KP08E-160A-100A-28-R"); + data.createCell(5).setCellValue("KP08-ZT-24A-42X8"); + data.createCell(6).setCellValue("KP08输入轴套24A-42X8"); + data.createCell(8).setCellValue(45); + data.createCell(9).setCellValue(1); + + RatioBomParseResult result = parser.parse(toStream(workbook)); + Assert.assertEquals(0, result.getSkippedSheets().size()); + SheetBom bom = result.getSheets().get(0); + Assert.assertEquals("160A", bom.getRatioValue()); + Assert.assertEquals(1, bom.getModels().size()); + Assert.assertEquals("轴套", bom.getModels().get(0).getParts().get(0).getPartCategory()); + Assert.assertEquals("KP08-ZT-24A-42X8", bom.getModels().get(0).getParts().get(0).getMaterialCode()); + Assert.assertEquals("KP08输入轴套24A-42X8", bom.getModels().get(0).getParts().get(0).getMaterialName()); + Assert.assertEquals("45", bom.getModels().get(0).getParts().get(0).getMaterial()); + Assert.assertEquals(new BigDecimal("1.0000"), bom.getModels().get(0).getParts().get(0).getBomQty()); + workbook.close(); + } + + @Test + public void ratioSheetNameIsKeptAsText() throws Exception { + XSSFWorkbook workbook = new XSSFWorkbook(); + Sheet first = workbook.createSheet("91B"); + writeHeader(first); + Row row = first.createRow(2); + row.createCell(0).setCellValue("A1"); + row.createCell(3).setCellValue(1); + Sheet second = workbook.createSheet("92A"); + writeHeader(second); + Row row2 = second.createRow(2); + row2.createCell(4).setCellValue("MODEL-1"); + + RatioBomParseResult result = parser.parse(toStream(workbook)); + Assert.assertEquals(2, result.getSheets().size()); + Assert.assertEquals("91B", result.getSheets().get(0).getRatioValue()); + Assert.assertEquals("92A", result.getSheets().get(1).getRatioValue()); + workbook.close(); + } + + private static void writeHeader(Sheet sheet) { + Row title = sheet.createRow(0); + title.createCell(0).setCellValue("减速机通用件"); + Row header = sheet.createRow(1); + header.createCell(0).setCellValue("物料编码"); + header.createCell(1).setCellValue("物料名称"); + header.createCell(2).setCellValue("材料"); + header.createCell(3).setCellValue("数量"); + header.createCell(4).setCellValue("减速机型号"); + header.createCell(5).setCellValue("轴套物料编码"); + header.createCell(6).setCellValue("轴套名称"); + header.createCell(7).setCellValue("材料"); + header.createCell(8).setCellValue("数量"); + header.createCell(9).setCellValue("行星架物料编码"); + header.createCell(10).setCellValue("行星架名称"); + header.createCell(11).setCellValue("材料"); + header.createCell(12).setCellValue("数量"); + header.createCell(13).setCellValue("法兰物料编码"); + header.createCell(14).setCellValue("法兰名称"); + header.createCell(15).setCellValue("材料"); + header.createCell(16).setCellValue("数量"); + } + + private static ByteArrayInputStream toStream(XSSFWorkbook workbook) throws Exception { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + workbook.write(out); + return new ByteArrayInputStream(out.toByteArray()); + } +}