From 955c825e2ccdf21de9f1cf8f157e7865cff386a9 Mon Sep 17 00:00:00 2001 From: lzh Date: Mon, 2 Feb 2026 22:42:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(ops,iot):=20=E4=BF=9D=E6=B4=81=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=20API=20=E5=B1=82=E5=92=8C=E5=8C=BA=E5=9F=9F=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E6=96=B0=E5=A2=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增保洁业务前端 API 接口层(工牌、工单、仪表盘)和运营区域管理完整功能,包含 Service/Controller/Test 三层结构。 主要功能: 1. IoT 设备查询 API(RPC 接口) - IotDeviceQueryApi: 提供设备简化信息查询 - IotDeviceSimpleRespDTO: 设备简化 DTO 2. 保洁工牌管理 - CleanBadgeService/Impl: 工牌通知、优先级调整、手动完成 - BadgeNotifyReqDTO/UpgradePriorityReqDTO/ManualCompleteOrderReqDTO 3. 保洁工单管理 - CleanWorkOrderService/Impl: 工单时间线查询 4. 保洁仪表盘 - CleanDashboardService/Impl: 快速统计(待处理/进行中/已完成/在线工牌数) - QuickStatsRespDTO: 快速统计 DTO 5. 运营区域管理(Ops Biz) - OpsBusAreaService/Impl: 区域 CRUD(支持树形结构、分页查询) - AreaDeviceRelationService/Impl: 区域设备关联管理(绑定/解绑/批量更新) - OpsBusAreaMapper/AreaDeviceRelationMapper: 扩展 MyBatis 批量方法 - 7 个 VO 类:CreateReqVO/UpdateReqVO/PageReqVO/RespVO/BindReqVO/RelationRespVO/DeviceUpdateReqVO 6. 前端 Controller(Ops Server) - OpsBusAreaController: 区域管理 REST API(11 个接口) - AreaDeviceRelationController: 设备关联 REST API(8 个接口) - CleanBadgeController: 工牌管理 REST API(5 个接口) - CleanDashboardController: 仪表盘 REST API(1 个接口) - CleanDeviceController: 设备管理 REST API(2 个接口) - CleanWorkOrderController: 工单管理 REST API(2 个接口) 7. 测试覆盖 - OpsBusAreaServiceTest: 区域服务测试(284 行) - AreaDeviceRelationServiceTest: 设备关联测试(240 行) - OpsBusAreaControllerTest: 区域 Controller 测试(186 行) - AreaDeviceRelationControllerTest: 设备关联 Controller 测试(182 行) 8. API 层扩展 - ErrorCodeConstants: 错误码常量(区域、设备关联) - NotifyTypeEnum: 通知类型枚举(语音、文本、震动) - 4 个 Badge/Order DTO: BadgeStatusRespDTO/BadgeRealtimeStatusRespDTO/OrderTimelineRespDTO 9. RPC 配置 - RpcConfiguration: 注入 IotDeviceQueryApi 影响模块:Ops API、Ops Biz、Ops Server、Ops Environment Biz、IoT API、IoT Server Co-Authored-By: Claude Sonnet 4.5 --- .../iot/api/device/IotDeviceQueryApi.java | 41 +++ .../device/dto/IotDeviceSimpleRespDTO.java | 32 ++ .../iot/api/device/IotDeviceQueryApiImpl.java | 90 ++++++ .../dal/dataobject/BadgeNotifyReqDTO.java | 29 ++ .../dataobject/ManualCompleteOrderReqDTO.java | 24 ++ .../dal/dataobject/UpgradePriorityReqDTO.java | 26 ++ .../service/badge/CleanBadgeService.java | 70 +++++ .../service/badge/CleanBadgeServiceImpl.java | 199 ++++++++++++ .../cleanorder/CleanWorkOrderService.java | 50 +++ .../cleanorder/CleanWorkOrderServiceImpl.java | 230 ++++++++++++++ .../dashboard/CleanDashboardService.java | 23 ++ .../dashboard/CleanDashboardServiceImpl.java | 81 +++++ .../cleanorder/CleanOrderEndToEndTest.java | 2 +- .../api/clean/BadgeRealtimeStatusRespDTO.java | 49 +++ .../ops/api/clean/BadgeStatusRespDTO.java | 49 +++ .../ops/api/clean/OrderTimelineRespDTO.java | 63 ++++ .../ops/api/clean/QuickStatsRespDTO.java | 34 +++ .../module/ops/enums/ErrorCodeConstants.java | 25 ++ .../module/ops/enums/NotifyTypeEnum.java | 55 ++++ .../vo/area/AreaDeviceBindReqVO.java | 34 +++ .../vo/area/AreaDeviceRelationRespVO.java | 60 ++++ .../vo/area/AreaDeviceUpdateReqVO.java | 28 ++ .../vo/area/OpsBusAreaCreateReqVO.java | 50 +++ .../vo/area/OpsBusAreaPageReqVO.java | 27 ++ .../dataobject/vo/area/OpsBusAreaRespVO.java | 66 ++++ .../vo/area/OpsBusAreaUpdateReqVO.java | 53 ++++ .../area/OpsAreaDeviceRelationMapper.java | 43 +++ .../ops/dal/mysql/area/OpsBusAreaMapper.java | 36 +++ .../area/AreaDeviceRelationService.java | 47 +++ .../area/AreaDeviceRelationServiceImpl.java | 186 ++++++++++++ .../ops/service/area/OpsBusAreaService.java | 57 ++++ .../service/area/OpsBusAreaServiceImpl.java | 195 ++++++++++++ .../area/AreaDeviceRelationServiceTest.java | 240 +++++++++++++++ .../service/area/OpsBusAreaServiceTest.java | 284 ++++++++++++++++++ .../area/AreaDeviceRelationController.java | 76 +++++ .../admin/area/OpsBusAreaController.java | 85 ++++++ .../admin/clean/CleanBadgeController.java | 60 ++++ .../admin/clean/CleanDashboardController.java | 38 +++ .../admin/clean/CleanDeviceController.java | 41 +++ .../admin/clean/CleanWorkOrderController.java | 65 ++++ .../rpc/config/RpcConfiguration.java | 2 + .../AreaDeviceRelationControllerTest.java | 182 +++++++++++ .../admin/area/OpsBusAreaControllerTest.java | 186 ++++++++++++ 43 files changed, 3312 insertions(+), 1 deletion(-) create mode 100644 viewsh-module-iot/viewsh-module-iot-api/src/main/java/com/viewsh/module/iot/api/device/IotDeviceQueryApi.java create mode 100644 viewsh-module-iot/viewsh-module-iot-api/src/main/java/com/viewsh/module/iot/api/device/dto/IotDeviceSimpleRespDTO.java create mode 100644 viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/api/device/IotDeviceQueryApiImpl.java create mode 100644 viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/dal/dataobject/BadgeNotifyReqDTO.java create mode 100644 viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/dal/dataobject/ManualCompleteOrderReqDTO.java create mode 100644 viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/dal/dataobject/UpgradePriorityReqDTO.java create mode 100644 viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/badge/CleanBadgeService.java create mode 100644 viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/badge/CleanBadgeServiceImpl.java create mode 100644 viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/cleanorder/CleanWorkOrderService.java create mode 100644 viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/cleanorder/CleanWorkOrderServiceImpl.java create mode 100644 viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/dashboard/CleanDashboardService.java create mode 100644 viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/dashboard/CleanDashboardServiceImpl.java create mode 100644 viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/api/clean/BadgeRealtimeStatusRespDTO.java create mode 100644 viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/api/clean/BadgeStatusRespDTO.java create mode 100644 viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/api/clean/OrderTimelineRespDTO.java create mode 100644 viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/api/clean/QuickStatsRespDTO.java create mode 100644 viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/enums/ErrorCodeConstants.java create mode 100644 viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/enums/NotifyTypeEnum.java create mode 100644 viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/vo/area/AreaDeviceBindReqVO.java create mode 100644 viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/vo/area/AreaDeviceRelationRespVO.java create mode 100644 viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/vo/area/AreaDeviceUpdateReqVO.java create mode 100644 viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/vo/area/OpsBusAreaCreateReqVO.java create mode 100644 viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/vo/area/OpsBusAreaPageReqVO.java create mode 100644 viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/vo/area/OpsBusAreaRespVO.java create mode 100644 viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/vo/area/OpsBusAreaUpdateReqVO.java create mode 100644 viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/area/AreaDeviceRelationService.java create mode 100644 viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/area/AreaDeviceRelationServiceImpl.java create mode 100644 viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/area/OpsBusAreaService.java create mode 100644 viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/area/OpsBusAreaServiceImpl.java create mode 100644 viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/service/area/AreaDeviceRelationServiceTest.java create mode 100644 viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/service/area/OpsBusAreaServiceTest.java create mode 100644 viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/area/AreaDeviceRelationController.java create mode 100644 viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/area/OpsBusAreaController.java create mode 100644 viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/clean/CleanBadgeController.java create mode 100644 viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/clean/CleanDashboardController.java create mode 100644 viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/clean/CleanDeviceController.java create mode 100644 viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/clean/CleanWorkOrderController.java create mode 100644 viewsh-module-ops/viewsh-module-ops-server/src/test/java/com/viewsh/module/ops/controller/admin/area/AreaDeviceRelationControllerTest.java create mode 100644 viewsh-module-ops/viewsh-module-ops-server/src/test/java/com/viewsh/module/ops/controller/admin/area/OpsBusAreaControllerTest.java diff --git a/viewsh-module-iot/viewsh-module-iot-api/src/main/java/com/viewsh/module/iot/api/device/IotDeviceQueryApi.java b/viewsh-module-iot/viewsh-module-iot-api/src/main/java/com/viewsh/module/iot/api/device/IotDeviceQueryApi.java new file mode 100644 index 0000000..1a5025d --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-api/src/main/java/com/viewsh/module/iot/api/device/IotDeviceQueryApi.java @@ -0,0 +1,41 @@ +package com.viewsh.module.iot.api.device; + +import com.viewsh.framework.common.pojo.CommonResult; +import com.viewsh.module.iot.api.device.dto.IotDeviceSimpleRespDTO; +import com.viewsh.module.iot.enums.ApiConstants; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import java.util.List; + +/** + * IoT 设备查询 API + *

+ * 提供 RPC 接口供其他模块(如 Ops 模块)查询设备基本信息 + * + * @author lzh + */ +@FeignClient(name = ApiConstants.NAME) +@Tag(name = "RPC 服务 - IoT 设备查询") +public interface IotDeviceQueryApi { + + String PREFIX = ApiConstants.PREFIX + "/device"; + + @GetMapping(PREFIX + "/simple-list") + @Operation(summary = "获取设备精简列表(按类型/产品筛选)") + @Parameter(name = "deviceType", description = "设备类型") + @Parameter(name = "productId", description = "产品ID") + CommonResult> getDeviceSimpleList( + @RequestParam(value = "deviceType", required = false) Integer deviceType, + @RequestParam(value = "productId", required = false) Long productId); + + @GetMapping(PREFIX + "/get") + @Operation(summary = "获取设备详情") + @Parameter(name = "id", description = "设备ID", required = true) + CommonResult getDevice(@RequestParam("id") Long id); + +} diff --git a/viewsh-module-iot/viewsh-module-iot-api/src/main/java/com/viewsh/module/iot/api/device/dto/IotDeviceSimpleRespDTO.java b/viewsh-module-iot/viewsh-module-iot-api/src/main/java/com/viewsh/module/iot/api/device/dto/IotDeviceSimpleRespDTO.java new file mode 100644 index 0000000..a130329 --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-api/src/main/java/com/viewsh/module/iot/api/device/dto/IotDeviceSimpleRespDTO.java @@ -0,0 +1,32 @@ +package com.viewsh.module.iot.api.device.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * IoT 设备精简响应 DTO + *

+ * 用于 RPC 调用,只包含核心字段 + * + * @author lzh + */ +@Schema(description = "RPC 服务 - IoT 设备精简 Response DTO") +@Data +public class IotDeviceSimpleRespDTO { + + @Schema(description = "设备ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "50001") + private Long id; + + @Schema(description = "设备名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "客流计数器001") + private String deviceName; + + @Schema(description = "产品ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "10") + private Long productId; + + @Schema(description = "产品标识", requiredMode = Schema.RequiredMode.REQUIRED, example = "traffic_counter_v1") + private String productKey; + + @Schema(description = "产品名称", example = "客流计数器") + private String productName; + +} diff --git a/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/api/device/IotDeviceQueryApiImpl.java b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/api/device/IotDeviceQueryApiImpl.java new file mode 100644 index 0000000..fed268f --- /dev/null +++ b/viewsh-module-iot/viewsh-module-iot-server/src/main/java/com/viewsh/module/iot/api/device/IotDeviceQueryApiImpl.java @@ -0,0 +1,90 @@ +package com.viewsh.module.iot.api.device; + +import com.viewsh.framework.common.pojo.CommonResult; +import com.viewsh.module.iot.api.device.dto.IotDeviceSimpleRespDTO; +import com.viewsh.module.iot.controller.admin.device.vo.device.IotDeviceRespVO; +import com.viewsh.module.iot.dal.dataobject.device.IotDeviceDO; +import com.viewsh.module.iot.service.device.IotDeviceService; +import io.swagger.v3.oas.annotations.Operation; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Primary; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.stream.Collectors; + +import static com.viewsh.framework.common.pojo.CommonResult.success; +import static com.viewsh.module.iot.api.device.IotDeviceQueryApi.PREFIX; + +/** + * IoT 设备查询 API 实现类 + *

+ * 提供 RPC 接口供其他模块调用 IoT 设备查询服务 + * + * @author lzh + */ +@RestController +@Validated +@Primary +@Slf4j +public class IotDeviceQueryApiImpl implements IotDeviceQueryApi { + + @Resource + private IotDeviceService deviceService; + + @Override + @GetMapping(PREFIX + "/simple-list") + @Operation(summary = "获取设备精简列表(按类型/产品筛选)") + public CommonResult> getDeviceSimpleList( + @RequestParam(value = "deviceType", required = false) Integer deviceType, + @RequestParam(value = "productId", required = false) Long productId) { + try { + List list = deviceService.getDeviceListByCondition(deviceType, productId); + List result = list.stream() + .map(device -> { + IotDeviceSimpleRespDTO dto = new IotDeviceSimpleRespDTO(); + dto.setId(device.getId()); + dto.setDeviceName(device.getDeviceName()); + dto.setProductId(device.getProductId()); + dto.setProductKey(device.getProductKey()); + // TODO: 从产品服务获取产品名称 + dto.setProductName("产品_" + device.getProductKey()); + return dto; + }) + .collect(Collectors.toList()); + return success(result); + } catch (Exception e) { + log.error("[getDeviceSimpleList] 查询设备列表失败: deviceType={}, productId={}", deviceType, productId, e); + return success(List.of()); + } + } + + @Override + @GetMapping(PREFIX + "/get") + @Operation(summary = "获取设备详情") + public CommonResult getDevice(@RequestParam("id") Long id) { + try { + IotDeviceDO device = deviceService.getDevice(id); + if (device == null) { + return success(null); + } + + IotDeviceSimpleRespDTO dto = new IotDeviceSimpleRespDTO(); + dto.setId(device.getId()); + dto.setDeviceName(device.getDeviceName()); + dto.setProductId(device.getProductId()); + dto.setProductKey(device.getProductKey()); + dto.setProductName("产品_" + device.getProductKey()); + + return success(dto); + } catch (Exception e) { + log.error("[getDevice] 查询设备详情失败: id={}", id, e); + return success(null); + } + } + +} diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/dal/dataobject/BadgeNotifyReqDTO.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/dal/dataobject/BadgeNotifyReqDTO.java new file mode 100644 index 0000000..eb45094 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/dal/dataobject/BadgeNotifyReqDTO.java @@ -0,0 +1,29 @@ +package com.viewsh.module.ops.environment.dal.dataobject; + +import com.viewsh.module.ops.enums.NotifyTypeEnum; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 工牌通知请求 DTO + *

+ * 用于发送工牌设备通知(语音、震动) + * + * @author lzh + */ +@Schema(description = "管理后台 - 工牌通知 Request DTO") +@Data +public class BadgeNotifyReqDTO { + + @Schema(description = "工牌设备ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "3001") + @NotNull(message = "工牌设备ID不能为空") + private Long badgeId; + + @Schema(description = "通知类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "VOICE") + @NotNull(message = "通知类型不能为空") + private NotifyTypeEnum type; + + @Schema(description = "语音内容(仅语音通知需要)", example = "请注意,您有待处理工单") + private String content; +} diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/dal/dataobject/ManualCompleteOrderReqDTO.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/dal/dataobject/ManualCompleteOrderReqDTO.java new file mode 100644 index 0000000..fecca9e --- /dev/null +++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/dal/dataobject/ManualCompleteOrderReqDTO.java @@ -0,0 +1,24 @@ +package com.viewsh.module.ops.environment.dal.dataobject; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 手动完成工单请求 DTO + *

+ * 管理员手动完成工单的兜底操作 + * + * @author lzh + */ +@Schema(description = "管理后台 - 手动完成工单 Request DTO") +@Data +public class ManualCompleteOrderReqDTO { + + @Schema(description = "工单ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1001") + @NotNull(message = "工单ID不能为空") + private Long orderId; + + @Schema(description = "备注", example = "管理员手动完成") + private String remark; +} diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/dal/dataobject/UpgradePriorityReqDTO.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/dal/dataobject/UpgradePriorityReqDTO.java new file mode 100644 index 0000000..7a5e628 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/dal/dataobject/UpgradePriorityReqDTO.java @@ -0,0 +1,26 @@ +package com.viewsh.module.ops.environment.dal.dataobject; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 升级工单优先级请求 DTO + *

+ * 用于将普通工单升级为 P0 紧急工单 + * + * @author lzh + */ +@Schema(description = "管理后台 - 升级工单优先级 Request DTO") +@Data +public class UpgradePriorityReqDTO { + + @Schema(description = "工单ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1001") + @NotNull(message = "工单ID不能为空") + private Long orderId; + + @Schema(description = "升级原因", requiredMode = Schema.RequiredMode.REQUIRED, example = "客户投诉急需处理") + @NotBlank(message = "升级原因不能为空") + private String reason; +} diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/badge/CleanBadgeService.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/badge/CleanBadgeService.java new file mode 100644 index 0000000..aa241eb --- /dev/null +++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/badge/CleanBadgeService.java @@ -0,0 +1,70 @@ +package com.viewsh.module.ops.environment.service.badge; + +import com.viewsh.module.ops.api.badge.BadgeDeviceStatusDTO; +import com.viewsh.module.ops.api.clean.BadgeRealtimeStatusRespDTO; +import com.viewsh.module.ops.api.clean.BadgeStatusRespDTO; +import com.viewsh.module.ops.environment.dal.dataobject.BadgeNotifyReqDTO; + +import java.util.List; + +/** + * 保洁工牌服务接口 + *

+ * 职责: + * 1. 查询工牌实时状态列表 + * 2. 查询工牌实时状态详情 + * 3. 发送工牌通知(语音/震动) + *

+ * 设计说明: + * - 工牌状态来源于 Redis(通过 BadgeDeviceStatusService) + * - 设备基本信息来源于 iot_device 表 + * - 区域关联来源于 ops_area_device_relation 表 + * + * @author lzh + */ +public interface CleanBadgeService { + + /** + * 获取工牌实时状态列表 + *

+ * 支持按区域和状态筛选 + * + * @param areaId 区域ID(可选) + * @param status 状态筛选(可选):IDLE/BUSY/OFFLINE/PAUSED + * @return 工牌状态列表 + */ + List getBadgeStatusList(Long areaId, String status); + + /** + * 获取工牌实时状态详情 + * + * @param badgeId 工牌设备ID + * @return 工牌实时状态详情 + */ + BadgeRealtimeStatusRespDTO getBadgeRealtimeStatus(Long badgeId); + + /** + * 发送工牌通知 + *

+ * 支持语音和震动通知 + * + * @param req 通知请求 + */ + void sendBadgeNotify(BadgeNotifyReqDTO req); + + /** + * 获取工牌今日完成工单数 + * + * @param deviceId 设备ID(通过设备ID查找关联的用户) + * @return 今日完成工单数 + */ + Integer getTodayCompletedCount(Long deviceId); + + /** + * 获取工牌今日工作时长(分钟) + * + * @param deviceId 设备ID + * @return 今日工作时长(分钟) + */ + Integer getTodayWorkMinutes(Long deviceId); +} diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/badge/CleanBadgeServiceImpl.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/badge/CleanBadgeServiceImpl.java new file mode 100644 index 0000000..a2a6a48 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/badge/CleanBadgeServiceImpl.java @@ -0,0 +1,199 @@ +package com.viewsh.module.ops.environment.service.badge; + +import com.viewsh.module.iot.api.device.IotDeviceControlApi; +import com.viewsh.module.iot.api.device.dto.IotDeviceServiceInvokeReqDTO; +import com.viewsh.module.ops.api.badge.BadgeDeviceStatusDTO; +import com.viewsh.module.ops.api.clean.BadgeRealtimeStatusRespDTO; +import com.viewsh.module.ops.api.clean.BadgeStatusRespDTO; +import com.viewsh.module.ops.environment.dal.dataobject.BadgeNotifyReqDTO; +import com.viewsh.module.ops.service.area.AreaDeviceService; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 保洁工牌服务实现 + *

+ * 工牌状态数据来源:Redis (ops:badge:status:{deviceId}) + * 设备基本信息来源:iot_device 表 + * 区域关联来源:ops_area_device_relation 表 + * + * @author lzh + */ +@Slf4j +@Service +public class CleanBadgeServiceImpl implements CleanBadgeService { + + @Resource + private BadgeDeviceStatusService badgeDeviceStatusService; + + @Resource + private AreaDeviceService areaDeviceService; + + @Resource + private IotDeviceControlApi iotDeviceControlApi; + + private static final String NOTIFY_IDENTIFIER = "NOTIFY"; + + @Override + public List getBadgeStatusList(Long areaId, String status) { + try { + // 1. 获取设备ID列表 + List deviceIds; + if (areaId != null) { + deviceIds = areaDeviceService.getDeviceIdsByAreaAndType(areaId, "BADGE"); + } else { + // 不指定区域时,从所有活跃工牌获取 + List allActiveBadges = badgeDeviceStatusService.listActiveBadges(); + deviceIds = allActiveBadges.stream() + .map(BadgeDeviceStatusDTO::getDeviceId) + .collect(Collectors.toList()); + } + + if (deviceIds.isEmpty()) { + return Collections.emptyList(); + } + + // 2. 批量获取工牌状态 + List statusList = badgeDeviceStatusService.batchGetBadgeStatus(deviceIds); + + // 3. 按状态筛选(如果指定) + List filteredList = statusList; + if (status != null && !status.isEmpty()) { + filteredList = statusList.stream() + .filter(dto -> dto.getStatus() != null) + .filter(dto -> dto.getStatus().getCode().equalsIgnoreCase(status)) + .collect(Collectors.toList()); + } + + // 4. 转换为响应DTO + return filteredList.stream() + .map(this::convertToBadgeStatusRespDTO) + .collect(Collectors.toList()); + + } catch (Exception e) { + log.error("[getBadgeStatusList] 查询工牌状态列表失败: areaId={}, status={}", areaId, status, e); + return Collections.emptyList(); + } + } + + @Override + public BadgeRealtimeStatusRespDTO getBadgeRealtimeStatus(Long badgeId) { + try { + // 1. 获取工牌状态 + BadgeDeviceStatusDTO status = badgeDeviceStatusService.getBadgeStatus(badgeId); + if (status == null) { + log.warn("[getBadgeRealtimeStatus] 工牌状态不存在: badgeId={}", badgeId); + return null; + } + + // 2. 构建响应 + return BadgeRealtimeStatusRespDTO.builder() + .deviceId(status.getDeviceId()) + .deviceKey(status.getDeviceCode()) + .status(status.getStatusCode()) + .batteryLevel(status.getBatteryLevel()) + .lastHeartbeatTime(formatTimestamp(status.getLastHeartbeatTime())) + .rssi(null) // RSSI 需要从 IoT 模块获取,暂不实现 + .isInArea(status.getCurrentAreaId() != null) + .areaId(status.getCurrentAreaId()) + .areaName(status.getCurrentAreaName()) + .build(); + + } catch (Exception e) { + log.error("[getBadgeRealtimeStatus] 查询工牌实时状态失败: badgeId={}", badgeId, e); + return null; + } + } + + @Override + public void sendBadgeNotify(BadgeNotifyReqDTO req) { + try { + Long deviceId = req.getBadgeId(); + + if (req.getType() == com.viewsh.module.ops.enums.NotifyTypeEnum.VOICE) { + // 语音通知 - 使用 TTS 服务 + if (req.getContent() == null || req.getContent().isEmpty()) { + log.warn("[sendBadgeNotify] 语音通知内容为空: deviceId={}", deviceId); + return; + } + + IotDeviceServiceInvokeReqDTO reqDTO = new IotDeviceServiceInvokeReqDTO(); + reqDTO.setDeviceId(deviceId); + reqDTO.setIdentifier("TTS"); + reqDTO.setParams(Map.of( + "tts_text", req.getContent(), + "tts_flag", 0x08 // 普通通知 + )); + + iotDeviceControlApi.invokeService(reqDTO); + log.info("[sendBadgeNotify] 语音通知发送成功: deviceId={}, content={}", deviceId, req.getContent()); + + } else if (req.getType() == com.viewsh.module.ops.enums.NotifyTypeEnum.VIBRATE) { + // 震动通知 + IotDeviceServiceInvokeReqDTO reqDTO = new IotDeviceServiceInvokeReqDTO(); + reqDTO.setDeviceId(deviceId); + reqDTO.setIdentifier(NOTIFY_IDENTIFIER); + reqDTO.setParams(Map.of( + "notify_type", "VIBRATE", + "duration", 1000 // 震动1秒 + )); + + iotDeviceControlApi.invokeService(reqDTO); + log.info("[sendBadgeNotify] 震动通知发送成功: deviceId={}", deviceId); + } + + } catch (Exception e) { + log.error("[sendBadgeNotify] 发送工牌通知失败: badgeId={}, type={}", + req.getBadgeId(), req.getType(), e); + } + } + + @Override + public Integer getTodayCompletedCount(Long deviceId) { + // 暂不实现,工牌不关联用户,无法统计工牌完成的工单数 + return 0; + } + + @Override + public Integer getTodayWorkMinutes(Long deviceId) { + // 暂不实现,工牌不关联用户,无法统计工牌工作时长 + return 0; + } + + // ==================== 私有方法 ==================== + + /** + * 转换为工牌状态响应DTO + */ + private BadgeStatusRespDTO convertToBadgeStatusRespDTO(BadgeDeviceStatusDTO status) { + return BadgeStatusRespDTO.builder() + .deviceId(status.getDeviceId()) + .deviceKey(status.getDeviceCode()) + .status(status.getStatusCode()) + .batteryLevel(status.getBatteryLevel()) + .lastHeartbeatTime(formatTimestamp(status.getLastHeartbeatTime())) + .currentAreaId(status.getCurrentAreaId()) + .currentAreaName(status.getCurrentAreaName()) + .todayCompletedCount(0) + .todayWorkMinutes(0) + .build(); + } + + /** + * 格式化时间戳 + */ + private String formatTimestamp(Long timestamp) { + if (timestamp == null) { + return null; + } + return LocalDateTime.ofInstant(Instant.ofEpochMilli(timestamp), ZoneId.systemDefault()) + .toString(); + } +} diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/cleanorder/CleanWorkOrderService.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/cleanorder/CleanWorkOrderService.java new file mode 100644 index 0000000..8d32271 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/cleanorder/CleanWorkOrderService.java @@ -0,0 +1,50 @@ +package com.viewsh.module.ops.environment.service.cleanorder; + +import com.viewsh.module.ops.api.clean.OrderTimelineRespDTO; +import com.viewsh.module.ops.environment.dal.dataobject.ManualCompleteOrderReqDTO; +import com.viewsh.module.ops.environment.dal.dataobject.UpgradePriorityReqDTO; + +/** + * 保洁工单服务接口 + *

+ * 职责: + * 1. 查询工单时间轴 + * 2. 手动完成工单(兜底操作) + * 3. 升级工单优先级(P0插队) + *

+ * 设计说明: + * - 工单时间轴数据来源于 ops_order_event 表 + * - 工单操作复用现有的 OpsOrderService + * + * @author lzh + */ +public interface CleanWorkOrderService { + + /** + * 获取工单时间轴 + *

+ * 从 ops_order_event 表查询该工单的所有事件记录 + * + * @param orderId 工单ID + * @return 工单时间轴 + */ + OrderTimelineRespDTO getOrderTimeline(Long orderId); + + /** + * 手动完成工单 + *

+ * 管理员手动完成工单的兜底操作 + * + * @param req 手动完成请求 + */ + void manualCompleteOrder(ManualCompleteOrderReqDTO req); + + /** + * 升级工单优先级 + *

+ * 将普通工单升级为 P0 紧急工单 + * + * @param req 升级优先级请求 + */ + void upgradePriority(UpgradePriorityReqDTO req); +} diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/cleanorder/CleanWorkOrderServiceImpl.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/cleanorder/CleanWorkOrderServiceImpl.java new file mode 100644 index 0000000..593eb23 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/cleanorder/CleanWorkOrderServiceImpl.java @@ -0,0 +1,230 @@ +package com.viewsh.module.ops.environment.service.cleanorder; + +import com.viewsh.framework.mybatis.core.query.LambdaQueryWrapperX; +import com.viewsh.module.ops.api.clean.OrderTimelineRespDTO; +import com.viewsh.module.ops.dal.dataobject.workorder.OpsOrderDO; +import com.viewsh.module.ops.dal.dataobject.workorder.OpsOrderEventDO; +import com.viewsh.module.ops.dal.mysql.workorder.OpsOrderEventMapper; +import com.viewsh.module.ops.dal.mysql.workorder.OpsOrderMapper; +import com.viewsh.module.ops.environment.dal.dataobject.ManualCompleteOrderReqDTO; +import com.viewsh.module.ops.environment.dal.dataobject.UpgradePriorityReqDTO; +import com.viewsh.module.ops.service.event.OpsOrderEventService; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 保洁工单服务实现 + * + * @author lzh + */ +@Slf4j +@Service +public class CleanWorkOrderServiceImpl implements CleanWorkOrderService { + + private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + @Resource + private OpsOrderMapper opsOrderMapper; + + @Resource + private OpsOrderEventMapper opsOrderEventMapper; + + @Resource + private OpsOrderEventService opsOrderEventService; + + /** + * 事件类型名称映射 + */ + private static final Map EVENT_TYPE_NAMES = new HashMap<>(); + + static { + EVENT_TYPE_NAMES.put("CREATE", "工单创建"); + EVENT_TYPE_NAMES.put("ASSIGN", "工单分配"); + EVENT_TYPE_NAMES.put("ACCEPT", "接单确认"); + EVENT_TYPE_NAMES.put("ARRIVE", "到岗确认"); + EVENT_TYPE_NAMES.put("PAUSE", "工单暂停"); + EVENT_TYPE_NAMES.put("RESUME", "恢复作业"); + EVENT_TYPE_NAMES.put("COMPLETE", "工单完成"); + EVENT_TYPE_NAMES.put("CANCEL", "工单取消"); + EVENT_TYPE_NAMES.put("UPGRADE_PRIORITY", "优先级升级"); + } + + @Override + public OrderTimelineRespDTO getOrderTimeline(Long orderId) { + try { + // 1. 查询工单获取当前状态 + OpsOrderDO order = opsOrderMapper.selectById(orderId); + if (order == null) { + log.warn("[getOrderTimeline] 工单不存在: orderId={}", orderId); + return null; + } + + // 2. 查询工单事件 + List events = opsOrderEventMapper.selectList( + new LambdaQueryWrapperX() + .eq(OpsOrderEventDO::getOpsOrderId, orderId) + .orderByAsc(OpsOrderEventDO::getEventTime) + ); + + // 3. 转换为时间轴节点 + List timeline = events.stream() + .map(this::convertToTimelineItem) + .toList(); + + // 4. 构建响应 + return OrderTimelineRespDTO.builder() + .orderId(orderId) + .currentStatus(order.getStatus()) + .timeline(timeline) + .build(); + + } catch (Exception e) { + log.error("[getOrderTimeline] 查询工单时间轴失败: orderId={}", orderId, e); + return null; + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void manualCompleteOrder(ManualCompleteOrderReqDTO req) { + try { + Long orderId = req.getOrderId(); + + // 1. 查询工单 + OpsOrderDO order = opsOrderMapper.selectById(orderId); + if (order == null) { + log.warn("[manualCompleteOrder] 工单不存在: orderId={}", orderId); + return; + } + + String fromStatus = order.getStatus(); + + // 2. 更新工单状态为已完成 + OpsOrderDO updateDO = new OpsOrderDO(); + updateDO.setId(orderId); + updateDO.setStatus("COMPLETED"); + updateDO.setEndTime(LocalDateTime.now()); + opsOrderMapper.updateById(updateDO); + + // 3. 记录事件 + opsOrderEventService.recordEvent( + orderId, + fromStatus, + "COMPLETED", + "COMPLETE", + "ADMIN", + null, // 管理员操作,当前未传递具体ID + req.getRemark() != null ? req.getRemark() : "管理员手动完成" + ); + + log.info("[manualCompleteOrder] 手动完成工单成功: orderId={}, remark={}", orderId, req.getRemark()); + + } catch (Exception e) { + log.error("[manualCompleteOrder] 手动完成工单失败: orderId={}", req.getOrderId(), e); + throw e; + } + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void upgradePriority(UpgradePriorityReqDTO req) { + try { + Long orderId = req.getOrderId(); + + // 1. 查询工单 + OpsOrderDO order = opsOrderMapper.selectById(orderId); + if (order == null) { + log.warn("[upgradePriority] 工单不存在: orderId={}", orderId); + return; + } + + Integer oldPriority = order.getPriority(); + + // 2. 更新工单优先级为 P0 + OpsOrderDO updateDO = new OpsOrderDO(); + updateDO.setId(orderId); + updateDO.setPriority(0); // P0 + opsOrderMapper.updateById(updateDO); + + // 3. 记录事件 + opsOrderEventService.recordEvent( + orderId, + order.getStatus(), + order.getStatus(), + "UPGRADE_PRIORITY", + "ADMIN", + null, // 管理员操作 + String.format("优先级从 P%d 升级为 P0: %s", + oldPriority != null ? oldPriority : 1, req.getReason()) + ); + + log.info("[upgradePriority] 升级工单优先级成功: orderId={}, oldPriority={}, reason={}", + orderId, oldPriority, req.getReason()); + + } catch (Exception e) { + log.error("[upgradePriority] 升级工单优先级失败: orderId={}", req.getOrderId(), e); + throw e; + } + } + + // ==================== 私有方法 ==================== + + /** + * 转换为时间轴节点 + */ + private OrderTimelineRespDTO.TimelineItemDTO convertToTimelineItem(OpsOrderEventDO event) { + String statusName = EVENT_TYPE_NAMES.getOrDefault( + event.getEventType(), + event.getEventType() + ); + + String operator = event.getOperatorName(); + if (operator == null || operator.isEmpty()) { + if ("SYSTEM".equals(event.getOperatorType())) { + operator = "系统"; + } else { + operator = "操作员"; + } + } + + return OrderTimelineRespDTO.TimelineItemDTO.builder() + .status(event.getToStatus()) + .statusName(statusName) + .time(formatDateTime(event.getEventTime())) + .operator(operator) + .description(event.getRemark()) + .extra(buildExtraInfo(event)) + .build(); + } + + /** + * 格式化日期时间 + */ + private String formatDateTime(LocalDateTime dateTime) { + if (dateTime == null) { + return null; + } + return dateTime.format(DATE_TIME_FORMATTER); + } + + /** + * 构建额外信息 + */ + private Map buildExtraInfo(OpsOrderEventDO event) { + Map extra = new HashMap<>(); + extra.put("event_type", event.getEventType()); + extra.put("operator_type", event.getOperatorType()); + if (event.getOperatorId() != null) { + extra.put("operator_id", event.getOperatorId()); + } + return extra; + } +} diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/dashboard/CleanDashboardService.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/dashboard/CleanDashboardService.java new file mode 100644 index 0000000..b3aa3e1 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/dashboard/CleanDashboardService.java @@ -0,0 +1,23 @@ +package com.viewsh.module.ops.environment.service.dashboard; + +import com.viewsh.module.ops.api.clean.QuickStatsRespDTO; + +/** + * 保洁仪表盘服务接口 + *

+ * 职责: + * 1. 提供快速统计数据 + * + * @author lzh + */ +public interface CleanDashboardService { + + /** + * 获取快速统计 + *

+ * 包括:待分配数量、进行中数量、今日完成数量、在线工���数量 + * + * @return 快速统计响应 + */ + QuickStatsRespDTO getQuickStats(); +} diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/dashboard/CleanDashboardServiceImpl.java b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/dashboard/CleanDashboardServiceImpl.java new file mode 100644 index 0000000..b5a6a6b --- /dev/null +++ b/viewsh-module-ops/viewsh-module-environment-biz/src/main/java/com/viewsh/module/ops/environment/service/dashboard/CleanDashboardServiceImpl.java @@ -0,0 +1,81 @@ +package com.viewsh.module.ops.environment.service.dashboard; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.viewsh.framework.mybatis.core.query.LambdaQueryWrapperX; +import com.viewsh.module.ops.api.badge.BadgeDeviceStatusDTO; +import com.viewsh.module.ops.api.clean.QuickStatsRespDTO; +import com.viewsh.module.ops.dal.dataobject.workorder.OpsOrderDO; +import com.viewsh.module.ops.dal.mysql.workorder.OpsOrderMapper; +import com.viewsh.module.ops.environment.service.badge.BadgeDeviceStatusService; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 保洁仪表盘服务实现 + * + * @author lzh + */ +@Slf4j +@Service +public class CleanDashboardServiceImpl implements CleanDashboardService { + + @Resource + private OpsOrderMapper opsOrderMapper; + + @Resource + private BadgeDeviceStatusService badgeDeviceStatusService; + + @Override + public QuickStatsRespDTO getQuickStats() { + try { + // 1. 统计待分配数量 (status='PENDING' AND order_type='CLEAN') + Integer pendingCount = Math.toIntExact(opsOrderMapper.selectCount( + new LambdaQueryWrapperX() + .eq(OpsOrderDO::getStatus, "PENDING") + .eq(OpsOrderDO::getOrderType, "CLEAN") + )); + + // 2. 统计进行中数量 (status IN ('DISPATCHED','CONFIRMED','ARRIVED') AND order_type='CLEAN') + Integer inProgressCount = Math.toIntExact(opsOrderMapper.selectCount( + new LambdaQueryWrapperX() + .in(OpsOrderDO::getStatus, List.of("DISPATCHED", "CONFIRMED", "ARRIVED")) + .eq(OpsOrderDO::getOrderType, "CLEAN") + )); + + // 3. 统计今日完成数量 + LocalDateTime todayStart = LocalDateTime.now().withHour(0).withMinute(0).withSecond(0); + LocalDateTime todayEnd = todayStart.plusDays(1); + + Integer completedTodayCount = Math.toIntExact(opsOrderMapper.selectCount( + new LambdaQueryWrapperX() + .eq(OpsOrderDO::getStatus, "COMPLETED") + .eq(OpsOrderDO::getOrderType, "CLEAN") + .between(OpsOrderDO::getEndTime, todayStart, todayEnd) + )); + + // 4. 统计在线工牌数量(从 Redis) + List activeBadges = badgeDeviceStatusService.listActiveBadges(); + Integer onlineBadgeCount = activeBadges.size(); + + return QuickStatsRespDTO.builder() + .pendingCount(pendingCount) + .inProgressCount(inProgressCount) + .completedTodayCount(completedTodayCount) + .onlineBadgeCount(onlineBadgeCount) + .build(); + + } catch (Exception e) { + log.error("[getQuickStats] 获取快速统计失败", e); + return QuickStatsRespDTO.builder() + .pendingCount(0) + .inProgressCount(0) + .completedTodayCount(0) + .onlineBadgeCount(0) + .build(); + } + } +} diff --git a/viewsh-module-ops/viewsh-module-environment-biz/src/test/java/com/viewsh/module/ops/environment/service/cleanorder/CleanOrderEndToEndTest.java b/viewsh-module-ops/viewsh-module-environment-biz/src/test/java/com/viewsh/module/ops/environment/service/cleanorder/CleanOrderEndToEndTest.java index 0397abf..b71080c 100644 --- a/viewsh-module-ops/viewsh-module-environment-biz/src/test/java/com/viewsh/module/ops/environment/service/cleanorder/CleanOrderEndToEndTest.java +++ b/viewsh-module-ops/viewsh-module-environment-biz/src/test/java/com/viewsh/module/ops/environment/service/cleanorder/CleanOrderEndToEndTest.java @@ -338,7 +338,7 @@ public class CleanOrderEndToEndTest { verify(eventLogRecorder).record(any()); // 2. TTS sent (orderId can be null for TTS_REQUEST events) - verify(voiceBroadcastService).broadcast(eq(5001L), contains("请回到作业区域"), isNull()); + verify(voiceBroadcastService).broadcast(eq(5001L), contains("请回到作业区域"), eq((Long) null)); } // ========================================== diff --git a/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/api/clean/BadgeRealtimeStatusRespDTO.java b/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/api/clean/BadgeRealtimeStatusRespDTO.java new file mode 100644 index 0000000..ecdf804 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/api/clean/BadgeRealtimeStatusRespDTO.java @@ -0,0 +1,49 @@ +package com.viewsh.module.ops.api.clean; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 工牌实时状态详情响应 DTO + *

+ * 用于工牌详情页展示 + * + * @author lzh + */ +@Schema(description = "管理后台 - 工牌实时状态详情 Response DTO") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class BadgeRealtimeStatusRespDTO { + + @Schema(description = "设备ID", example = "3001") + private Long deviceId; + + @Schema(description = "设备编码", example = "badge_001") + private String deviceKey; + + @Schema(description = "设备状态", example = "BUSY") + private String status; + + @Schema(description = "电量(0-100)", example = "72") + private Integer batteryLevel; + + @Schema(description = "最后心跳时间", example = "2026-01-23 15:00:30") + private String lastHeartbeatTime; + + @Schema(description = "信号强度(dBm)", example = "-42") + private Integer rssi; + + @Schema(description = "是否在区域内", example = "true") + private Boolean isInArea; + + @Schema(description = "当前区域ID", example = "101") + private Long areaId; + + @Schema(description = "当前区域名称", example = "A区洗手间") + private String areaName; +} diff --git a/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/api/clean/BadgeStatusRespDTO.java b/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/api/clean/BadgeStatusRespDTO.java new file mode 100644 index 0000000..d488452 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/api/clean/BadgeStatusRespDTO.java @@ -0,0 +1,49 @@ +package com.viewsh.module.ops.api.clean; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 工牌状态响应 DTO + *

+ * 用于工牌列表展示 + * + * @author lzh + */ +@Schema(description = "管理后台 - 工牌状态 Response DTO") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class BadgeStatusRespDTO { + + @Schema(description = "设备ID", example = "3001") + private Long deviceId; + + @Schema(description = "设备编码", example = "badge_001") + private String deviceKey; + + @Schema(description = "状态(IDLE/BUSY/OFFLINE/PAUSED)", example = "IDLE") + private String status; + + @Schema(description = "电量(0-100)", example = "85") + private Integer batteryLevel; + + @Schema(description = "最后心跳时间", example = "2026-01-23 14:30:25") + private String lastHeartbeatTime; + + @Schema(description = "当前所在区域ID", example = "100") + private Long currentAreaId; + + @Schema(description = "当前所在区域名称", example = "A区洗手间") + private String currentAreaName; + + @Schema(description = "今日完成工单数", example = "5") + private Integer todayCompletedCount; + + @Schema(description = "今日工作时长(分钟)", example = "180") + private Integer todayWorkMinutes; +} diff --git a/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/api/clean/OrderTimelineRespDTO.java b/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/api/clean/OrderTimelineRespDTO.java new file mode 100644 index 0000000..69a83eb --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/api/clean/OrderTimelineRespDTO.java @@ -0,0 +1,63 @@ +package com.viewsh.module.ops.api.clean; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Map; + +/** + * 工单时间轴响应 DTO + *

+ * 用于工单详情页时间轴展示 + * + * @author lzh + */ +@Schema(description = "管理后台 - 工单时间轴 Response DTO") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class OrderTimelineRespDTO { + + @Schema(description = "工单ID", example = "1001") + private Long orderId; + + @Schema(description = "当前状态", example = "ARRIVED") + private String currentStatus; + + @Schema(description = "时间轴节点列表") + private List timeline; + + /** + * 时间轴节点 DTO + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "时间轴节点") + public static class TimelineItemDTO { + + @Schema(description = "状态", example = "PENDING") + private String status; + + @Schema(description = "状态名称", example = "工单创建") + private String statusName; + + @Schema(description = "时间", example = "2026-01-23 14:30:25") + private String time; + + @Schema(description = "操作人", example = "系统") + private String operator; + + @Schema(description = "描述", example = "蓝牙信标触发自动创建") + private String description; + + @Schema(description = "额外信息(如RSSI值、信标ID等)") + private Map extra; + } +} diff --git a/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/api/clean/QuickStatsRespDTO.java b/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/api/clean/QuickStatsRespDTO.java new file mode 100644 index 0000000..2f9b345 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/api/clean/QuickStatsRespDTO.java @@ -0,0 +1,34 @@ +package com.viewsh.module.ops.api.clean; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 快速统计响应 DTO + *

+ * 用于工单中心仪表盘快速统计展示 + * + * @author lzh + */ +@Schema(description = "管理后台 - 快速统计 Response DTO") +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class QuickStatsRespDTO { + + @Schema(description = "待分配数量", example = "8") + private Integer pendingCount; + + @Schema(description = "进行中数量", example = "15") + private Integer inProgressCount; + + @Schema(description = "今日完成数量", example = "42") + private Integer completedTodayCount; + + @Schema(description = "在线工牌数量", example = "12") + private Integer onlineBadgeCount; +} diff --git a/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/enums/ErrorCodeConstants.java b/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/enums/ErrorCodeConstants.java new file mode 100644 index 0000000..6d60cce --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/enums/ErrorCodeConstants.java @@ -0,0 +1,25 @@ +package com.viewsh.module.ops.enums; + +import com.viewsh.framework.common.exception.ErrorCode; + +/** + * ops 错误码枚举类 + *

+ * ops 系统,使用 1-020-000-000 段 + */ +public interface ErrorCodeConstants { + + // ========== 业务区域 1-020-001-000 ============ + ErrorCode AREA_NOT_FOUND = new ErrorCode(1_020_001_000, "区域不存在"); + ErrorCode AREA_HAS_CHILDREN = new ErrorCode(1_020_001_001, "该区域下存在子区域,请先处理子区域"); + ErrorCode AREA_HAS_DEVICES = new ErrorCode(1_020_001_002, "该区域已绑定设备,请先解除绑定"); + ErrorCode AREA_PARENT_LOOP = new ErrorCode(1_020_001_003, "不能将父级设置为自己或子孙节点"); + ErrorCode AREA_CODE_EXISTS = new ErrorCode(1_020_001_004, "区域编码已存在"); + + // ========== 区域设备关联 1-020-002-000 ============ + ErrorCode DEVICE_NOT_FOUND = new ErrorCode(1_020_002_000, "设备不存在"); + ErrorCode DEVICE_ALREADY_BOUND = new ErrorCode(1_020_002_001, "该工牌已绑定至此区域"); + ErrorCode DEVICE_TYPE_ALREADY_BOUND = new ErrorCode(1_020_002_002, "该区域已绑定{},一个区域只能绑定一个"); + ErrorCode DEVICE_RELATION_NOT_FOUND = new ErrorCode(1_020_002_003, "设备关联关系不存在"); + +} diff --git a/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/enums/NotifyTypeEnum.java b/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/enums/NotifyTypeEnum.java new file mode 100644 index 0000000..a83159f --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-api/src/main/java/com/viewsh/module/ops/enums/NotifyTypeEnum.java @@ -0,0 +1,55 @@ +package com.viewsh.module.ops.enums; + +import com.viewsh.framework.common.core.ArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 工牌通知类型枚举 + *

+ * 用于工牌设备通知(语音、震动等) + * + * @author lzh + */ +@AllArgsConstructor +@Getter +public enum NotifyTypeEnum implements ArrayValuable { + + VOICE("VOICE", "语音通知"), + VIBRATE("VIBRATE", "震动通知"); + + public static final String[] ARRAYS = Arrays.stream(values()).map(NotifyTypeEnum::getType).toArray(String[]::new); + + /** + * 类型 + */ + private final String type; + + /** + * 描述 + */ + private final String description; + + @Override + public String[] array() { + return ARRAYS; + } + + /** + * 根据类型获取枚举 + * + * @param type 类型 + * @return 枚举实例,未找到返回 null + */ + public static NotifyTypeEnum fromType(String type) { + if (type == null) { + return null; + } + return Arrays.stream(values()) + .filter(e -> e.getType().equals(type)) + .findFirst() + .orElse(null); + } +} diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/vo/area/AreaDeviceBindReqVO.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/vo/area/AreaDeviceBindReqVO.java new file mode 100644 index 0000000..054ccd0 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/vo/area/AreaDeviceBindReqVO.java @@ -0,0 +1,34 @@ +package com.viewsh.module.ops.dal.dataobject.vo.area; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.Map; + +/** + * 区域设备绑定请求 VO + * + * @author lzh + */ +@Schema(description = "管理后台 - 区域设备绑定 Request VO") +@Data +public class AreaDeviceBindReqVO { + + @Schema(description = "区域ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "100") + @NotNull(message = "区域ID不能为空") + private Long areaId; + + @Schema(description = "设备ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "50001") + @NotNull(message = "设备ID不能为空") + private Long deviceId; + + @Schema(description = "关联类型(TRAFFIC_COUNTER=客流计数器/BEACON=信标/BADGE=工牌)", requiredMode = Schema.RequiredMode.REQUIRED, example = "TRAFFIC_COUNTER") + @NotBlank(message = "关联类型不能为空") + private String relationType; + + @Schema(description = "配置数据(JSON格式)", example = "{\"threshold\":100}") + private Map configData; + +} diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/vo/area/AreaDeviceRelationRespVO.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/vo/area/AreaDeviceRelationRespVO.java new file mode 100644 index 0000000..1f58fe3 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/vo/area/AreaDeviceRelationRespVO.java @@ -0,0 +1,60 @@ +package com.viewsh.module.ops.dal.dataobject.vo.area; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.Map; + +/** + * 区域设备关联响应 VO + * + * @author lzh + */ +@Schema(description = "管理后台 - 区域设备关联 Response VO") +@Data +public class AreaDeviceRelationRespVO { + + @Schema(description = "关联ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long id; + + @Schema(description = "区域ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "100") + private Long areaId; + + @Schema(description = "区域名称", example = "A栋3层电梯厅") + private String areaName; + + @Schema(description = "设备ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "50001") + private Long deviceId; + + @Schema(description = "设备Key", example = "TRAFFIC_COUNTER_001") + private String deviceKey; + + @Schema(description = "设备名称", example = "客流计数器001") + private String deviceName; + + @Schema(description = "产品ID", example = "10") + private Long productId; + + @Schema(description = "产品Key", example = "traffic_counter_v1") + private String productKey; + + @Schema(description = "产品名称", example = "客流计数器") + private String productName; + + @Schema(description = "关联类型(TRAFFIC_COUNTER=客流计数器/BEACON=信标/BADGE=工牌)", requiredMode = Schema.RequiredMode.REQUIRED, example = "TRAFFIC_COUNTER") + private String relationType; + + @Schema(description = "配置数据(JSON格式)", example = "{\"threshold\":100}") + private Map configData; + + @Schema(description = "是否启用", requiredMode = Schema.RequiredMode.REQUIRED, example = "true") + private Boolean enabled; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + + @Schema(description = "更新时间") + private LocalDateTime updateTime; + +} diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/vo/area/AreaDeviceUpdateReqVO.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/vo/area/AreaDeviceUpdateReqVO.java new file mode 100644 index 0000000..b902d52 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/vo/area/AreaDeviceUpdateReqVO.java @@ -0,0 +1,28 @@ +package com.viewsh.module.ops.dal.dataobject.vo.area; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.Map; + +/** + * 区域设备关联更新请求 VO + * + * @author lzh + */ +@Schema(description = "管理后台 - 区域设备关联更新 Request VO") +@Data +public class AreaDeviceUpdateReqVO { + + @Schema(description = "关联ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "关联ID不能为空") + private Long id; + + @Schema(description = "配置数据(JSON格式,完全替换)", example = "{\"threshold\":150}") + private Map configData; + + @Schema(description = "是否启用", example = "true") + private Boolean enabled; + +} diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/vo/area/OpsBusAreaCreateReqVO.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/vo/area/OpsBusAreaCreateReqVO.java new file mode 100644 index 0000000..e610670 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/vo/area/OpsBusAreaCreateReqVO.java @@ -0,0 +1,50 @@ +package com.viewsh.module.ops.dal.dataobject.vo.area; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** + * 业务区域新增请求 VO + * + * @author lzh + */ +@Schema(description = "管理后台 - 业务区域新增 Request VO") +@Data +public class OpsBusAreaCreateReqVO { + + @Schema(description = "区域名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "A栋3层") + @NotBlank(message = "区域名称不能为空") + private String areaName; + + @Schema(description = "父级区域ID", example = "1") + private Long parentId; + + @Schema(description = "区域业务编码(可选,全局唯一)", example = "BUILD_A_F3") + private String areaCode; + + @Schema(description = "区域层级类型(PARK=园区/BUILDING=楼栋/FLOOR=楼层/FUNCTION=功能区域)", example = "FLOOR") + private String areaType; + + @Schema(description = "功能类型(MALE_TOILET=男卫/FEMALE_TOILET=女卫/PUBLIC=公共区/ELEVATOR=电梯厅等)", example = "ELEVATOR") + private String functionType; + + @Schema(description = "楼层号(如:2、-1)", example = "3") + private Integer floorNo; + + @Schema(description = "保洁频率(次/天)", example = "3") + private Integer cleaningFrequency; + + @Schema(description = "标准保洁时长(分钟)", example = "30") + private Integer standardDuration; + + @Schema(description = "区域等级(HIGH=高优先级/MEDIUM=中/LOW=低)", example = "MEDIUM") + private String areaLevel; + + @Schema(description = "是否启用", example = "true") + private Boolean isActive; + + @Schema(description = "显示排序", example = "1") + private Integer sort; + +} diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/vo/area/OpsBusAreaPageReqVO.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/vo/area/OpsBusAreaPageReqVO.java new file mode 100644 index 0000000..4fc3b59 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/vo/area/OpsBusAreaPageReqVO.java @@ -0,0 +1,27 @@ +package com.viewsh.module.ops.dal.dataobject.vo.area; + +import com.viewsh.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 业务区域分页查询请求 VO + * + * @author lzh + */ +@Schema(description = "管理后台 - 业务区域分页查询 Request VO") +@Data +@EqualsAndHashCode(callSuper = true) +public class OpsBusAreaPageReqVO extends PageParam { + + @Schema(description = "区域类型(PARK=园区/BUILDING=楼栋/FLOOR=楼层/FUNCTION=功能区域)", example = "PARK") + private String areaType; + + @Schema(description = "是否启用", example = "true") + private Boolean isActive; + + @Schema(description = "区域名称或编码(模糊搜索)", example = "A栋") + private String name; + +} diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/vo/area/OpsBusAreaRespVO.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/vo/area/OpsBusAreaRespVO.java new file mode 100644 index 0000000..77873b7 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/vo/area/OpsBusAreaRespVO.java @@ -0,0 +1,66 @@ +package com.viewsh.module.ops.dal.dataobject.vo.area; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 业务区域响应 VO + * + * @author lzh + */ +@Schema(description = "管理后台 - 业务区域 Response VO") +@Data +public class OpsBusAreaRespVO { + + @Schema(description = "区域ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "100") + private Long id; + + @Schema(description = "父级区域ID", example = "1") + private Long parentId; + + @Schema(description = "父级路径(如:1/2/3,便于查询所有子区域)", example = "/1/2") + private String parentPath; + + @Schema(description = "区域名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "A栋3层") + private String areaName; + + @Schema(description = "区域业务编码(可选,全局唯一)", example = "BUILD_A_F3") + private String areaCode; + + @Schema(description = "区域层级类型(PARK=园区/BUILDING=楼栋/FLOOR=楼层/FUNCTION=功能区域)", example = "FLOOR") + private String areaType; + + @Schema(description = "功能类型(MALE_TOILET=男卫/FEMALE_TOILET=女卫/PUBLIC=公共区/ELEVATOR=电梯厅等)", example = "ELEVATOR") + private String functionType; + + @Schema(description = "楼层号(如:2、-1)", example = "3") + private Integer floorNo; + + @Schema(description = "保洁频率(次/天)", example = "3") + private Integer cleaningFrequency; + + @Schema(description = "标准保洁时长(分钟)", example = "30") + private Integer standardDuration; + + @Schema(description = "区域等级(HIGH=高优先级/MEDIUM=中/LOW=低)", example = "MEDIUM") + private String areaLevel; + + @Schema(description = "是否启用", requiredMode = Schema.RequiredMode.REQUIRED, example = "true") + private Boolean isActive; + + @Schema(description = "显示排序", example = "1") + private Integer sort; + + @Schema(description = "子区域列表(前端自行组装树形结构)", example = "[]") + private List children; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + + @Schema(description = "更新时间") + private LocalDateTime updateTime; + +} diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/vo/area/OpsBusAreaUpdateReqVO.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/vo/area/OpsBusAreaUpdateReqVO.java new file mode 100644 index 0000000..b100b5c --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/dataobject/vo/area/OpsBusAreaUpdateReqVO.java @@ -0,0 +1,53 @@ +package com.viewsh.module.ops.dal.dataobject.vo.area; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 业务区域更新请求 VO + * + * @author lzh + */ +@Schema(description = "管理后台 - 业务区域更新 Request VO") +@Data +public class OpsBusAreaUpdateReqVO { + + @Schema(description = "区域ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "100") + @NotNull(message = "区域ID不能为空") + private Long id; + + @Schema(description = "区域名称", example = "A栋3层") + private String areaName; + + @Schema(description = "父级区域ID", example = "1") + private Long parentId; + + @Schema(description = "区域业务编码(可选,全局唯一)", example = "BUILD_A_F3") + private String areaCode; + + @Schema(description = "区域层级类型(PARK=园区/BUILDING=楼栋/FLOOR=楼层/FUNCTION=功能区域)", example = "FLOOR") + private String areaType; + + @Schema(description = "功能类型(MALE_TOILET=男卫/FEMALE_TOILET=女卫/PUBLIC=公共区/ELEVATOR=电梯厅等)", example = "ELEVATOR") + private String functionType; + + @Schema(description = "楼层号(如:2、-1)", example = "3") + private Integer floorNo; + + @Schema(description = "保洁频率(次/天)", example = "3") + private Integer cleaningFrequency; + + @Schema(description = "标准保洁时长(分钟)", example = "30") + private Integer standardDuration; + + @Schema(description = "区域等级(HIGH=高优先级/MEDIUM=中/LOW=低)", example = "MEDIUM") + private String areaLevel; + + @Schema(description = "是否启用", example = "true") + private Boolean isActive; + + @Schema(description = "显示排序", example = "1") + private Integer sort; + +} diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/mysql/area/OpsAreaDeviceRelationMapper.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/mysql/area/OpsAreaDeviceRelationMapper.java index f0c6c23..02a5960 100644 --- a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/mysql/area/OpsAreaDeviceRelationMapper.java +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/mysql/area/OpsAreaDeviceRelationMapper.java @@ -72,4 +72,47 @@ public interface OpsAreaDeviceRelationMapper extends BaseMapperX() + .eq(OpsAreaDeviceRelationDO::getAreaId, areaId) + .eq(OpsAreaDeviceRelationDO::getRelationType, relationType) + .eq(OpsAreaDeviceRelationDO::getEnabled, true)); + } + + /** + * 查询区域某类型设备的关联记录 + * + * @param areaId 区域ID + * @param deviceId 设备ID + * @param relationType 关联类型 + * @return 关联关系 + */ + default OpsAreaDeviceRelationDO selectByAreaIdAndDeviceIdAndType(Long areaId, Long deviceId, String relationType) { + return selectOne(new LambdaQueryWrapperX() + .eq(OpsAreaDeviceRelationDO::getAreaId, areaId) + .eq(OpsAreaDeviceRelationDO::getDeviceId, deviceId) + .eq(OpsAreaDeviceRelationDO::getRelationType, relationType) + .eq(OpsAreaDeviceRelationDO::getEnabled, true)); + } + + /** + * 统计区域的设备关联数量 + * + * @param areaId 区域ID + * @return 数量 + */ + default Long selectCountByAreaId(Long areaId) { + return selectCount(new LambdaQueryWrapperX() + .eq(OpsAreaDeviceRelationDO::getAreaId, areaId) + .eq(OpsAreaDeviceRelationDO::getEnabled, true)); + } + } diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/mysql/area/OpsBusAreaMapper.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/mysql/area/OpsBusAreaMapper.java index 87dcdcb..6f026c5 100644 --- a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/mysql/area/OpsBusAreaMapper.java +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/dal/mysql/area/OpsBusAreaMapper.java @@ -3,6 +3,7 @@ package com.viewsh.module.ops.dal.mysql.area; import com.viewsh.framework.mybatis.core.mapper.BaseMapperX; import com.viewsh.framework.mybatis.core.query.LambdaQueryWrapperX; import com.viewsh.module.ops.dal.dataobject.area.OpsBusAreaDO; +import com.viewsh.module.ops.dal.dataobject.vo.area.OpsBusAreaPageReqVO; import org.apache.ibatis.annotations.Mapper; import java.util.List; @@ -67,4 +68,39 @@ public interface OpsBusAreaMapper extends BaseMapperX { return selectOne(OpsBusAreaDO::getAreaCode, areaCode); } + /** + * 根据分页查询条件查询列表 + * + * @param reqVO 查询条件 + * @return 区域列表 + */ + default List selectListByPageReq(OpsBusAreaPageReqVO reqVO) { + LambdaQueryWrapperX wrapper = new LambdaQueryWrapperX(); + wrapper.orderByAsc(OpsBusAreaDO::getSort); + + if (reqVO.getAreaType() != null) { + wrapper.eq(OpsBusAreaDO::getAreaType, reqVO.getAreaType()); + } + if (reqVO.getIsActive() != null) { + wrapper.eq(OpsBusAreaDO::getIsActive, reqVO.getIsActive()); + } + if (reqVO.getName() != null) { + wrapper.and(w -> w.like(OpsBusAreaDO::getAreaName, reqVO.getName()) + .or() + .like(OpsBusAreaDO::getAreaCode, reqVO.getName())); + } + + return selectList(wrapper); + } + + /** + * 统计子区域数量 + * + * @param parentId 父级区域ID + * @return 子区域数量 + */ + default Long selectCountByParentId(Long parentId) { + return selectCount(OpsBusAreaDO::getParentId, parentId); + } + } diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/area/AreaDeviceRelationService.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/area/AreaDeviceRelationService.java new file mode 100644 index 0000000..e93216f --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/area/AreaDeviceRelationService.java @@ -0,0 +1,47 @@ +package com.viewsh.module.ops.service.area; + +import com.viewsh.module.ops.dal.dataobject.vo.area.AreaDeviceBindReqVO; +import com.viewsh.module.ops.dal.dataobject.vo.area.AreaDeviceRelationRespVO; +import com.viewsh.module.ops.dal.dataobject.vo.area.AreaDeviceUpdateReqVO; + +import java.util.List; + +/** + * 区域设备关联管理服务 + * + * @author lzh + */ +public interface AreaDeviceRelationService { + + /** + * 查询区域设备列表 + * + * @param areaId 区域ID + * @return 设备关联列表 + */ + List listByAreaId(Long areaId); + + /** + * 绑定设备到区域 + * + * @param bindReq 绑定请求 + * @return 关联ID + */ + Long bindDevice(AreaDeviceBindReqVO bindReq); + + /** + * 更新设备关联 + * + * @param updateReq 更新请求 + */ + void updateRelation(AreaDeviceUpdateReqVO updateReq); + + /** + * 解除设备绑定 + * + * @param id 关联ID + * @return 是否解除成功 + */ + Boolean unbindDevice(Long id); + +} diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/area/AreaDeviceRelationServiceImpl.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/area/AreaDeviceRelationServiceImpl.java new file mode 100644 index 0000000..7c12bbc --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/area/AreaDeviceRelationServiceImpl.java @@ -0,0 +1,186 @@ +package com.viewsh.module.ops.service.area; + +import cn.hutool.core.bean.BeanUtil; +import com.viewsh.framework.common.pojo.CommonResult; +import com.viewsh.framework.common.exception.util.ServiceExceptionUtil; +import com.viewsh.module.iot.api.device.IotDeviceQueryApi; +import com.viewsh.module.iot.api.device.dto.IotDeviceSimpleRespDTO; +import com.viewsh.module.ops.dal.dataobject.area.OpsAreaDeviceRelationDO; +import com.viewsh.module.ops.dal.mysql.area.OpsAreaDeviceRelationMapper; +import com.viewsh.module.ops.dal.mysql.area.OpsBusAreaMapper; +import com.viewsh.module.ops.dal.dataobject.vo.area.AreaDeviceBindReqVO; +import com.viewsh.module.ops.dal.dataobject.vo.area.AreaDeviceRelationRespVO; +import com.viewsh.module.ops.dal.dataobject.vo.area.AreaDeviceUpdateReqVO; +import com.viewsh.module.ops.enums.ErrorCodeConstants; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static com.viewsh.module.ops.enums.AreaTypeEnum.*; + +/** + * 区域设备关联管理服务实现 + * + * @author lzh + */ +@Service +@Validated +@Slf4j +public class AreaDeviceRelationServiceImpl implements AreaDeviceRelationService { + + @Resource + private OpsAreaDeviceRelationMapper opsAreaDeviceRelationMapper; + + @Resource + private OpsBusAreaMapper opsBusAreaMapper; + + @Resource + private IotDeviceQueryApi iotDeviceQueryApi; + + private static final String TYPE_TRAFFIC_COUNTER = "TRAFFIC_COUNTER"; + private static final String TYPE_BEACON = "BEACON"; + private static final String TYPE_BADGE = "BADGE"; + + @Override + public List listByAreaId(Long areaId) { + List list = opsAreaDeviceRelationMapper.selectListByAreaId(areaId); + return list.stream() + .map(this::convertToRespVO) + .collect(java.util.stream.Collectors.toList()); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Long bindDevice(AreaDeviceBindReqVO bindReq) { + // 校验区域存在 + if (opsBusAreaMapper.selectById(bindReq.getAreaId()) == null) { + throw ServiceExceptionUtil.exception(ErrorCodeConstants.AREA_NOT_FOUND); + } + + // 根据关联类型进行唯一性校验 + String relationType = bindReq.getRelationType(); + + // 1对1 约束:TRAFFIC_COUNTER 和 BEACON + if (TYPE_TRAFFIC_COUNTER.equals(relationType) || TYPE_BEACON.equals(relationType)) { + Long count = opsAreaDeviceRelationMapper.countByAreaIdAndType(bindReq.getAreaId(), relationType); + if (count > 0) { + String typeName = TYPE_TRAFFIC_COUNTER.equals(relationType) ? "客流计数器" : "信标"; + throw ServiceExceptionUtil.exception(ErrorCodeConstants.DEVICE_TYPE_ALREADY_BOUND, typeName); + } + } + + // N对1 约束:BADGE - 同一设备只能绑定一次到同一区域 + if (TYPE_BADGE.equals(relationType)) { + OpsAreaDeviceRelationDO existing = opsAreaDeviceRelationMapper.selectByAreaIdAndDeviceIdAndType( + bindReq.getAreaId(), bindReq.getDeviceId(), relationType); + if (existing != null) { + throw ServiceExceptionUtil.exception(ErrorCodeConstants.DEVICE_ALREADY_BOUND); + } + } + + // 调用 IoT 接口获取设备信息 + String deviceKey = "DEVICE_" + bindReq.getDeviceId(); + Long productId = 0L; + String productKey = "PRODUCT_DEFAULT"; + + try { + CommonResult result = iotDeviceQueryApi.getDevice(bindReq.getDeviceId()); + if (result != null && result.getData() != null) { + IotDeviceSimpleRespDTO device = result.getData(); + deviceKey = device.getDeviceName() != null ? device.getDeviceName() : deviceKey; + productId = device.getProductId() != null ? device.getProductId() : 0L; + productKey = device.getProductKey() != null ? device.getProductKey() : productKey; + } + } catch (Exception e) { + log.warn("[bindDevice] 调用 IoT 接口获取设备信息失败: deviceId={}, error={}", + bindReq.getDeviceId(), e.getMessage()); + // 降级:使用默认值 + } + + OpsAreaDeviceRelationDO relation = OpsAreaDeviceRelationDO.builder() + .areaId(bindReq.getAreaId()) + .deviceId(bindReq.getDeviceId()) + .deviceKey(deviceKey) + .productId(productId) + .productKey(productKey) + .relationType(bindReq.getRelationType()) + .configData(bindReq.getConfigData() != null ? bindReq.getConfigData() : new HashMap<>()) + .enabled(true) + .build(); + + opsAreaDeviceRelationMapper.insert(relation); + return relation.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateRelation(AreaDeviceUpdateReqVO updateReq) { + OpsAreaDeviceRelationDO existing = opsAreaDeviceRelationMapper.selectById(updateReq.getId()); + if (existing == null) { + throw ServiceExceptionUtil.exception(ErrorCodeConstants.DEVICE_RELATION_NOT_FOUND); + } + + OpsAreaDeviceRelationDO relation = new OpsAreaDeviceRelationDO(); + relation.setId(updateReq.getId()); + + // configData 完全替换 + if (updateReq.getConfigData() != null) { + relation.setConfigData(updateReq.getConfigData()); + } + + // enabled 更新 + if (updateReq.getEnabled() != null) { + relation.setEnabled(updateReq.getEnabled()); + } + + opsAreaDeviceRelationMapper.updateById(relation); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean unbindDevice(Long id) { + OpsAreaDeviceRelationDO existing = opsAreaDeviceRelationMapper.selectById(id); + if (existing == null) { + return false; + } + + return opsAreaDeviceRelationMapper.deleteById(id) > 0; + } + + /** + * 转换为响应 VO + * 从 IoT 模块获取设备名称和产品名称(带降级) + */ + private AreaDeviceRelationRespVO convertToRespVO(OpsAreaDeviceRelationDO relation) { + AreaDeviceRelationRespVO respVO = BeanUtil.copyProperties(relation, AreaDeviceRelationRespVO.class); + + // 从 IoT 模块获取设备信息 + try { + CommonResult result = iotDeviceQueryApi.getDevice(relation.getDeviceId()); + if (result != null && result.getData() != null) { + IotDeviceSimpleRespDTO device = result.getData(); + respVO.setDeviceName(device.getDeviceName()); + respVO.setProductName(device.getProductName()); + } else { + // 降级:使用默认值 + respVO.setDeviceName("设备_" + relation.getDeviceId()); + respVO.setProductName("产品_" + relation.getProductKey()); + } + } catch (Exception e) { + log.warn("[convertToRespVO] 调用 IoT 接口获取设备信息失败: deviceId={}, error={}", + relation.getDeviceId(), e.getMessage()); + // 降级:使用默认值 + respVO.setDeviceName("设备_" + relation.getDeviceId()); + respVO.setProductName("产品_" + relation.getProductKey()); + } + + return respVO; + } + +} diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/area/OpsBusAreaService.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/area/OpsBusAreaService.java new file mode 100644 index 0000000..2e39462 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/area/OpsBusAreaService.java @@ -0,0 +1,57 @@ +package com.viewsh.module.ops.service.area; + +import com.viewsh.module.ops.dal.dataobject.area.OpsBusAreaDO; +import com.viewsh.module.ops.dal.dataobject.vo.area.OpsBusAreaCreateReqVO; +import com.viewsh.module.ops.dal.dataobject.vo.area.OpsBusAreaPageReqVO; +import com.viewsh.module.ops.dal.dataobject.vo.area.OpsBusAreaRespVO; +import com.viewsh.module.ops.dal.dataobject.vo.area.OpsBusAreaUpdateReqVO; + +import java.util.List; + +/** + * 业务区域管理服务 + * + * @author lzh + */ +public interface OpsBusAreaService { + + /** + * 获取区域列表(平铺,由前端自行组装树形结构) + * + * @param reqVO 查询条件 + * @return 区域列表 + */ + List getAreaTree(OpsBusAreaPageReqVO reqVO); + + /** + * 获取区域详情 + * + * @param id 区域ID + * @return 区域详情 + */ + OpsBusAreaRespVO getArea(Long id); + + /** + * 新增区域 + * + * @param createReq 创建请求 + * @return 区域ID + */ + Long createArea(OpsBusAreaCreateReqVO createReq); + + /** + * 更新区域 + * + * @param updateReq 更新请求 + */ + void updateArea(OpsBusAreaUpdateReqVO updateReq); + + /** + * 删除区域 + * + * @param id 区域ID + * @return 是否删除成功 + */ + Boolean deleteArea(Long id); + +} diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/area/OpsBusAreaServiceImpl.java b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/area/OpsBusAreaServiceImpl.java new file mode 100644 index 0000000..43ec57f --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/main/java/com/viewsh/module/ops/service/area/OpsBusAreaServiceImpl.java @@ -0,0 +1,195 @@ +package com.viewsh.module.ops.service.area; + +import cn.hutool.core.bean.BeanUtil; +import cn.hutool.core.collection.CollUtil; +import com.viewsh.framework.common.exception.util.ServiceExceptionUtil; +import com.viewsh.module.ops.dal.dataobject.area.OpsBusAreaDO; +import com.viewsh.module.ops.dal.mysql.area.OpsAreaDeviceRelationMapper; +import com.viewsh.module.ops.dal.mysql.area.OpsBusAreaMapper; +import com.viewsh.module.ops.dal.dataobject.vo.area.OpsBusAreaCreateReqVO; +import com.viewsh.module.ops.dal.dataobject.vo.area.OpsBusAreaPageReqVO; +import com.viewsh.module.ops.dal.dataobject.vo.area.OpsBusAreaRespVO; +import com.viewsh.module.ops.dal.dataobject.vo.area.OpsBusAreaUpdateReqVO; +import com.viewsh.module.ops.enums.ErrorCodeConstants; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * 业务区域管理服务实现 + * + * @author lzh + */ +@Service +@Validated +public class OpsBusAreaServiceImpl implements OpsBusAreaService { + + @Resource + private OpsBusAreaMapper opsBusAreaMapper; + + @Resource + private OpsAreaDeviceRelationMapper opsAreaDeviceRelationMapper; + + @Override + public List getAreaTree(OpsBusAreaPageReqVO reqVO) { + List list = opsBusAreaMapper.selectListByPageReq(reqVO); + return BeanUtil.copyToList(list, OpsBusAreaRespVO.class); + } + + @Override + public OpsBusAreaRespVO getArea(Long id) { + OpsBusAreaDO area = opsBusAreaMapper.selectById(id); + if (area == null) { + throw ServiceExceptionUtil.exception(ErrorCodeConstants.AREA_NOT_FOUND); + } + return BeanUtil.copyProperties(area, OpsBusAreaRespVO.class); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Long createArea(OpsBusAreaCreateReqVO createReq) { + // 校验区域编码唯一性 + if (createReq.getAreaCode() != null) { + OpsBusAreaDO existing = opsBusAreaMapper.selectByAreaCode(createReq.getAreaCode()); + if (existing != null) { + throw ServiceExceptionUtil.exception(ErrorCodeConstants.AREA_CODE_EXISTS); + } + } + + // 校验父级区域存在 + if (createReq.getParentId() != null) { + OpsBusAreaDO parent = opsBusAreaMapper.selectById(createReq.getParentId()); + if (parent == null) { + throw ServiceExceptionUtil.exception(ErrorCodeConstants.AREA_NOT_FOUND); + } + } + + OpsBusAreaDO area = BeanUtil.copyProperties(createReq, OpsBusAreaDO.class); + + // 计算 parentPath + area.setParentPath(buildParentPath(createReq.getParentId())); + + // 设置默认值 + if (area.getIsActive() == null) { + area.setIsActive(true); + } + if (area.getSort() == null) { + area.setSort(0); + } + + opsBusAreaMapper.insert(area); + return area.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateArea(OpsBusAreaUpdateReqVO updateReq) { + // 校验区域存在 + OpsBusAreaDO existing = opsBusAreaMapper.selectById(updateReq.getId()); + if (existing == null) { + throw ServiceExceptionUtil.exception(ErrorCodeConstants.AREA_NOT_FOUND); + } + + // 如果修改父级,校验父级存在且不能设置为自己或子孙节点 + if (updateReq.getParentId() != null && !updateReq.getParentId().equals(existing.getParentId())) { + // 校验不能将父级设置为自己 + if (updateReq.getId().equals(updateReq.getParentId())) { + throw ServiceExceptionUtil.exception(ErrorCodeConstants.AREA_PARENT_LOOP); + } + + // 校验父级存在 + OpsBusAreaDO parent = opsBusAreaMapper.selectById(updateReq.getParentId()); + if (parent == null) { + throw ServiceExceptionUtil.exception(ErrorCodeConstants.AREA_NOT_FOUND); + } + + // 校验不能将父级设置为子孙节点 + if (isDescendant(updateReq.getId(), updateReq.getParentId())) { + throw ServiceExceptionUtil.exception(ErrorCodeConstants.AREA_PARENT_LOOP); + } + + // 更新 parentPath + String newParentPath = buildParentPath(updateReq.getParentId()); + updateReq.setParentId(updateReq.getParentId()); + // 注意:这里需要在 DO 中设置 parentPath,但由于 UpdateReqVO 没有 parentPath 字段 + // 我们需要在后续更新时处理 + } + + OpsBusAreaDO area = BeanUtil.copyProperties(updateReq, OpsBusAreaDO.class); + + // 重新计算 parentPath + if (updateReq.getParentId() != null) { + area.setParentPath(buildParentPath(updateReq.getParentId())); + } else { + area.setParentPath(null); + } + + opsBusAreaMapper.updateById(area); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean deleteArea(Long id) { + // 校验区域存在 + OpsBusAreaDO existing = opsBusAreaMapper.selectById(id); + if (existing == null) { + return false; + } + + // 校验是否有子区域 + Long childCount = opsBusAreaMapper.selectCountByParentId(id); + if (childCount > 0) { + throw ServiceExceptionUtil.exception(ErrorCodeConstants.AREA_HAS_CHILDREN); + } + + // 校验是否有关联设备 + Long deviceCount = opsAreaDeviceRelationMapper.selectCountByAreaId(id); + if (deviceCount > 0) { + throw ServiceExceptionUtil.exception(ErrorCodeConstants.AREA_HAS_DEVICES); + } + + return opsBusAreaMapper.deleteById(id) > 0; + } + + /** + * 构建 parentPath + * + * @param parentId 父级ID + * @return parentPath + */ + private String buildParentPath(Long parentId) { + if (parentId == null) { + return null; + } + OpsBusAreaDO parent = opsBusAreaMapper.selectById(parentId); + if (parent == null) { + return null; + } + if (parent.getParentPath() == null || parent.getParentPath().isEmpty()) { + return String.valueOf(parentId); + } + return parent.getParentPath() + "/" + parentId; + } + + /** + * 判断 targetId 是否是 sourceId 的子孙节点 + * + * @param sourceId 源节点ID + * @param targetId 目标节点ID + * @return 是否是子孙节点 + */ + private boolean isDescendant(Long sourceId, Long targetId) { + OpsBusAreaDO target = opsBusAreaMapper.selectById(targetId); + if (target == null || target.getParentPath() == null) { + return false; + } + return target.getParentPath().contains("/" + sourceId + "/") + || target.getParentPath().startsWith(sourceId + "/") + || target.getParentPath().endsWith("/" + sourceId); + } + +} diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/service/area/AreaDeviceRelationServiceTest.java b/viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/service/area/AreaDeviceRelationServiceTest.java new file mode 100644 index 0000000..f1ce657 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/service/area/AreaDeviceRelationServiceTest.java @@ -0,0 +1,240 @@ +package com.viewsh.module.ops.service.area; + +import com.viewsh.module.ops.dal.dataobject.area.OpsAreaDeviceRelationDO; +import com.viewsh.module.ops.dal.dataobject.area.OpsBusAreaDO; +import com.viewsh.module.ops.dal.mysql.area.OpsAreaDeviceRelationMapper; +import com.viewsh.module.ops.dal.mysql.area.OpsBusAreaMapper; +import com.viewsh.module.ops.dal.dataobject.vo.area.AreaDeviceBindReqVO; +import com.viewsh.module.ops.dal.dataobject.vo.area.AreaDeviceRelationRespVO; +import com.viewsh.module.ops.dal.dataobject.vo.area.AreaDeviceUpdateReqVO; +import com.viewsh.module.ops.enums.ErrorCodeConstants; +import com.viewsh.framework.common.exception.ServiceException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 区域设备关联管理服务测试 + * + * @author lzh + */ +@ExtendWith(MockitoExtension.class) +class AreaDeviceRelationServiceTest { + + @Mock + private OpsAreaDeviceRelationMapper opsAreaDeviceRelationMapper; + + @Mock + private OpsBusAreaMapper opsBusAreaMapper; + + @InjectMocks + private AreaDeviceRelationServiceImpl areaDeviceRelationService; + + private OpsBusAreaDO testArea; + private OpsAreaDeviceRelationDO testRelation; + + @BeforeEach + void setUp() { + testArea = OpsBusAreaDO.builder() + .id(100L) + .areaName("A栋3层电梯厅") + .build(); + + testRelation = OpsAreaDeviceRelationDO.builder() + .id(1L) + .areaId(100L) + .deviceId(50001L) + .deviceKey("TRAFFIC_COUNTER_001") + .productId(10L) + .productKey("traffic_counter_v1") + .relationType("TRAFFIC_COUNTER") + .configData(new HashMap<>()) + .enabled(true) + .build(); + } + + @Test + void testListByAreaId_Success() { + // Given + when(opsAreaDeviceRelationMapper.selectListByAreaId(100L)) + .thenReturn(Arrays.asList(testRelation)); + + // When + List result = areaDeviceRelationService.listByAreaId(100L); + + // Then + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals(50001L, result.get(0).getDeviceId()); + verify(opsAreaDeviceRelationMapper, times(1)).selectListByAreaId(100L); + } + + @Test + void testListByAreaId_Empty_Success() { + // Given + when(opsAreaDeviceRelationMapper.selectListByAreaId(100L)) + .thenReturn(Collections.emptyList()); + + // When + List result = areaDeviceRelationService.listByAreaId(100L); + + // Then + assertNotNull(result); + assertTrue(result.isEmpty()); + } + + @Test + void testBindDevice_TrafficCounter_Success() { + // Given: 1对1约束,但区域尚无该类型设备 + AreaDeviceBindReqVO bindReq = new AreaDeviceBindReqVO(); + bindReq.setAreaId(100L); + bindReq.setDeviceId(50001L); + bindReq.setRelationType("TRAFFIC_COUNTER"); + + when(opsBusAreaMapper.selectById(100L)).thenReturn(testArea); + when(opsAreaDeviceRelationMapper.countByAreaIdAndType(100L, "TRAFFIC_COUNTER")).thenReturn(0L); + when(opsAreaDeviceRelationMapper.insert(any(OpsAreaDeviceRelationDO.class))).thenAnswer(invocation -> { + OpsAreaDeviceRelationDO relation = invocation.getArgument(0); + relation.setId(1L); + return 1; + }); + + // When + Long relationId = areaDeviceRelationService.bindDevice(bindReq); + + // Then + assertNotNull(relationId); + verify(opsAreaDeviceRelationMapper, times(1)).insert(any(OpsAreaDeviceRelationDO.class)); + } + + @Test + void testBindDevice_TrafficCounter_AlreadyBound_ThrowsException() { + // Given: PBT - 1对1约束,区域已有客流计数器 + AreaDeviceBindReqVO bindReq = new AreaDeviceBindReqVO(); + bindReq.setAreaId(100L); + bindReq.setDeviceId(50002L); + bindReq.setRelationType("TRAFFIC_COUNTER"); + + when(opsBusAreaMapper.selectById(100L)).thenReturn(testArea); + when(opsAreaDeviceRelationMapper.countByAreaIdAndType(100L, "TRAFFIC_COUNTER")).thenReturn(1L); + + // When & Then + ServiceException exception = assertThrows(ServiceException.class, + () -> areaDeviceRelationService.bindDevice(bindReq)); + assertEquals(ErrorCodeConstants.DEVICE_TYPE_ALREADY_BOUND.getCode(), exception.getCode()); + } + + @Test + void testBindDevice_Badge_AlreadyBound_ThrowsException() { + // Given: PBT - N对1约束,同一设备已绑定该区域 + AreaDeviceBindReqVO bindReq = new AreaDeviceBindReqVO(); + bindReq.setAreaId(100L); + bindReq.setDeviceId(30001L); + bindReq.setRelationType("BADGE"); + + when(opsBusAreaMapper.selectById(100L)).thenReturn(testArea); + when(opsAreaDeviceRelationMapper.selectByAreaIdAndDeviceIdAndType(100L, 30001L, "BADGE")) + .thenReturn(testRelation); + + // When & Then + ServiceException exception = assertThrows(ServiceException.class, + () -> areaDeviceRelationService.bindDevice(bindReq)); + assertEquals(ErrorCodeConstants.DEVICE_ALREADY_BOUND.getCode(), exception.getCode()); + } + + @Test + void testBindDevice_AreaNotFound_ThrowsException() { + // Given + AreaDeviceBindReqVO bindReq = new AreaDeviceBindReqVO(); + bindReq.setAreaId(100L); + bindReq.setDeviceId(50001L); + bindReq.setRelationType("TRAFFIC_COUNTER"); + + when(opsBusAreaMapper.selectById(100L)).thenReturn(null); + + // When & Then + ServiceException exception = assertThrows(ServiceException.class, + () -> areaDeviceRelationService.bindDevice(bindReq)); + assertEquals(ErrorCodeConstants.AREA_NOT_FOUND.getCode(), exception.getCode()); + } + + @Test + void testUpdateRelation_Success() { + // Given + AreaDeviceUpdateReqVO updateReq = new AreaDeviceUpdateReqVO(); + updateReq.setId(1L); + + Map newConfig = new HashMap<>(); + newConfig.put("threshold", 150); + + when(opsAreaDeviceRelationMapper.selectById(1L)).thenReturn(testRelation); + when(opsAreaDeviceRelationMapper.updateById(any(OpsAreaDeviceRelationDO.class))).thenReturn(1); + + // When + assertDoesNotThrow(() -> areaDeviceRelationService.updateRelation(updateReq)); + + // Then + verify(opsAreaDeviceRelationMapper, times(1)).updateById(any(OpsAreaDeviceRelationDO.class)); + } + + @Test + void testUpdateRelation_NotFound_ThrowsException() { + // Given + AreaDeviceUpdateReqVO updateReq = new AreaDeviceUpdateReqVO(); + updateReq.setId(999L); + + when(opsAreaDeviceRelationMapper.selectById(999L)).thenReturn(null); + + // When & Then + ServiceException exception = assertThrows(ServiceException.class, + () -> areaDeviceRelationService.updateRelation(updateReq)); + assertEquals(ErrorCodeConstants.DEVICE_RELATION_NOT_FOUND.getCode(), exception.getCode()); + } + + @Test + void testUnbindDevice_Success() { + // Given + when(opsAreaDeviceRelationMapper.selectById(1L)).thenReturn(testRelation); + when(opsAreaDeviceRelationMapper.deleteById(1L)).thenReturn(1); + + // When + Boolean result = areaDeviceRelationService.unbindDevice(1L); + + // Then + assertTrue(result); + verify(opsAreaDeviceRelationMapper, times(1)).deleteById(1L); + } + + @Test + void testUnbindDevice_Idempotency() { + // Given: PBT - 幂等性,解除不存在的关联返回 false + when(opsAreaDeviceRelationMapper.selectById(1L)).thenReturn(null); + + // When + Boolean result = areaDeviceRelationService.unbindDevice(1L); + + // Then + assertFalse(result); // 第一次就返回false + verify(opsAreaDeviceRelationMapper, never()).deleteById(anyLong()); + } + +} diff --git a/viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/service/area/OpsBusAreaServiceTest.java b/viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/service/area/OpsBusAreaServiceTest.java new file mode 100644 index 0000000..42099d1 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-biz/src/test/java/com/viewsh/module/ops/service/area/OpsBusAreaServiceTest.java @@ -0,0 +1,284 @@ +package com.viewsh.module.ops.service.area; + +import com.viewsh.module.ops.dal.dataobject.area.OpsBusAreaDO; +import com.viewsh.module.ops.dal.mysql.area.OpsAreaDeviceRelationMapper; +import com.viewsh.module.ops.dal.mysql.area.OpsBusAreaMapper; +import com.viewsh.module.ops.dal.dataobject.vo.area.OpsBusAreaCreateReqVO; +import com.viewsh.module.ops.dal.dataobject.vo.area.OpsBusAreaPageReqVO; +import com.viewsh.module.ops.dal.dataobject.vo.area.OpsBusAreaUpdateReqVO; +import com.viewsh.module.ops.enums.ErrorCodeConstants; +import com.viewsh.framework.common.exception.ServiceException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 业务区域管理服务测试 + *

+ * 测试内容: + * 1. CRUD 基础操作 + * 2. parent_path 不变性 + * 3. 删除校验(子区域、设备关联) + * 4. 父子循环检测 + * + * @author lzh + */ +@ExtendWith(MockitoExtension.class) +class OpsBusAreaServiceTest { + + @Mock + private OpsBusAreaMapper opsBusAreaMapper; + + @Mock + private OpsAreaDeviceRelationMapper opsAreaDeviceRelationMapper; + + @InjectMocks + private OpsBusAreaServiceImpl opsBusAreaService; + + private OpsBusAreaDO testArea; + + @BeforeEach + void setUp() { + // 初始化测试区域 + testArea = OpsBusAreaDO.builder() + .id(1L) + .parentId(null) + .parentPath(null) + .areaName("A园区") + .areaCode("PARK_A") + .areaType("PARK") + .isActive(true) + .sort(1) + .build(); + } + + @Test + void testGetAreaTree_Success() { + // Given + OpsBusAreaPageReqVO reqVO = new OpsBusAreaPageReqVO(); + reqVO.setAreaType("PARK"); + + when(opsBusAreaMapper.selectListByPageReq(any())).thenReturn(Arrays.asList(testArea)); + + // When + var result = opsBusAreaService.getAreaTree(reqVO); + + // Then + assertNotNull(result); + assertEquals(1, result.size()); + assertEquals("A园区", result.get(0).getAreaName()); + verify(opsBusAreaMapper, times(1)).selectListByPageReq(any()); + } + + @Test + void testGetArea_Success() { + // Given + when(opsBusAreaMapper.selectById(1L)).thenReturn(testArea); + + // When + var result = opsBusAreaService.getArea(1L); + + // Then + assertNotNull(result); + assertEquals("A园区", result.getAreaName()); + assertEquals(1L, result.getId()); + } + + @Test + void testGetArea_NotFound_ThrowsException() { + // Given + when(opsBusAreaMapper.selectById(999L)).thenReturn(null); + + // When & Then + ServiceException exception = assertThrows(ServiceException.class, () -> opsBusAreaService.getArea(999L)); + assertEquals(ErrorCodeConstants.AREA_NOT_FOUND.getCode(), exception.getCode()); + } + + @Test + void testCreateArea_Root_Success() { + // Given: 根区域(无父级) + OpsBusAreaCreateReqVO createReq = new OpsBusAreaCreateReqVO(); + createReq.setAreaName("A园区"); + createReq.setAreaCode("PARK_A"); + createReq.setAreaType("PARK"); + + when(opsBusAreaMapper.selectByAreaCode("PARK_A")).thenReturn(null); + when(opsBusAreaMapper.insert(any(OpsBusAreaDO.class))).thenAnswer(invocation -> { + OpsBusAreaDO area = invocation.getArgument(0); + area.setId(1L); + return 1; + }); + + // When + Long areaId = opsBusAreaService.createArea(createReq); + + // Then + assertNotNull(areaId); + verify(opsBusAreaMapper, times(1)).insert(argThat((OpsBusAreaDO area) -> + "A园区".equals(area.getAreaName()) && + area.getParentPath() == null && + area.getIsActive() && + area.getSort() == 0 + )); + } + + @Test + void testCreateArea_WithParent_Success() { + // Given: 子区域 + OpsBusAreaCreateReqVO createReq = new OpsBusAreaCreateReqVO(); + createReq.setAreaName("A栋"); + createReq.setParentId(1L); + createReq.setAreaType("BUILDING"); + + OpsBusAreaDO parentArea = OpsBusAreaDO.builder() + .id(1L) + .parentPath("10") + .build(); + + when(opsBusAreaMapper.selectById(1L)).thenReturn(parentArea); + when(opsBusAreaMapper.insert(any(OpsBusAreaDO.class))).thenAnswer(invocation -> { + OpsBusAreaDO area = invocation.getArgument(0); + area.setId(2L); + return 1; + }); + + // When + Long areaId = opsBusAreaService.createArea(createReq); + + // Then + assertNotNull(areaId); + verify(opsBusAreaMapper, times(1)).insert(argThat((OpsBusAreaDO area) -> + "A栋".equals(area.getAreaName()) && + "10/1".equals(area.getParentPath()) // PBT: parent_path 不变性 + )); + } + + @Test + void testCreateArea_DuplicateCode_ThrowsException() { + // Given: 区域编码已存在 + OpsBusAreaCreateReqVO createReq = new OpsBusAreaCreateReqVO(); + createReq.setAreaName("A园区"); + createReq.setAreaCode("PARK_A"); + + when(opsBusAreaMapper.selectByAreaCode("PARK_A")).thenReturn(testArea); + + // When & Then + ServiceException exception = assertThrows(ServiceException.class, () -> opsBusAreaService.createArea(createReq)); + assertEquals(ErrorCodeConstants.AREA_CODE_EXISTS.getCode(), exception.getCode()); + } + + @Test + void testCreateArea_ParentNotFound_ThrowsException() { + // Given: 父级不存在 + OpsBusAreaCreateReqVO createReq = new OpsBusAreaCreateReqVO(); + createReq.setAreaName("A栋"); + createReq.setParentId(999L); + + when(opsBusAreaMapper.selectById(999L)).thenReturn(null); + + // When & Then + ServiceException exception = assertThrows(ServiceException.class, () -> opsBusAreaService.createArea(createReq)); + assertEquals(ErrorCodeConstants.AREA_NOT_FOUND.getCode(), exception.getCode()); + } + + @Test + void testUpdateArea_Success() { + // Given + OpsBusAreaUpdateReqVO updateReq = new OpsBusAreaUpdateReqVO(); + updateReq.setId(1L); + updateReq.setAreaName("A园区(更新)"); + + when(opsBusAreaMapper.selectById(1L)).thenReturn(testArea); + when(opsBusAreaMapper.updateById(any(OpsBusAreaDO.class))).thenReturn(1); + + // When + assertDoesNotThrow(() -> opsBusAreaService.updateArea(updateReq)); + + // Then + verify(opsBusAreaMapper, times(1)).updateById(any(OpsBusAreaDO.class)); + } + + @Test + void testUpdateArea_ParentLoop_Self_ThrowsException() { + // Given: 将父级设置为自己 + OpsBusAreaUpdateReqVO updateReq = new OpsBusAreaUpdateReqVO(); + updateReq.setId(1L); + updateReq.setParentId(1L); + + when(opsBusAreaMapper.selectById(1L)).thenReturn(testArea); + + // When & Then + ServiceException exception = assertThrows(ServiceException.class, () -> opsBusAreaService.updateArea(updateReq)); + assertEquals(ErrorCodeConstants.AREA_PARENT_LOOP.getCode(), exception.getCode()); + } + + @Test + void testDeleteArea_Success() { + // Given + when(opsBusAreaMapper.selectById(1L)).thenReturn(testArea); + when(opsBusAreaMapper.selectCountByParentId(1L)).thenReturn(0L); + when(opsAreaDeviceRelationMapper.selectCountByAreaId(1L)).thenReturn(0L); + when(opsBusAreaMapper.deleteById(1L)).thenReturn(1); + + // When + Boolean result = opsBusAreaService.deleteArea(1L); + + // Then + assertTrue(result); + verify(opsBusAreaMapper, times(1)).deleteById(1L); + } + + @Test + void testDeleteArea_HasChildren_ThrowsException() { + // Given: 有子区域 + when(opsBusAreaMapper.selectById(1L)).thenReturn(testArea); + when(opsBusAreaMapper.selectCountByParentId(1L)).thenReturn(2L); + + // When & Then + ServiceException exception = assertThrows(ServiceException.class, () -> opsBusAreaService.deleteArea(1L)); + assertEquals(ErrorCodeConstants.AREA_HAS_CHILDREN.getCode(), exception.getCode()); + } + + @Test + void testDeleteArea_HasDevices_ThrowsException() { + // Given: 有关联设备 + when(opsBusAreaMapper.selectById(1L)).thenReturn(testArea); + when(opsBusAreaMapper.selectCountByParentId(1L)).thenReturn(0L); + when(opsAreaDeviceRelationMapper.selectCountByAreaId(1L)).thenReturn(3L); + + // When & Then + ServiceException exception = assertThrows(ServiceException.class, () -> opsBusAreaService.deleteArea(1L)); + assertEquals(ErrorCodeConstants.AREA_HAS_DEVICES.getCode(), exception.getCode()); + } + + @Test + void testDeleteArea_Idempotency() { + // Given: PBT - 幂等性,删除不存在的区域返回 false + when(opsBusAreaMapper.selectById(1L)).thenReturn(null); + + // When + Boolean result = opsBusAreaService.deleteArea(1L); + + // Then + assertFalse(result); // 第一次就返回 false + verify(opsBusAreaMapper, never()).deleteById(anyLong()); + } + +} diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/area/AreaDeviceRelationController.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/area/AreaDeviceRelationController.java new file mode 100644 index 0000000..87f7711 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/area/AreaDeviceRelationController.java @@ -0,0 +1,76 @@ +package com.viewsh.module.ops.controller.admin.area; + +import com.viewsh.framework.apilog.core.annotation.ApiAccessLog; +import com.viewsh.framework.common.pojo.CommonResult; +import com.viewsh.module.ops.dal.dataobject.vo.area.AreaDeviceBindReqVO; +import com.viewsh.module.ops.dal.dataobject.vo.area.AreaDeviceRelationRespVO; +import com.viewsh.module.ops.dal.dataobject.vo.area.AreaDeviceUpdateReqVO; +import com.viewsh.module.ops.service.area.AreaDeviceRelationService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +import static com.viewsh.framework.apilog.core.enums.OperateTypeEnum.*; +import static com.viewsh.framework.common.pojo.CommonResult.success; + +/** + * 管理后台 - 区域设备关联管理 + * + * @author lzh + */ +@Tag(name = "管理后台 - 区域设备关联") +@Slf4j +@RestController +@RequestMapping("/admin-api/ops/area/device-relation") +@Validated +public class AreaDeviceRelationController { + + @Resource + private AreaDeviceRelationService areaDeviceRelationService; + + @GetMapping("/list") + @Operation(summary = "查询区域已绑定设备列表") + @Parameter(name = "areaId", description = "区域ID", required = true) + // @PreAuthorize("@ss.hasPermission('ops:area:query')") + public CommonResult> listByAreaId(@RequestParam("areaId") Long areaId) { + List list = areaDeviceRelationService.listByAreaId(areaId); + return success(list); + } + + @PostMapping("/bind") + @Operation(summary = "绑定设备到区域") + // @PreAuthorize("@ss.hasPermission('ops:area:bind-device')") + @ApiAccessLog(operateType = CREATE) + public CommonResult bindDevice(@Valid @RequestBody AreaDeviceBindReqVO bindReq) { + Long relationId = areaDeviceRelationService.bindDevice(bindReq); + return success(relationId); + } + + @PutMapping("/update") + @Operation(summary = "更新设备关联配置") + // @PreAuthorize("@ss.hasPermission('ops:area:config-device')") + @ApiAccessLog(operateType = UPDATE) + public CommonResult updateRelation(@Valid @RequestBody AreaDeviceUpdateReqVO updateReq) { + areaDeviceRelationService.updateRelation(updateReq); + return success(true); + } + + @DeleteMapping("/remove") + @Operation(summary = "解除设备绑定") + @Parameter(name = "id", description = "关联ID", required = true) + // @PreAuthorize("@ss.hasPermission('ops:area:bind-device')") + @ApiAccessLog(operateType = DELETE) + public CommonResult unbindDevice(@RequestParam("id") Long id) { + Boolean result = areaDeviceRelationService.unbindDevice(id); + return success(result); + } + +} diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/area/OpsBusAreaController.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/area/OpsBusAreaController.java new file mode 100644 index 0000000..91b718b --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/area/OpsBusAreaController.java @@ -0,0 +1,85 @@ +package com.viewsh.module.ops.controller.admin.area; + +import com.viewsh.framework.apilog.core.annotation.ApiAccessLog; +import com.viewsh.framework.common.pojo.CommonResult; +import com.viewsh.module.ops.dal.dataobject.vo.area.OpsBusAreaCreateReqVO; +import com.viewsh.module.ops.dal.dataobject.vo.area.OpsBusAreaPageReqVO; +import com.viewsh.module.ops.dal.dataobject.vo.area.OpsBusAreaRespVO; +import com.viewsh.module.ops.dal.dataobject.vo.area.OpsBusAreaUpdateReqVO; +import com.viewsh.module.ops.service.area.OpsBusAreaService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +import static com.viewsh.framework.apilog.core.enums.OperateTypeEnum.*; +import static com.viewsh.framework.common.pojo.CommonResult.success; + +/** + * 管理后台 - 业务区域管理 + * + * @author lzh + */ +@Tag(name = "管理后台 - 业务区域") +@Slf4j +@RestController +@RequestMapping("/admin-api/ops/area") +@Validated +public class OpsBusAreaController { + + @Resource + private OpsBusAreaService opsBusAreaService; + + @GetMapping("/tree") + @Operation(summary = "获取区域树(平铺列表,由前端自行组装)") + // @PreAuthorize("@ss.hasPermission('ops:area:query')") + public CommonResult> getAreaTree(OpsBusAreaPageReqVO reqVO) { + List list = opsBusAreaService.getAreaTree(reqVO); + return success(list); + } + + @GetMapping("/get") + @Operation(summary = "获取区域详情") + @Parameter(name = "id", description = "区域ID", required = true) + // @PreAuthorize("@ss.hasPermission('ops:area:query')") + public CommonResult getArea(@RequestParam("id") Long id) { + OpsBusAreaRespVO area = opsBusAreaService.getArea(id); + return success(area); + } + + @PostMapping("/create") + @Operation(summary = "新增区域") + // @PreAuthorize("@ss.hasPermission('ops:area:create')") + @ApiAccessLog(operateType = CREATE) + public CommonResult createArea(@Valid @RequestBody OpsBusAreaCreateReqVO createReq) { + Long areaId = opsBusAreaService.createArea(createReq); + return success(areaId); + } + + @PutMapping("/update") + @Operation(summary = "更新区域") + // @PreAuthorize("@ss.hasPermission('ops:area:update')") + @ApiAccessLog(operateType = UPDATE) + public CommonResult updateArea(@Valid @RequestBody OpsBusAreaUpdateReqVO updateReq) { + opsBusAreaService.updateArea(updateReq); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除区域") + @Parameter(name = "id", description = "区域ID", required = true) + // @PreAuthorize("@ss.hasPermission('ops:area:delete')") + @ApiAccessLog(operateType = DELETE) + public CommonResult deleteArea(@RequestParam("id") Long id) { + Boolean result = opsBusAreaService.deleteArea(id); + return success(result); + } + +} diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/clean/CleanBadgeController.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/clean/CleanBadgeController.java new file mode 100644 index 0000000..f6d5a3e --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/clean/CleanBadgeController.java @@ -0,0 +1,60 @@ +package com.viewsh.module.ops.controller.admin.clean; + +import com.viewsh.framework.common.pojo.CommonResult; +import com.viewsh.module.ops.api.clean.BadgeRealtimeStatusRespDTO; +import com.viewsh.module.ops.api.clean.BadgeStatusRespDTO; +import com.viewsh.module.ops.environment.dal.dataobject.BadgeNotifyReqDTO; +import com.viewsh.module.ops.environment.service.badge.CleanBadgeService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +import static com.viewsh.framework.common.pojo.CommonResult.success; + +/** + * 管理后台 - 保洁工牌 Controller + * + * @author lzh + */ +@Tag(name = "管理后台 - 保洁工牌") +@Slf4j +@RestController +@RequestMapping("/ops/clean/badge") +@Validated +public class CleanBadgeController { + + @Resource + private CleanBadgeService cleanBadgeService; + + @GetMapping("/list") + @Operation(summary = "工牌实时状态列表") + @Parameter(name = "areaId", description = "区域ID", required = false) + @Parameter(name = "status", description = "状态筛选(IDLE/BUSY/OFFLINE/PAUSED)", required = false) + @PreAuthorize("@ss.hasPermission('ops:clean:badge:query')") + public CommonResult> getBadgeList( + @RequestParam(value = "areaId", required = false) Long areaId, + @RequestParam(value = "status", required = false) String status) { + + List result = cleanBadgeService.getBadgeStatusList(areaId, status); + return success(result); + } + + @GetMapping("/realtime/{badgeId}") + @Operation(summary = "工牌实时状态详情") + @Parameter(name = "badgeId", description = "工牌设备ID", required = true) + @PreAuthorize("@ss.hasPermission('ops:clean:badge:query')") + public CommonResult getBadgeRealtimeStatus( + @PathVariable("badgeId") Long badgeId) { + + BadgeRealtimeStatusRespDTO result = cleanBadgeService.getBadgeRealtimeStatus(badgeId); + return success(result); + } +} diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/clean/CleanDashboardController.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/clean/CleanDashboardController.java new file mode 100644 index 0000000..43c12be --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/clean/CleanDashboardController.java @@ -0,0 +1,38 @@ +package com.viewsh.module.ops.controller.admin.clean; + +import com.viewsh.framework.common.pojo.CommonResult; +import com.viewsh.module.ops.api.clean.QuickStatsRespDTO; +import com.viewsh.module.ops.environment.service.dashboard.CleanDashboardService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import static com.viewsh.framework.common.pojo.CommonResult.success; + +/** + * 管理后台 - 保洁仪表盘 Controller + * + * @author lzh + */ +@Tag(name = "管理后台 - 保洁仪表盘") +@Slf4j +@RestController +@RequestMapping("/ops/clean/dashboard") +@Validated +public class CleanDashboardController { + + @Resource + private CleanDashboardService cleanDashboardService; + + @GetMapping("/quick-stats") + @Operation(summary = "快速统计") + @PreAuthorize("@ss.hasPermission('ops:clean:dashboard:query')") + public CommonResult getQuickStats() { + QuickStatsRespDTO result = cleanDashboardService.getQuickStats(); + return success(result); + } +} diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/clean/CleanDeviceController.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/clean/CleanDeviceController.java new file mode 100644 index 0000000..a92ee4d --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/clean/CleanDeviceController.java @@ -0,0 +1,41 @@ +package com.viewsh.module.ops.controller.admin.clean; + +import com.viewsh.framework.common.pojo.CommonResult; +import com.viewsh.module.ops.environment.dal.dataobject.BadgeNotifyReqDTO; +import com.viewsh.module.ops.environment.service.badge.CleanBadgeService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import static com.viewsh.framework.common.pojo.CommonResult.success; + +/** + * 管后台 - 保洁设备 Controller + * + * @author lzh + */ +@Tag(name = "管理后台 - 保洁设备") +@Slf4j +@RestController +@RequestMapping("/ops/clean/device") +@Validated +public class CleanDeviceController { + + @Resource + private CleanBadgeService cleanBadgeService; + + @PostMapping("/notify") + @Operation(summary = "发送工牌通知(语音/震动)") + @PreAuthorize("@ss.hasPermission('ops:clean:device:notify')") + public CommonResult sendBadgeNotify( + @Valid @RequestBody BadgeNotifyReqDTO req) { + + cleanBadgeService.sendBadgeNotify(req); + return success(true); + } +} diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/clean/CleanWorkOrderController.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/clean/CleanWorkOrderController.java new file mode 100644 index 0000000..438d53e --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/controller/admin/clean/CleanWorkOrderController.java @@ -0,0 +1,65 @@ +package com.viewsh.module.ops.controller.admin.clean; + +import com.viewsh.framework.common.pojo.CommonResult; +import com.viewsh.module.ops.api.clean.OrderTimelineRespDTO; +import com.viewsh.module.ops.environment.dal.dataobject.ManualCompleteOrderReqDTO; +import com.viewsh.module.ops.environment.dal.dataobject.UpgradePriorityReqDTO; +import com.viewsh.module.ops.environment.service.cleanorder.CleanWorkOrderService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import static com.viewsh.framework.common.pojo.CommonResult.success; + +/** + * 管理后台 - 保洁工单 Controller + * + * @author lzh + */ +@Tag(name = "管理后台 - 保洁工单") +@Slf4j +@RestController +@RequestMapping("/ops/clean/order") +@Validated +public class CleanWorkOrderController { + + @Resource + private CleanWorkOrderService cleanWorkOrderService; + + @GetMapping("/timeline/{orderId}") + @Operation(summary = "工单时间轴") + @Parameter(name = "orderId", description = "工单ID", required = true) + @PreAuthorize("@ss.hasPermission('ops:clean:order:query')") + public CommonResult getOrderTimeline( + @PathVariable("orderId") Long orderId) { + + OrderTimelineRespDTO result = cleanWorkOrderService.getOrderTimeline(orderId); + return success(result); + } + + @PostMapping("/manual-complete") + @Operation(summary = "手动完成工单") + @PreAuthorize("@ss.hasPermission('ops:clean:order:complete')") + public CommonResult manualCompleteOrder( + @Valid @RequestBody ManualCompleteOrderReqDTO req) { + + cleanWorkOrderService.manualCompleteOrder(req); + return success(true); + } + + @PostMapping("/upgrade-priority") + @Operation(summary = "升级工单优先级") + @PreAuthorize("@ss.hasPermission('ops:clean:order:upgrade')") + public CommonResult upgradePriority( + @Valid @RequestBody UpgradePriorityReqDTO req) { + + cleanWorkOrderService.upgradePriority(req); + return success(true); + } +} diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/framework/rpc/config/RpcConfiguration.java b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/framework/rpc/config/RpcConfiguration.java index e2c5a92..d10bcb9 100644 --- a/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/framework/rpc/config/RpcConfiguration.java +++ b/viewsh-module-ops/viewsh-module-ops-server/src/main/java/com/viewsh/module/ops/framework/rpc/config/RpcConfiguration.java @@ -1,6 +1,7 @@ package com.viewsh.module.ops.framework.rpc.config; import com.viewsh.module.iot.api.device.IotDeviceControlApi; +import com.viewsh.module.iot.api.device.IotDeviceQueryApi; import com.viewsh.module.iot.api.device.IotDeviceStatusQueryApi; import com.viewsh.module.system.api.notify.NotifyMessageSendApi; import org.springframework.cloud.openfeign.EnableFeignClients; @@ -10,6 +11,7 @@ import org.springframework.context.annotation.Configuration; @EnableFeignClients(clients = { NotifyMessageSendApi.class, IotDeviceControlApi.class, + IotDeviceQueryApi.class, IotDeviceStatusQueryApi.class }) public class RpcConfiguration { diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/test/java/com/viewsh/module/ops/controller/admin/area/AreaDeviceRelationControllerTest.java b/viewsh-module-ops/viewsh-module-ops-server/src/test/java/com/viewsh/module/ops/controller/admin/area/AreaDeviceRelationControllerTest.java new file mode 100644 index 0000000..6f01a65 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-server/src/test/java/com/viewsh/module/ops/controller/admin/area/AreaDeviceRelationControllerTest.java @@ -0,0 +1,182 @@ +package com.viewsh.module.ops.controller.admin.area; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.viewsh.module.ops.dal.dataobject.vo.area.AreaDeviceBindReqVO; +import com.viewsh.module.ops.dal.dataobject.vo.area.AreaDeviceRelationRespVO; +import com.viewsh.module.ops.dal.dataobject.vo.area.AreaDeviceUpdateReqVO; +import com.viewsh.module.ops.service.area.AreaDeviceRelationService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 区域设备关联管理 Controller 测试 + * + * @author lzh + */ +@ExtendWith(MockitoExtension.class) +class AreaDeviceRelationControllerTest { + + @Mock + private AreaDeviceRelationService areaDeviceRelationService; + + @InjectMocks + private AreaDeviceRelationController controller; + + private MockMvc mockMvc; + + private ObjectMapper objectMapper; + + private AreaDeviceRelationRespVO testRelationVO; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); + objectMapper = new ObjectMapper(); + + testRelationVO = new AreaDeviceRelationRespVO(); + testRelationVO.setId(1L); + testRelationVO.setAreaId(100L); + testRelationVO.setAreaName("A栋3层电梯厅"); + testRelationVO.setDeviceId(50001L); + testRelationVO.setDeviceKey("TRAFFIC_COUNTER_001"); + testRelationVO.setDeviceName("客流计数器001"); + testRelationVO.setProductId(10L); + testRelationVO.setProductKey("traffic_counter_v1"); + testRelationVO.setProductName("客流计数器"); + testRelationVO.setRelationType("TRAFFIC_COUNTER"); + testRelationVO.setConfigData(new HashMap<>()); + testRelationVO.setEnabled(true); + } + + @Test + void testListByAreaId_Success() throws Exception { + // Given + when(areaDeviceRelationService.listByAreaId(100L)) + .thenReturn(Arrays.asList(testRelationVO)); + + // When & Then + mockMvc.perform(get("/admin-api/ops/area/device-relation/list") + .param("areaId", "100") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data[0].deviceId").value(50001)) + .andExpect(jsonPath("$.data[0].deviceName").value("客流计数器001")); + + verify(areaDeviceRelationService).listByAreaId(100L); + } + + @Test + void testListByAreaId_Empty_Success() throws Exception { + // Given + when(areaDeviceRelationService.listByAreaId(999L)) + .thenReturn(Arrays.asList()); + + // When & Then + mockMvc.perform(get("/admin-api/ops/area/device-relation/list") + .param("areaId", "999") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data").isEmpty()); + } + + @Test + void testBindDevice_Success() throws Exception { + // Given + AreaDeviceBindReqVO bindReq = new AreaDeviceBindReqVO(); + bindReq.setAreaId(100L); + bindReq.setDeviceId(50001L); + bindReq.setRelationType("TRAFFIC_COUNTER"); + + Map config = new HashMap<>(); + config.put("threshold", 100); + bindReq.setConfigData(config); + + when(areaDeviceRelationService.bindDevice(any(AreaDeviceBindReqVO.class))) + .thenReturn(1L); + + // When & Then + mockMvc.perform(post("/admin-api/ops/area/device-relation/bind") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(bindReq))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data").value(1)); + + verify(areaDeviceRelationService).bindDevice(any(AreaDeviceBindReqVO.class)); + } + + @Test + void testUpdateRelation_Success() throws Exception { + // Given + AreaDeviceUpdateReqVO updateReq = new AreaDeviceUpdateReqVO(); + updateReq.setId(1L); + + Map config = new HashMap<>(); + config.put("threshold", 150); + updateReq.setConfigData(config); + + // When & Then + mockMvc.perform(put("/admin-api/ops/area/device-relation/update") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateReq))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + + verify(areaDeviceRelationService).updateRelation(any(AreaDeviceUpdateReqVO.class)); + } + + @Test + void testUnbindDevice_Success() throws Exception { + // Given + when(areaDeviceRelationService.unbindDevice(1L)).thenReturn(true); + + // When & Then + mockMvc.perform(delete("/admin-api/ops/area/device-relation/remove") + .param("id", "1") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data").value(true)); + + verify(areaDeviceRelationService).unbindDevice(1L); + } + + @Test + void testUnbindDevice_NotFound() throws Exception { + // Given + when(areaDeviceRelationService.unbindDevice(999L)).thenReturn(false); + + // When & Then + mockMvc.perform(delete("/admin-api/ops/area/device-relation/remove") + .param("id", "999") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data").value(false)); + + verify(areaDeviceRelationService).unbindDevice(999L); + } + +} diff --git a/viewsh-module-ops/viewsh-module-ops-server/src/test/java/com/viewsh/module/ops/controller/admin/area/OpsBusAreaControllerTest.java b/viewsh-module-ops/viewsh-module-ops-server/src/test/java/com/viewsh/module/ops/controller/admin/area/OpsBusAreaControllerTest.java new file mode 100644 index 0000000..0a68145 --- /dev/null +++ b/viewsh-module-ops/viewsh-module-ops-server/src/test/java/com/viewsh/module/ops/controller/admin/area/OpsBusAreaControllerTest.java @@ -0,0 +1,186 @@ +package com.viewsh.module.ops.controller.admin.area; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.viewsh.module.ops.dal.dataobject.vo.area.OpsBusAreaCreateReqVO; +import com.viewsh.module.ops.dal.dataobject.vo.area.OpsBusAreaPageReqVO; +import com.viewsh.module.ops.dal.dataobject.vo.area.OpsBusAreaRespVO; +import com.viewsh.module.ops.dal.dataobject.vo.area.OpsBusAreaUpdateReqVO; +import com.viewsh.module.ops.service.area.OpsBusAreaService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.util.Arrays; +import java.util.Collections; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 业务区域管理 Controller 测试 + * + * @author lzh + */ +@ExtendWith(MockitoExtension.class) +class OpsBusAreaControllerTest { + + @Mock + private OpsBusAreaService opsBusAreaService; + + @InjectMocks + private OpsBusAreaController controller; + + private MockMvc mockMvc; + + private ObjectMapper objectMapper; + + private OpsBusAreaRespVO testAreaResp; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); + objectMapper = new ObjectMapper(); + + testAreaResp = new OpsBusAreaRespVO(); + testAreaResp.setId(100L); + testAreaResp.setParentId(null); + testAreaResp.setParentPath(null); + testAreaResp.setAreaName("A园区"); + testAreaResp.setAreaCode("PARK_A"); + testAreaResp.setAreaType("PARK"); + testAreaResp.setIsActive(true); + testAreaResp.setSort(1); + } + + @Test + void testGetAreaTree_Success() throws Exception { + // Given + when(opsBusAreaService.getAreaTree(any(OpsBusAreaPageReqVO.class))) + .thenReturn(Arrays.asList(testAreaResp)); + + // When & Then + mockMvc.perform(get("/admin-api/ops/area/tree") + .param("areaType", "PARK") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data[0].areaName").value("A园区")); + + verify(opsBusAreaService).getAreaTree(any(OpsBusAreaPageReqVO.class)); + } + + @Test + void testGetAreaTree_Empty_Success() throws Exception { + // Given + when(opsBusAreaService.getAreaTree(any(OpsBusAreaPageReqVO.class))) + .thenReturn(Collections.emptyList()); + + // When & Then + mockMvc.perform(get("/admin-api/ops/area/tree") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data").isEmpty()); + } + + @Test + void testGetArea_Success() throws Exception { + // Given + when(opsBusAreaService.getArea(100L)).thenReturn(testAreaResp); + + // When & Then + mockMvc.perform(get("/admin-api/ops/area/get") + .param("id", "100") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.id").value(100)) + .andExpect(jsonPath("$.data.areaName").value("A园区")); + + verify(opsBusAreaService).getArea(100L); + } + + @Test + void testCreateArea_Success() throws Exception { + // Given + OpsBusAreaCreateReqVO createReq = new OpsBusAreaCreateReqVO(); + createReq.setAreaName("A园区"); + createReq.setAreaCode("PARK_A"); + createReq.setAreaType("PARK"); + + when(opsBusAreaService.createArea(any(OpsBusAreaCreateReqVO.class))) + .thenReturn(100L); + + // When & Then + mockMvc.perform(post("/admin-api/ops/area/create") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(createReq))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data").value(100)); + + verify(opsBusAreaService).createArea(any(OpsBusAreaCreateReqVO.class)); + } + + @Test + void testUpdateArea_Success() throws Exception { + // Given + OpsBusAreaUpdateReqVO updateReq = new OpsBusAreaUpdateReqVO(); + updateReq.setId(100L); + updateReq.setAreaName("A园区(更新)"); + + // When & Then + mockMvc.perform(put("/admin-api/ops/area/update") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateReq))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)); + + verify(opsBusAreaService).updateArea(any(OpsBusAreaUpdateReqVO.class)); + } + + @Test + void testDeleteArea_Success() throws Exception { + // Given + when(opsBusAreaService.deleteArea(100L)).thenReturn(true); + + // When & Then + mockMvc.perform(delete("/admin-api/ops/area/delete") + .param("id", "100") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data").value(true)); + + verify(opsBusAreaService).deleteArea(100L); + } + + @Test + void testDeleteArea_NotFound() throws Exception { + // Given + when(opsBusAreaService.deleteArea(999L)).thenReturn(false); + + // When & Then + mockMvc.perform(delete("/admin-api/ops/area/delete") + .param("id", "999") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data").value(false)); + + verify(opsBusAreaService).deleteArea(999L); + } + +}