diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 000000000..aca325182
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,106 @@
+# `biz-api-cloud-zax` 开发规则
+
+> 适用于 Java 后端多模块项目。执行任务前必须先完整阅读 [HQ Platform 通用规则](../agents/common.md),并继续检查目标模块内更具体的说明和现有同类实现。
+
+## 技术栈基线
+
+以下为当前父 POM 和 BOM 的扫描结果。实际版本以根 `pom.xml` 与 `pjcloud-common/pjcloud-common-bom/pom.xml` 为准,禁止在子模块中随意覆盖统一版本。
+
+- 工程版本:`com.bsd:pjcloud:5.7.0`,Maven 多模块工程。
+- 运行环境:Java 17、Spring Boot `3.3.6`、Spring Cloud `2023.0.4`、Spring Cloud Alibaba `2023.0.3.2`。
+- 服务治理:Nacos 注册发现与配置中心、Spring Cloud Gateway、OpenFeign、Feign OkHttp、Spring Cloud LoadBalancer、Sentinel、Caffeine。
+- 安全认证:Spring Security、OAuth2/Jose、Spring Authorization Server `1.4.0`,项目内统一安全组件与方法级 `@PreAuthorize` 权限校验。
+- 数据访问:MyBatis-Plus `3.5.9`、MyBatis-Plus Join `1.5.2`、动态数据源 `4.3.1`、Druid `1.2.23`、MySQL Connector/J `8.3.0`。
+- 缓存:Spring Data Redis;网关还使用 Reactive Redis。缓存访问应通过项目公共组件和既有模板,不自行创建不一致的序列化方案。
+- 分布式事务:Seata `1.7.0`,由 `pjcloud-common-seata` 统一封装。
+- 接口与文档:Spring MVC / WebFlux、Springdoc OpenAPI `2.1.0`、Knife4j;统一响应通常使用项目 `R`。
+- 服务容器与构建:业务 Web 服务普遍使用 Undertow,Maven Spring Boot 插件负责打包,Fabric8 Docker Maven Plugin 提供镜像构建能力。
+- 流程与任务:Flowable `7.0.0`、XXL-Job、Quartz、ElasticJob 相关模块。
+- 消息与实时能力:Spring Integration MQTT、WebSocket、SSE。
+- 公共业务能力:OSS、Excel、日志、审计、幂等、接口加解密、数据脱敏、XSS 过滤、分布式序列、灰度路由、数据权限等均已有公共模块。
+- 常用基础库:Lombok、Hutool、Jasypt 等。是否可用仍需看目标模块 POM,不能因为 BOM 管理了版本就直接在任意模块引用。
+
+## 顶层服务与模块职责
+
+- `pjcloud-register`:内置 Nacos 注册/配置中心相关模块。
+- `pjcloud-gateway`:Spring Cloud Gateway 网关,负责动态路由、Reactive Redis、Sentinel 网关限流和聚合接口文档等。
+- `pjcloud-auth`:OAuth2 认证授权中心,依赖统一安全、Feign、Redis、验证码和灰度路由能力。
+- `pjcloud-upms`:用户、部门、角色、菜单、租户、字典等基础权限管理,拆分为 `api` 与 `biz`。
+- `pjcloud-app-server`:移动端应用配置、页面、TabBar 及 App 侧公共服务,拆分为 `api` 与 `biz`。
+- `pjcloud-canteen`:食堂、菜品、库存、预订、配送等业务,拆分为 `api` 与 `biz`。
+- `pjcloud-hardware`:门禁、设备、硬件商品/订单等能力,拆分为 `api` 与 `biz`。
+- `pjcloud-integrated`:访客、会议、反馈、公告、车辆、广告等综合业务,拆分为 `api` 与 `biz`。
+- `pjcloud-wallet`:钱包、充值、交易、商户结算等业务,拆分为 `api` 与 `biz`。
+- `pjcloud-supermarket`:商城、商品、SKU、库存和 ERP 等业务,拆分为 `api` 与 `biz`。
+- `pjcloud-thirdparty`:第三方认证、支付、门禁和外部系统集成,拆分为 `api` 与 `biz`。
+- `pjcloud-mqtt`:MQTT 接入与消息能力,拆分为 `api` 与 `biz`。
+- `pjcloud-pass`:通行相关业务,拆分为 `api` 与 `biz`。
+- `pjcloud-flow`:流程引擎与流程任务,内部继续拆分 engine/task 的 `api` 和 `biz`。
+- `pjcloud-visual`:代码生成、报表、监控、微信/支付平台、Quartz、ElasticJob、XXL-Job 管理端等辅助服务。
+- `pjcloud-boot`:聚合多个业务模块的单体启动方式;修改公共业务时需同时考虑微服务模式和 Boot 聚合模式。
+
+## `api` / `biz` 分层规则
+
+- 典型业务模块使用父聚合模块 + `*-api` + `*-biz` 结构。
+- `*-api` 放跨模块契约:Feign Client、DTO、VO、Entity、常量及必要接口。不得把 Controller、具体 Service 实现或仅本服务内部使用的实现细节放入 API 模块。
+- `*-biz` 放运行实现:启动类、Controller、Service/Impl、Mapper、配置、任务和资源文件。
+- 跨服务只能依赖对方 `*-api` 和 Feign 契约,不得直接依赖对方 `*-biz`、Mapper 或 ServiceImpl。
+- 新增类型前先搜索现有 DTO、VO、Entity 和公共 `R`;请求对象、响应对象、持久化对象职责要分开,不用 Map 或 Entity 随意穿透所有层。
+- Controller 负责参数接收、权限和响应包装,复杂业务放 Service;Mapper 负责数据访问,不在 Controller 堆叠查询与事务逻辑。
+
+## 数据、缓存、事务与远程调用
+
+- 持久化沿用 MyBatis-Plus、项目 `PjcloudBaseMapper`、现有分页 `Page`、Wrapper 与 XML/Mapper 风格。使用 MP Join 或自定义 SQL 前先看同模块已有写法。
+- 多租户、数据权限和审计逻辑可能由公共 Mapper、拦截器或上下文提供。禁止用手写 SQL 绕过租户/数据范围约束。
+- **不得修改数据库字段和表结构。** 需求缺少字段时先使用现有模型评估替代方案;确实必须调整时停止实现并请求用户明确授权。
+- 事务方法应放在 Spring 管理的 Service 层,明确 `rollbackFor`;跨库/跨服务一致性需要评估本地事务、Seata、补偿和幂等,不能把一次远程调用误认为本地事务的一部分。
+- Redis 必须复用已有 Key、序列化、Template/工具类和过期策略。修改数据库数据时同步评估缓存更新或失效;不要新增永久缓存或无命名空间 Key。
+- Redis 基础设施会根据当前租户上下文自动处理租户 Key 前缀;使用 `RedisTemplate`、`StringRedisTemplate`、Redisson 锁及其他 Redis 对象时,业务代码只声明稳定的业务 Key,禁止读取或拼接 `TenantContextHolder.getTenantId()` 等租户编号。跨租户任务应通过 `TenantBroker` 等既有能力切换租户上下文;确需全局共享的 Key 必须沿用项目已有全局 Key 契约,不得用手工拼接方式绕过隔离。
+- 远程调用通过已有 Feign Client,复用统一请求头、内部调用标识、LoadBalancer、Sentinel 降级和异常约定。禁止在业务代码中手写服务地址进行 HTTP 调用。
+- Feign 返回 `R` 时必须检查业务状态与空数据;不得只判断 HTTP 成功。
+- 接口加解密已有 `pjcloud-common-encrypt-api`,前端也已有对应拦截器。不得在单个接口另造不兼容的加密协议。
+
+## 配置、安全与接口约定
+
+- 各服务 `application.yml` 通常只保存启动端口、服务名和 Nacos 引导配置,并导入 `application-{profile}.yml` 与服务级配置。修改配置前同时检查本地文件、环境变量占位符和 Nacos 配置来源。
+- 不得把账号、密码、密钥、Token、证书或生产地址硬编码进仓库。发现现有敏感配置也不得在无关任务中扩散或复制。
+- 方法权限遵循现有 `@PreAuthorize("@pms.hasPermission('...')")` 风格,权限字符串需与管理端菜单/按钮权限对应。
+- 认证、Token、租户头、内部调用头和数据权限由公共安全组件处理;新增公开接口、匿名接口或内部接口必须先核对现有注解和安全白名单机制。
+- 接口路径、HTTP 方法、分页参数、统一返回码、异常处理和 OpenAPI 注解跟随同 Controller 的现有风格。
+- 删除或改变已有字段、枚举值、接口路径和返回结构前必须评估 PC、App 与其他服务调用方;默认保持向后兼容。
+
+## 公共模块复用指引
+
+- 核心工具与返回结构:`pjcloud-common-core`。
+- 数据与 MyBatis 扩展:`pjcloud-common-data`、`pjcloud-common-datasource`。
+- 认证授权:`pjcloud-common-security`。
+- 远程调用:`pjcloud-common-feign`。
+- 限流降级与分布式事务:`pjcloud-common-sentinel`、`pjcloud-common-seata`。
+- 文档与网关:`pjcloud-common-swagger`、`pjcloud-common-gateway`。
+- 日志、审计、幂等:`pjcloud-common-log`、`pjcloud-common-audit`、`pjcloud-common-idempotent`。
+- 文件、Excel、任务:`pjcloud-common-oss`、`pjcloud-common-excel`、`pjcloud-common-job`。
+- 实时通信:`pjcloud-common-websocket`、`pjcloud-common-sse`。
+- 安全辅助:`pjcloud-common-encrypt-api`、`pjcloud-common-sensitive`、`pjcloud-common-xss`。
+- 其他基础设施:`pjcloud-common-gray`、`pjcloud-common-sequence`。新增通用能力前先确认这里是否已有实现。
+
+## 常用命令与验证
+
+- 在后端根目录按模块验证:`mvn -pl <模块路径> -am test`。
+- 仅需编译/打包时:`mvn -pl <模块路径> -am -DskipTests package`,但不能用跳过测试的结果冒充测试通过。
+- 修改 `*-api`、公共模块或 BOM 时,应验证所有直接依赖模块,必要时执行更大范围构建。
+- 修改网关、安全、数据、Feign、缓存、加解密或 Boot 聚合模块时,属于高影响改动,必须扩大测试范围并检查微服务与单体两种启动关系。
+- 当前自动化测试分布不均。集中验证时,无现成测试的模块也应完成针对性编译,并对关键业务补充与现有测试风格一致的测试。
+
+## 开发规则
+
+- 修改前先确认所属业务模块及其 `api` / `biz` 边界,沿用现有 Controller、Service、Mapper、DTO、VO、Feign Client、异常处理、鉴权和统一响应结构。
+- 跨服务调用优先遵循项目现有 OpenFeign 封装、服务名、请求模型、降级与异常处理方式,不得绕过已有边界直接耦合其他服务内部实现。
+- Redis 使用必须复用已有缓存工具、序列化方式、键命名规则和过期策略。新增或修改缓存逻辑时,必须考虑缓存穿透、数据一致性、失效时机和空值处理。
+- MySQL 持久化必须沿用已有 Mapper/ORM、事务和分页方式。未经用户明确同意,不得以任何形式修改数据库结构。
+- 关键业务方法、事务边界、缓存一致性处理、远程调用容错和复杂查询必须添加必要的中文注释。
+- 接口变更必须评估后台管理端、移动端及其他服务调用方的兼容性;优先保持向后兼容。
+- 日常修改不自动运行 Maven 测试、编译或打包;收到用户明确的集中验证指令后,再验证受影响模块,多模块依赖变更同时验证直接依赖模块。
+
+## 完成前项目检查
+
+- [ ] 后端改动已考虑接口兼容、事务、缓存和远程调用影响。
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/hard/HardTerminalFaceDeleteDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/hard/HardTerminalFaceDeleteDto.java
new file mode 100644
index 000000000..c3fb365ca
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/hard/HardTerminalFaceDeleteDto.java
@@ -0,0 +1,21 @@
+package com.bsd.pjcloud.hard.api.dto.hard;
+
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * Android 终端批量删除服务端人脸请求。
+ */
+@Data
+public class HardTerminalFaceDeleteDto {
+
+ /**
+ * 待删除的虹软样本编号,设备部门由请求头解析,客户端不得指定。
+ */
+ @NotNull(message = "人脸样本编号不能为空")
+ @Size(min = 1, max = 500, message = "单次最多删除500条人脸")
+ private List sampleIds;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderDailyStatisticsVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderDailyStatisticsVo.java
new file mode 100644
index 000000000..d52ec565e
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderDailyStatisticsVo.java
@@ -0,0 +1,19 @@
+package com.bsd.pjcloud.hard.api.vo.order;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.time.LocalDate;
+
+/**
+ * 指定预计使用日期的订单统计。
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@Schema(description = "订单每日统计")
+public class HardOrderDailyStatisticsVo extends HardOrderStatisticsSummaryVo {
+
+ @Schema(description = "统计日期")
+ private LocalDate date;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderMealStatisticsVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderMealStatisticsVo.java
new file mode 100644
index 000000000..f5a13bb7c
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderMealStatisticsVo.java
@@ -0,0 +1,39 @@
+package com.bsd.pjcloud.hard.api.vo.order;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+/**
+ * 订单按餐段聚合的统计数据,所有金额统一使用分。
+ */
+@Data
+@Schema(description = "订单餐段统计")
+public class HardOrderMealStatisticsVo {
+
+ @Schema(description = "餐段编码")
+ private String hourType;
+
+ @Schema(description = "订单数")
+ private Long orderCount = 0L;
+
+ @Schema(description = "商品份数")
+ private Long productCount = 0L;
+
+ @Schema(description = "商品总价,单位为分")
+ private Long totalPrice = 0L;
+
+ @Schema(description = "额外支付,单位为分")
+ private Long extraPrice = 0L;
+
+ @Schema(description = "配送费,单位为分")
+ private Long deliveryPrice = 0L;
+
+ @Schema(description = "实际支付,单位为分")
+ private Long payPrice = 0L;
+
+ @Schema(description = "已退款,单位为分")
+ private Long refundPrice = 0L;
+
+ @Schema(description = "订单实际,单位为分")
+ private Long actualPrice = 0L;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderStatisticsSummaryVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderStatisticsSummaryVo.java
new file mode 100644
index 000000000..cd5e36563
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderStatisticsSummaryVo.java
@@ -0,0 +1,42 @@
+package com.bsd.pjcloud.hard.api.vo.order;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 订单统计的通用汇总数据,所有金额统一使用分。
+ */
+@Data
+@Schema(description = "订单统计汇总")
+public class HardOrderStatisticsSummaryVo {
+
+ @Schema(description = "订单数")
+ private Long orderCount = 0L;
+
+ @Schema(description = "商品份数")
+ private Long productCount = 0L;
+
+ @Schema(description = "商品总价,单位为分")
+ private Long totalPrice = 0L;
+
+ @Schema(description = "额外支付,单位为分")
+ private Long extraPrice = 0L;
+
+ @Schema(description = "配送费,单位为分")
+ private Long deliveryPrice = 0L;
+
+ @Schema(description = "实际支付,单位为分")
+ private Long payPrice = 0L;
+
+ @Schema(description = "已退款,单位为分")
+ private Long refundPrice = 0L;
+
+ @Schema(description = "订单实际,单位为分")
+ private Long actualPrice = 0L;
+
+ @Schema(description = "餐段统计")
+ private List meals = new ArrayList<>();
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderStatisticsVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderStatisticsVo.java
new file mode 100644
index 000000000..79d957a2b
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardOrderStatisticsVo.java
@@ -0,0 +1,21 @@
+package com.bsd.pjcloud.hard.api.vo.order;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 订单列表统计响应,同时提供当前筛选范围总计和每日明细。
+ */
+@Data
+@Schema(description = "订单列表统计")
+public class HardOrderStatisticsVo {
+
+ @Schema(description = "当前筛选范围总计")
+ private HardOrderStatisticsSummaryVo total = new HardOrderStatisticsSummaryVo();
+
+ @Schema(description = "按预计使用日期拆分的统计")
+ private List daily = new ArrayList<>();
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/hard/HardApiController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/hard/HardApiController.java
index d5f552950..295bfe3c4 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/hard/HardApiController.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/hard/HardApiController.java
@@ -2,6 +2,7 @@ package com.bsd.pjcloud.hard.controller.hard;
import com.bsd.pjcloud.common.api.encrypt.annotation.crypto.ApiCryptoAes;
+import com.bsd.pjcloud.admin.api.vo.SysUserFaceTerminalDeleteVo;
import cn.hutool.core.util.ObjectUtil;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.common.core.util.R;
@@ -59,6 +60,7 @@ public class HardApiController {
private final HardOrderTotalService hardOrderTotalService;
private final ReservationMenuQueryService reservationMenuQueryService;
private final VerificationRecordService verificationRecordService;
+ private final HardTerminalFaceService hardTerminalFaceService;
/**
* 食堂、超市 Android 终端心跳状态服务。
@@ -92,6 +94,21 @@ public class HardApiController {
return R.ok();
}
+ /**
+ * 删除当前设备商户部门及子部门范围内的人脸。
+ *
+ * @param hardId 当前绑定设备 ID
+ * @param dto 最多 500 条样本编号
+ * @return 实际删除及已不存在的样本编号
+ */
+ @PostMapping("/face/delete")
+ @Operation(summary = "终端批量删除人脸")
+ public R deleteTerminalFaces(
+ @RequestHeader("DEVICE-ID") Long hardId,
+ @Valid @RequestBody HardTerminalFaceDeleteDto dto) {
+ return R.ok(hardTerminalFaceService.delete(hardId, dto));
+ }
+
@GetMapping("/get")
@Operation(summary = "获取硬件配置信息")
public R get(@RequestParam Long id) {
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java
index ebf7559f7..fadbc2607 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java
@@ -41,6 +41,7 @@ import com.bsd.pjcloud.hard.api.dto.order.remote.HardUpdateShopStatusDto;
import com.bsd.pjcloud.hard.api.dto.order.remote.HardUpdateTakeOutStatusDto;
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo;
+import com.bsd.pjcloud.hard.api.vo.order.HardOrderStatisticsVo;
import com.bsd.pjcloud.hard.api.vo.order.HardPendingReservationCountVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppCreateReserveOrdersVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppScanPayOrderVo;
@@ -85,6 +86,7 @@ public class HardOrderController {
private final HardOnlineCanteenStatsService hardOnlineCanteenStatsService;
private final HardOrderSyncService hardOrderSyncService;
private final HardCanteenTotalService canteenTotalService;
+ private final HardOrderStatisticsService hardOrderStatisticsService;
/** 订单导出服务,负责异步任务、EasyExcel 分片写入和文件中心上传。 */
private final HardOrderExportService hardOrderExportService;
@@ -598,6 +600,28 @@ public class HardOrderController {
@PostMapping("/page-join")
@PreAuthorize("@pms.hasPermission('hard_hardwareOrder_view')")
public R pageJoin(@ParameterObject Page page, @RequestBody HardOrderDto dto) {
+ DataScope dataScope = prepareAdminOrderQuery(dto);
+ return R.ok(hardwareOrderService.scopePageList(page, dto, dataScope));
+ }
+
+ /**
+ * 按订单列表当前完整筛选条件统计全部命中订单,不受分页大小影响。
+ *
+ * @param dto 与订单分页请求相同的筛选条件
+ * @return 范围金额总计,以及按预计使用日期、餐段拆分的商品份数和金额
+ */
+ @Operation(summary = "订单列表统计", description = "按订单列表完整筛选条件统计范围总计及日期餐段明细")
+ @PostMapping("/statistics")
+ @PreAuthorize("@pms.hasPermission('hard_hardwareOrder_view')")
+ public R statistics(@RequestBody HardOrderDto dto) {
+ DataScope dataScope = prepareAdminOrderQuery(dto);
+ return R.ok(hardOrderStatisticsService.statistics(dto, dataScope));
+ }
+
+ /**
+ * 为管理端订单分页和统计统一注入设备类型权限、单商户子级范围及商户部门数据权限。
+ */
+ private DataScope prepareAdminOrderQuery(HardOrderDto dto) {
DataScope dataScope = DataScope.of().func(DataScopeFuncEnum.ALL);
dataScope.setScopeDeptName("merchant_dept_id");
PjcloudUser user = SecurityUtils.getUser();
@@ -614,7 +638,7 @@ public class HardOrderController {
dto.setMchIds(mchDtoIds);
}
}
- return R.ok(hardwareOrderService.scopePageList(page, dto, dataScope));
+ return dataScope;
}
@Operation(summary = "分页查询未识别订单", description = "分页查询未识别订单")
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderMapper.java
index f7e54249d..6c05ef4da 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderMapper.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardOrderMapper.java
@@ -16,6 +16,7 @@ import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardParentCarEntity;
import com.bsd.pjcloud.hard.api.vo.hard.PanelPorductVo;
import com.bsd.pjcloud.hard.api.vo.order.*;
+import com.bsd.pjcloud.hard.mapper.order.model.HardOrderStatisticsAggregate;
import com.github.yulichang.wrapper.MPJLambdaWrapper;
import org.apache.ibatis.annotations.Mapper;
@@ -169,6 +170,15 @@ public interface HardOrderMapper extends PjcloudBaseMapper {
List scopeList(@Param("dto") HardOrderDto dto, DataScope dataScope);
+ /**
+ * 按订单列表完整筛选条件统计订单金额、商品份数及日期餐段明细。
+ *
+ * @param dto 与管理端订单分页相同的查询条件
+ * @param dataScope 与管理端订单分页相同的商户部门数据范围
+ * @return 按日期、餐段和商户部门预聚合的统计行
+ */
+ List listStatistics(@Param("dto") HardOrderDto dto, DataScope dataScope);
+
/**
* 按订单筛选条件查询去重后的预约用户主键。
*
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/model/HardOrderStatisticsAggregate.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/model/HardOrderStatisticsAggregate.java
new file mode 100644
index 000000000..286f830d4
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/model/HardOrderStatisticsAggregate.java
@@ -0,0 +1,51 @@
+package com.bsd.pjcloud.hard.mapper.order.model;
+
+import lombok.Data;
+
+import java.time.LocalDate;
+
+/**
+ * 订单列表统计的数据库聚合行。
+ *
+ * 数据库先按统计日期、餐段和商户部门聚合,保留商户部门是为了让统一数据权限拦截器
+ * 与订单分页查询应用完全相同的可见范围;服务层会把权限过滤后的商户部门行再次合并。
+ */
+@Data
+public class HardOrderStatisticsAggregate {
+
+ /** 预计使用日期;历史空值由 SQL 回退到订单创建日期。 */
+ private LocalDate statisticsDate;
+
+ /** 订单餐段协议编码;历史空值允许为空。 */
+ private String hourType;
+
+ /** 商户所属部门,供统一数据权限拦截器过滤。 */
+ private Long merchantDeptId;
+
+ /** 订单创建人账号,供“仅本人”及“本人 + 部门”数据权限过滤。 */
+ private String createBy;
+
+ /** 命中筛选条件的订单数。 */
+ private Long orderCount;
+
+ /** 订单购物车中的原始商品份数。 */
+ private Long productCount;
+
+ /** 商品总价,单位为分。 */
+ private Long totalPrice;
+
+ /** 额外支付金额,单位为分。 */
+ private Long extraPrice;
+
+ /** 配送费,单位为分。 */
+ private Long deliveryPrice;
+
+ /** 实际支付金额,单位为分。 */
+ private Long payPrice;
+
+ /** 已退款金额,单位为分。 */
+ private Long refundPrice;
+
+ /** 订单实际金额,即实际支付减已退款,单位为分。 */
+ private Long actualPrice;
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderStatisticsService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderStatisticsService.java
new file mode 100644
index 000000000..fcde2a07a
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderStatisticsService.java
@@ -0,0 +1,20 @@
+package com.bsd.pjcloud.hard.service;
+
+import com.bsd.pjcloud.common.data.datascope.DataScope;
+import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
+import com.bsd.pjcloud.hard.api.vo.order.HardOrderStatisticsVo;
+
+/**
+ * 订单管理列表统计服务。
+ */
+public interface HardOrderStatisticsService {
+
+ /**
+ * 按订单列表完整筛选条件和当前操作者数据范围统计全部命中订单。
+ *
+ * @param query 订单列表查询条件
+ * @param dataScope 与订单分页一致的商户部门数据范围
+ * @return 范围总计及按日期、餐段拆分的统计
+ */
+ HardOrderStatisticsVo statistics(HardOrderDto query, DataScope dataScope);
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardTerminalFaceService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardTerminalFaceService.java
new file mode 100644
index 000000000..9c4e72744
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardTerminalFaceService.java
@@ -0,0 +1,19 @@
+package com.bsd.pjcloud.hard.service;
+
+import com.bsd.pjcloud.admin.api.vo.SysUserFaceTerminalDeleteVo;
+import com.bsd.pjcloud.hard.api.dto.hard.HardTerminalFaceDeleteDto;
+
+/**
+ * 终端在库人脸维护服务。
+ */
+public interface HardTerminalFaceService {
+
+ /**
+ * 以 DEVICE-ID 解析真实商户部门后请求用户服务删除人脸。
+ *
+ * @param hardId 当前设备主键
+ * @param dto 样本编号集合
+ * @return 实际删除及已不存在的样本编号
+ */
+ SysUserFaceTerminalDeleteVo delete(Long hardId, HardTerminalFaceDeleteDto dto);
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/manager/HardTerminalFaceServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/manager/HardTerminalFaceServiceImpl.java
new file mode 100644
index 000000000..652e2e9b2
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/manager/HardTerminalFaceServiceImpl.java
@@ -0,0 +1,75 @@
+package com.bsd.pjcloud.hard.service.impl.manager;
+
+import cn.hutool.core.util.StrUtil;
+import com.bsd.pjcloud.admin.api.dto.SysUserFaceTerminalDeleteDto;
+import com.bsd.pjcloud.admin.api.feign.RemoteUserFaceService;
+import com.bsd.pjcloud.admin.api.vo.SysUserFaceTerminalDeleteVo;
+import com.bsd.pjcloud.common.core.util.R;
+import com.bsd.pjcloud.hard.api.dto.hard.HardTerminalFaceDeleteDto;
+import com.bsd.pjcloud.hard.api.entity.manager.HardManagerEntity;
+import com.bsd.pjcloud.hard.api.vo.manager.HardManagerVo;
+import com.bsd.pjcloud.hard.mapper.manager.HardManagerMapper;
+import com.bsd.pjcloud.hard.service.HardManagerService;
+import com.bsd.pjcloud.hard.service.HardTerminalFaceService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+/**
+ * 终端在库人脸维护服务实现。
+ */
+@Service
+@RequiredArgsConstructor
+public class HardTerminalFaceServiceImpl implements HardTerminalFaceService {
+
+ /**
+ * 设备 Mapper,用于在当前租户内验证 DEVICE-ID。
+ */
+ private final HardManagerMapper hardManagerMapper;
+
+ /**
+ * 设备服务,用于解析与终端人脸同步一致的商户部门。
+ */
+ private final HardManagerService hardManagerService;
+
+ /**
+ * UPMS 人脸远程服务,负责最终部门树范围校验和事务删除。
+ */
+ private final RemoteUserFaceService remoteUserFaceService;
+
+ /**
+ * 根据当前设备真实绑定关系删除部门范围内人脸。
+ *
+ * @param hardId 当前设备主键
+ * @param dto 样本编号集合
+ * @return 实际删除及已不存在的样本编号
+ */
+ @Override
+ public SysUserFaceTerminalDeleteVo delete(Long hardId, HardTerminalFaceDeleteDto dto) {
+ if (hardId == null) {
+ throw new RuntimeException("DEVICE-ID不能为空");
+ }
+ HardManagerEntity hard = hardManagerMapper.selectById(hardId);
+ if (hard == null) {
+ throw new RuntimeException("当前租户下设备不存在");
+ }
+ if (hard.getMerchantId() == null) {
+ throw new RuntimeException("设备尚未绑定商户");
+ }
+ HardManagerVo hardDetail = hardManagerService.getByHardId(hardId);
+ if (hardDetail == null || hardDetail.getMerchantDeptId() == null) {
+ throw new RuntimeException("设备绑定商户缺少部门");
+ }
+
+ SysUserFaceTerminalDeleteDto request = new SysUserFaceTerminalDeleteDto();
+ request.setMerchantDeptId(hardDetail.getMerchantDeptId());
+ request.setSampleIds(dto.getSampleIds());
+ R response = remoteUserFaceService.terminalDelete(request);
+ if (response == null || !response.isOk() || response.getData() == null) {
+ String message = response == null || StrUtil.isBlank(response.getMsg())
+ ? "用户服务删除人脸失败"
+ : response.getMsg();
+ throw new RuntimeException(message);
+ }
+ return response.getData();
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderStatisticsServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderStatisticsServiceImpl.java
new file mode 100644
index 000000000..7fba45924
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderStatisticsServiceImpl.java
@@ -0,0 +1,126 @@
+package com.bsd.pjcloud.hard.service.impl.order;
+
+import com.bsd.pjcloud.common.data.datascope.DataScope;
+import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
+import com.bsd.pjcloud.hard.api.vo.order.HardOrderDailyStatisticsVo;
+import com.bsd.pjcloud.hard.api.vo.order.HardOrderMealStatisticsVo;
+import com.bsd.pjcloud.hard.api.vo.order.HardOrderStatisticsSummaryVo;
+import com.bsd.pjcloud.hard.api.vo.order.HardOrderStatisticsVo;
+import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
+import com.bsd.pjcloud.hard.mapper.order.model.HardOrderStatisticsAggregate;
+import com.bsd.pjcloud.hard.service.HardOrderStatisticsService;
+import com.bsd.pjcloud.hard.support.HardUserIdentitySupport;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 订单列表统计实现;数据库负责筛选和初次聚合,服务层合并权限范围内的商户部门行。
+ */
+@Service
+@RequiredArgsConstructor
+public class HardOrderStatisticsServiceImpl implements HardOrderStatisticsService {
+
+ private final HardOrderMapper hardOrderMapper;
+ private final HardUserIdentitySupport userIdentitySupport;
+
+ @Override
+ public HardOrderStatisticsVo statistics(HardOrderDto query, DataScope dataScope) {
+ applyIdentityQuery(query);
+ List rows = hardOrderMapper.listStatistics(query, dataScope);
+ return buildStatistics(rows == null ? List.of() : rows);
+ }
+
+ /**
+ * 身份和人脸条件必须先转换成用户主键,与订单分页服务的筛选流程保持一致。
+ */
+ private void applyIdentityQuery(HardOrderDto query) {
+ if (!userIdentitySupport.hasIdentityQuery(query)) {
+ return;
+ }
+ query.setUserIds(userIdentitySupport.findIdentityUserIds(query, query.getUserId(), query.getUserIds()));
+ query.setUserId(null);
+ query.setIdentityTypes(null);
+ query.setStudentInfo(null);
+ query.setTeacherInfo(null);
+ query.setHasFace(null);
+ }
+
+ /**
+ * 把按商户部门拆分的数据库结果合并为范围总计、每日统计和餐段统计。
+ */
+ private HardOrderStatisticsVo buildStatistics(List rows) {
+ HardOrderStatisticsVo result = new HardOrderStatisticsVo();
+ Map dailyMap = new LinkedHashMap<>();
+
+ for (HardOrderStatisticsAggregate row : rows) {
+ addAggregate(result.getTotal(), row);
+ mergeMeal(result.getTotal(), row);
+
+ HardOrderDailyStatisticsVo daily = dailyMap.computeIfAbsent(row.getStatisticsDate(), date -> {
+ HardOrderDailyStatisticsVo item = new HardOrderDailyStatisticsVo();
+ item.setDate(date);
+ return item;
+ });
+ addAggregate(daily, row);
+ mergeMeal(daily, row);
+ }
+
+ sortMeals(result.getTotal());
+ List daily = new ArrayList<>(dailyMap.values());
+ daily.sort(Comparator.comparing(HardOrderDailyStatisticsVo::getDate,
+ Comparator.nullsLast(Comparator.reverseOrder())));
+ daily.forEach(this::sortMeals);
+ result.setDaily(daily);
+ return result;
+ }
+
+ /** 累加订单级金额和商品份数。 */
+ private void addAggregate(HardOrderStatisticsSummaryVo target, HardOrderStatisticsAggregate source) {
+ target.setOrderCount(target.getOrderCount() + safe(source.getOrderCount()));
+ target.setProductCount(target.getProductCount() + safe(source.getProductCount()));
+ target.setTotalPrice(target.getTotalPrice() + safe(source.getTotalPrice()));
+ target.setExtraPrice(target.getExtraPrice() + safe(source.getExtraPrice()));
+ target.setDeliveryPrice(target.getDeliveryPrice() + safe(source.getDeliveryPrice()));
+ target.setPayPrice(target.getPayPrice() + safe(source.getPayPrice()));
+ target.setRefundPrice(target.getRefundPrice() + safe(source.getRefundPrice()));
+ target.setActualPrice(target.getActualPrice() + safe(source.getActualPrice()));
+ }
+
+ /** 同一日期餐段可能被数据权限范围内的多个商户部门拆开,需要再次合并。 */
+ private void mergeMeal(HardOrderStatisticsSummaryVo target, HardOrderStatisticsAggregate source) {
+ HardOrderMealStatisticsVo meal = target.getMeals().stream()
+ .filter(item -> java.util.Objects.equals(item.getHourType(), source.getHourType()))
+ .findFirst()
+ .orElseGet(() -> {
+ HardOrderMealStatisticsVo item = new HardOrderMealStatisticsVo();
+ item.setHourType(source.getHourType());
+ target.getMeals().add(item);
+ return item;
+ });
+ meal.setOrderCount(meal.getOrderCount() + safe(source.getOrderCount()));
+ meal.setProductCount(meal.getProductCount() + safe(source.getProductCount()));
+ meal.setTotalPrice(meal.getTotalPrice() + safe(source.getTotalPrice()));
+ meal.setExtraPrice(meal.getExtraPrice() + safe(source.getExtraPrice()));
+ meal.setDeliveryPrice(meal.getDeliveryPrice() + safe(source.getDeliveryPrice()));
+ meal.setPayPrice(meal.getPayPrice() + safe(source.getPayPrice()));
+ meal.setRefundPrice(meal.getRefundPrice() + safe(source.getRefundPrice()));
+ meal.setActualPrice(meal.getActualPrice() + safe(source.getActualPrice()));
+ }
+
+ /** 餐段编码稳定排序,空餐段放到最后;页面会再按当前字典顺序显示。 */
+ private void sortMeals(HardOrderStatisticsSummaryVo summary) {
+ summary.getMeals().sort(Comparator.comparing(HardOrderMealStatisticsVo::getHourType,
+ Comparator.nullsLast(String::compareTo)));
+ }
+
+ private long safe(Long value) {
+ return value == null ? 0L : value;
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml
index 978aed988..6692a5d70 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardOrderMapper.xml
@@ -468,6 +468,86 @@
+
+
+
+ *
+ * @param dto 商户部门和待删除样本编号
+ * @return 实际删除及请求前已不存在的样本编号
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public SysUserFaceTerminalDeleteVo terminalDelete(SysUserFaceTerminalDeleteDto dto) {
+ if (dto == null || dto.getMerchantDeptId() == null || ObjectUtil.isEmpty(dto.getSampleIds())) {
+ throw new RuntimeException("终端人脸删除参数不能为空");
+ }
+ if (dto.getSampleIds().size() > FACE_SYNC_MAX_LIMIT) {
+ throw new RuntimeException("单次最多删除" + FACE_SYNC_MAX_LIMIT + "条人脸");
+ }
+
+ LinkedHashSet normalizedSet = new LinkedHashSet<>();
+ for (String sampleId : dto.getSampleIds()) {
+ String normalized = StrUtil.trim(sampleId);
+ try {
+ long numeric = Long.parseLong(normalized);
+ if (numeric <= 0 || numeric > FACE_AUTO_CODE_MAX_VALUE) {
+ throw new NumberFormatException("out of range");
+ }
+ } catch (Exception exception) {
+ throw new RuntimeException("存在无效人脸样本编号");
+ }
+ normalizedSet.add(normalized);
+ }
+ List sampleIds = new ArrayList<>(normalizedSet);
+ List deptIds = sysDeptService.listDescendant(dto.getMerchantDeptId()).stream()
+ .map(SysDept::getDeptId)
+ .filter(Objects::nonNull)
+ .distinct()
+ .toList();
+ if (deptIds.isEmpty()) {
+ throw new RuntimeException("终端绑定部门不存在或已删除");
+ }
+
+ List candidates = baseMapper.terminalDeleteCandidates(sampleIds);
+ Set allowedDeptIds = new HashSet<>(deptIds);
+ boolean outOfScope = candidates.stream()
+ .anyMatch(face -> face.getDeptId() == null || !allowedDeptIds.contains(face.getDeptId()));
+ if (outOfScope) {
+ throw new RuntimeException("请求包含不属于当前设备部门范围的人脸");
+ }
+
+ Set existingSampleIds = candidates.stream()
+ .map(SysUserFaceVo::getAutoCode)
+ .filter(StrUtil::isNotBlank)
+ .collect(Collectors.toSet());
+ List deletedSampleIds = sampleIds.stream()
+ .filter(existingSampleIds::contains)
+ .toList();
+ List alreadyAbsentSampleIds = sampleIds.stream()
+ .filter(sampleId -> !existingSampleIds.contains(sampleId))
+ .toList();
+ ArrayList databaseIds = candidates.stream()
+ .map(SysUserFaceVo::getId)
+ .filter(Objects::nonNull)
+ .distinct()
+ .collect(Collectors.toCollection(ArrayList::new));
+ delByIds(databaseIds);
+ return SysUserFaceTerminalDeleteVo.builder()
+ .deletedSampleIds(deletedSampleIds)
+ .alreadyAbsentSampleIds(alreadyAbsentSampleIds)
+ .build();
+ }
+
/**
* 校验快照记录的虹软搜索标识可被 Android 端安全解析为 32 位正整数。
*
diff --git a/pjcloud-upms/pjcloud-upms-biz/src/test/java/com/bsd/pjcloud/admin/service/impl/SysUserFaceTerminalDeleteServiceImplTest.java b/pjcloud-upms/pjcloud-upms-biz/src/test/java/com/bsd/pjcloud/admin/service/impl/SysUserFaceTerminalDeleteServiceImplTest.java
new file mode 100644
index 000000000..4a1aedb6f
--- /dev/null
+++ b/pjcloud-upms/pjcloud-upms-biz/src/test/java/com/bsd/pjcloud/admin/service/impl/SysUserFaceTerminalDeleteServiceImplTest.java
@@ -0,0 +1,144 @@
+package com.bsd.pjcloud.admin.service.impl;
+
+import com.bsd.pjcloud.admin.api.dto.SysUserFaceTerminalDeleteDto;
+import com.bsd.pjcloud.admin.api.entity.SysDept;
+import com.bsd.pjcloud.admin.api.vo.SysUserFaceTerminalDeleteVo;
+import com.bsd.pjcloud.admin.api.vo.SysUserFaceVo;
+import com.bsd.pjcloud.admin.mapper.SysUserFaceMapper;
+import com.bsd.pjcloud.admin.service.SysDeptService;
+import com.bsd.pjcloud.admin.service.SysRoleService;
+import com.bsd.pjcloud.admin.service.SysUserRelationService;
+import com.bsd.pjcloud.admin.service.SysUserService;
+import com.bsd.pjcloud.admin.utils.flow.FlowUtil;
+import com.bsd.pjcloud.flow.task.api.feign.RemoteFlowEngineService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.redisson.Redisson;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 用户服务终端人脸删除测试,验证部门树范围、幂等结果和整批拒绝规则。
+ */
+class SysUserFaceTerminalDeleteServiceImplTest {
+
+ /** 被测服务使用的人脸 Mapper。 */
+ private final SysUserFaceMapper faceMapper = mock(SysUserFaceMapper.class);
+
+ /** 部门树服务。 */
+ private final SysDeptService sysDeptService = mock(SysDeptService.class);
+
+ /** 被测用户人脸服务。 */
+ private SysUserFaceServiceImpl service;
+
+ /** 创建只保留终端删除路径依赖的服务实例,并注入 MyBatis-Plus 基础 Mapper。 */
+ @BeforeEach
+ void setUp() {
+ service = new SysUserFaceServiceImpl(
+ mock(SysUserService.class),
+ sysDeptService,
+ mock(SysRoleService.class),
+ mock(SysUserRelationService.class),
+ mock(StringRedisTemplate.class),
+ mock(Redisson.class),
+ mock(ApplicationEventPublisher.class),
+ mock(RemoteFlowEngineService.class),
+ mock(FlowUtil.class));
+ ReflectionTestUtils.setField(service, "baseMapper", faceMapper);
+ }
+
+ /** 空请求、缺少部门或空样本集合均必须拒绝。 */
+ @Test
+ void terminalDeleteRejectsMissingParameters() {
+ assertThrows(RuntimeException.class, () -> service.terminalDelete(null));
+ assertThrows(RuntimeException.class, () -> service.terminalDelete(request(null, "1001")));
+ assertThrows(RuntimeException.class, () -> service.terminalDelete(request(10L)));
+ verify(faceMapper, never()).terminalDeleteCandidates(org.mockito.ArgumentMatchers.any());
+ }
+
+ /** 非数字、非正数和超过 Android 32 位搜索标识范围的编号必须拒绝。 */
+ @Test
+ void terminalDeleteRejectsInvalidSampleIds() {
+ assertThrows(RuntimeException.class, () -> service.terminalDelete(request(10L, "abc")));
+ assertThrows(RuntimeException.class, () -> service.terminalDelete(request(10L, "0")));
+ assertThrows(RuntimeException.class, () -> service.terminalDelete(request(10L, "2147483648")));
+ verify(faceMapper, never()).terminalDeleteCandidates(org.mockito.ArgumentMatchers.any());
+ }
+
+ /** 原始请求超过500条时必须在部门和数据库查询前拒绝。 */
+ @Test
+ void terminalDeleteRejectsMoreThanFiveHundredSamples() {
+ List sampleIds = java.util.stream.IntStream.rangeClosed(1, 501)
+ .mapToObj(String::valueOf)
+ .toList();
+
+ RuntimeException exception = assertThrows(RuntimeException.class,
+ () -> service.terminalDelete(request(10L, sampleIds.toArray(String[]::new))));
+
+ assertEquals("单次最多删除500条人脸", exception.getMessage());
+ verify(sysDeptService, never()).listDescendant(org.mockito.ArgumentMatchers.any());
+ }
+
+ /** 请求中存在任意外部门人脸时整批拒绝,并且不能执行删除。 */
+ @Test
+ void terminalDeleteRejectsWholeBatchWhenAnyFaceIsOutOfScope() {
+ when(sysDeptService.listDescendant(10L)).thenReturn(List.of(dept(10L), dept(11L)));
+ when(faceMapper.terminalDeleteCandidates(List.of("1001", "1002"))).thenReturn(List.of(
+ face(1L, "1001", 10L),
+ face(2L, "1002", 99L)));
+
+ RuntimeException exception = assertThrows(RuntimeException.class,
+ () -> service.terminalDelete(request(10L, "1001", "1002")));
+
+ assertEquals("请求包含不属于当前设备部门范围的人脸", exception.getMessage());
+ verify(faceMapper, never()).deleteBatchIds(org.mockito.ArgumentMatchers.anyCollection());
+ }
+
+ /** 重复编号按首次顺序去重,已不存在编号作为幂等成功返回。 */
+ @Test
+ void terminalDeleteDeduplicatesAndReturnsAlreadyAbsentSamples() {
+ when(sysDeptService.listDescendant(10L)).thenReturn(List.of(dept(10L), dept(11L)));
+ when(faceMapper.terminalDeleteCandidates(List.of("1001", "1002")))
+ .thenReturn(List.of(face(1L, "1001", 11L)));
+
+ SysUserFaceTerminalDeleteVo result = service.terminalDelete(request(10L, "1001", "1001", "1002"));
+
+ assertEquals(List.of("1001"), result.getDeletedSampleIds());
+ assertEquals(List.of("1002"), result.getAlreadyAbsentSampleIds());
+ verify(faceMapper).deleteBatchIds(List.of(1L));
+ }
+
+ /** 构造终端删除内部请求。 */
+ private static SysUserFaceTerminalDeleteDto request(Long deptId, String... sampleIds) {
+ SysUserFaceTerminalDeleteDto dto = new SysUserFaceTerminalDeleteDto();
+ dto.setMerchantDeptId(deptId);
+ dto.setSampleIds(List.of(sampleIds));
+ return dto;
+ }
+
+ /** 构造部门树节点。 */
+ private static SysDept dept(Long deptId) {
+ SysDept dept = new SysDept();
+ dept.setDeptId(deptId);
+ return dept;
+ }
+
+ /** 构造仍存在的人脸及其用户当前部门。 */
+ private static SysUserFaceVo face(Long id, String sampleId, Long deptId) {
+ SysUserFaceVo face = new SysUserFaceVo();
+ face.setId(id);
+ face.setAutoCode(sampleId);
+ face.setDeptId(deptId);
+ return face;
+ }
+}