diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCanteenUserApptQueryDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCanteenUserApptQueryDto.java
index e8384b42d..d16c4ae45 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCanteenUserApptQueryDto.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/HardCanteenUserApptQueryDto.java
@@ -1,6 +1,9 @@
package com.bsd.pjcloud.hard.api.dto.order;
+import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
+import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.vo.base.BaseIsIncludeTreeInterface;
+import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityQueryInterface;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
@@ -17,7 +20,7 @@ import java.util.List;
*/
@Data
@Schema(description = "食堂用户预约查询条件")
-public class HardCanteenUserApptQueryDto implements BaseIsIncludeTreeInterface {
+public class HardCanteenUserApptQueryDto implements BaseIsIncludeTreeInterface, BaseUserIdentityQueryInterface {
/** 用餐日期闭区间,固定为开始、结束两个服务端本地日期,业务层限制跨度最多为两个自然月。 */
@NotNull(message = "用餐日期范围不能为空")
@@ -29,6 +32,22 @@ public class HardCanteenUserApptQueryDto implements BaseIsIncludeTreeInterface {
@Schema(description = "用户姓名、登录账号或手机号")
private String nameOrPhone;
+ /** 用户身份协议编码集合;为空时不限制身份类型,编码由 UPMS 身份表和前端字典共同约束。 */
+ @Schema(description = "用户身份类型集合")
+ private List identityTypes;
+
+ /** 学生身份扩展查询条件;字段匹配语义沿用 UPMS 用户查询。 */
+ @Schema(description = "学生身份扩展查询条件")
+ private SysUserStudentDto studentInfo;
+
+ /** 老师身份扩展查询条件;老师学号为空时不限制老师资料。 */
+ @Schema(description = "老师身份扩展查询条件")
+ private SysUserTeacherDto teacherInfo;
+
+ /** 人脸录入状态筛选:1 表示存在未删除人脸,0 表示不存在,空值表示不限制。 */
+ @Schema(description = "是否已录入人脸:1已录入,0未录入")
+ private String hasFace;
+
/** 部门树选中的部门主键集合;为空时使用当前操作者完整的数据权限范围。 */
@Schema(description = "部门主键集合")
private List deptIds;
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalExportServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalExportServiceImpl.java
index ca95abc97..b8c40d3df 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalExportServiceImpl.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalExportServiceImpl.java
@@ -7,6 +7,8 @@ import com.alibaba.excel.write.metadata.WriteSheet;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.admin.api.constant.user.UserIdentityTypeEnum;
import com.bsd.pjcloud.admin.api.dto.UserDTO;
+import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
+import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.entity.SysDept;
import com.bsd.pjcloud.admin.api.entity.SysDictItem;
import com.bsd.pjcloud.admin.api.entity.SysPost;
@@ -446,6 +448,10 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot
target.setApptUseDateList(source.getApptUseDateList() == null
? null : source.getApptUseDateList().clone());
target.setNameOrPhone(source.getNameOrPhone());
+ target.setIdentityTypes(copyList(source.getIdentityTypes()));
+ target.setStudentInfo(copyStudentInfo(source.getStudentInfo()));
+ target.setTeacherInfo(copyTeacherInfo(source.getTeacherInfo()));
+ target.setHasFace(source.getHasFace());
target.setDeptIds(copyList(source.getDeptIds()));
target.setIsInclude(source.getIsInclude());
target.setMerchantId(source.getMerchantId());
@@ -467,6 +473,36 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot
return source == null ? null : new ArrayList<>(source);
}
+ /**
+ * 复制学生身份查询条件,确保异步导出线程不共享请求对象的可变扩展资料。
+ *
+ * @param source 原始学生身份查询条件
+ * @return 独立的学生身份查询条件;源值为空时返回空值
+ */
+ private SysUserStudentDto copyStudentInfo(SysUserStudentDto source) {
+ if (source == null) return null;
+ SysUserStudentDto target = new SysUserStudentDto();
+ target.setStudentNo(source.getStudentNo());
+ target.setSchoolName(source.getSchoolName());
+ target.setClassName(source.getClassName());
+ target.setGuardianPhones(source.getGuardianPhones());
+ target.setExtJson(source.getExtJson());
+ return target;
+ }
+
+ /**
+ * 复制老师身份查询条件,确保异步导出线程不共享请求对象的可变扩展资料。
+ *
+ * @param source 原始老师身份查询条件
+ * @return 独立的老师身份查询条件;源值为空时返回空值
+ */
+ private SysUserTeacherDto copyTeacherInfo(SysUserTeacherDto source) {
+ if (source == null) return null;
+ SysUserTeacherDto target = new SysUserTeacherDto();
+ target.setTeacherNo(source.getTeacherNo());
+ return target;
+ }
+
/**
* 按文件服务约定编码中文原始文件名,避免 ISO-8859-1 到 UTF-8 的转换产生乱码。
*
@@ -625,11 +661,15 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot
* 将预约查询中的用户筛选条件转换为 UPMS 用户分页条件。
*
* @param query 用户预约查询条件
- * @return 姓名手机号、部门包含关系和指定用户条件的独立请求对象
+ * @return 姓名手机号、身份、部门包含关系和指定用户条件的独立请求对象
*/
private UserDTO buildUserQuery(HardCanteenUserApptQueryDto query) {
UserDTO userQuery = new UserDTO();
userQuery.setNameOrPhone(query.getNameOrPhone());
+ userQuery.setIdentityTypes(copyList(query.getIdentityTypes()));
+ userQuery.setStudentInfo(copyStudentInfo(query.getStudentInfo()));
+ userQuery.setTeacherInfo(copyTeacherInfo(query.getTeacherInfo()));
+ userQuery.setHasFace(query.getHasFace());
userQuery.setDeptIds(copyList(query.getDeptIds()));
userQuery.setIsInclude(query.getIsInclude());
userQuery.setUserIds(copyList(query.getUserIds()));
@@ -1377,16 +1417,46 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot
continue;
}
String hourType = summary.getHourType();
+ String summaryKey = date + "|" + hourType;
+ List merchantRows = merchantGroups.getOrDefault(summaryKey, List.of());
+ Map> detailRowsByMerchant = new LinkedHashMap<>();
+ Map detailCountsByMerchant = new HashMap<>();
+ Map detailPricesByMerchant = new HashMap<>();
+ long summaryProductCount = 0L;
+ long summaryTotalPrice = 0L;
+ for (HardCanteenProductApptExportRecord merchant : merchantRows) {
+ String merchantKey = groupKey(merchant);
+ List detailRows = productGroups
+ .getOrDefault(merchantKey, List.of()).stream()
+ .flatMap(product -> toProductRows(product,
+ menuMap.get(menuKey(product, fixedDate != null ? fixedDate : date))).stream())
+ .toList();
+ long detailCount = detailRows.stream().mapToLong(detail -> valueOrZero(detail.getProductCount())).sum();
+ long detailPrice = detailRows.stream().mapToLong(detail -> yuanToCents(detail.getProductTotalPrice())).sum();
+ // 每日菜谱查询失败时仍保留 SQL 的有效预约统计,避免导出因远程菜谱暂时不可用而丢失金额。
+ if (detailRows.isEmpty()) {
+ detailCount = valueOrZero(merchant.getPayCount());
+ detailPrice = valueOrZero(merchant.getTotalPrice());
+ }
+ detailRowsByMerchant.put(merchantKey, detailRows);
+ detailCountsByMerchant.put(merchantKey, detailCount);
+ detailPricesByMerchant.put(merchantKey, detailPrice);
+ summaryProductCount += detailCount;
+ summaryTotalPrice += detailPrice;
+ }
+ if (merchantRows.isEmpty()) {
+ summaryProductCount = valueOrZero(summary.getPayCount());
+ summaryTotalPrice = valueOrZero(summary.getTotalPrice());
+ }
HardCanteenProductApptExcelVo summaryRow = new HardCanteenProductApptExcelVo();
summaryRow.setSummaryDate(date);
summaryRow.setSummaryHourType(resolveHourName(hourType, hourNames));
summaryRow.setSummaryPayUserCount(valueOrZero(summary.getPayUserCount()));
summaryRow.setSummaryTimeCount(valueOrZero(summary.getTimeCount()));
- summaryRow.setSummaryPayCount(valueOrZero(summary.getPayCount()));
- summaryRow.setSummaryTotalPrice(centsToYuan(summary.getTotalPrice()));
+ // 汇总数量必须与下方已展开的商品明细一致;套餐按组成商品份数计入,而不是只计套餐份数。
+ summaryRow.setSummaryPayCount(summaryProductCount);
+ summaryRow.setSummaryTotalPrice(centsToYuan(summaryTotalPrice));
rows.add(summaryRow);
- String summaryKey = date + "|" + hourType;
- List merchantRows = merchantGroups.getOrDefault(summaryKey, List.of());
for (HardCanteenProductApptExportRecord merchant : merchantRows) {
HardCanteenProductApptExcelVo merchantRow = new HardCanteenProductApptExcelVo();
merchantRow.setMerchantName(merchantNames.getOrDefault(merchant.getMerchantId(), "商户信息未记录"));
@@ -1395,14 +1465,13 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot
merchantRow.setMerchantHourType(resolveHourName(hourType, hourNames));
merchantRow.setMerchantPayUserCount(valueOrZero(merchant.getPayUserCount()));
merchantRow.setMerchantTimeCount(valueOrZero(merchant.getTimeCount()));
- merchantRow.setMerchantPayCount(valueOrZero(merchant.getPayCount()));
- merchantRow.setMerchantTotalPrice(centsToYuan(merchant.getTotalPrice()));
- rows.add(merchantRow);
String merchantKey = groupKey(merchant);
- List productRows = productGroups.getOrDefault(merchantKey, List.of());
- for (HardCanteenProductApptExportRecord product : productRows) {
- rows.addAll(toProductRows(product, menuMap.get(menuKey(product, fixedDate != null ? fixedDate : date))));
- }
+ merchantRow.setMerchantPayCount(detailCountsByMerchant.getOrDefault(merchantKey,
+ valueOrZero(merchant.getPayCount())));
+ merchantRow.setMerchantTotalPrice(centsToYuan(detailPricesByMerchant.getOrDefault(merchantKey,
+ valueOrZero(merchant.getTotalPrice()))));
+ rows.add(merchantRow);
+ rows.addAll(detailRowsByMerchant.getOrDefault(merchantKey, List.of()));
}
}
return rows;
@@ -1609,6 +1678,18 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot
return BigDecimal.valueOf(valueOrZero(cents)).movePointLeft(2).setScale(2, RoundingMode.HALF_UP);
}
+ /**
+ * 将商品明细展示金额从元转换回分,用于按最终商品行重新计算日期餐段和部门合计。
+ *
+ * 商品行已经按两位小数输出,因此这里使用半舍入恢复整数分,避免直接使用 double 导致合计出现尾差。
+ *
+ * @param yuan 商品明细展示金额,单位为元;为空按零处理
+ * @return 金额,单位为分
+ */
+ private long yuanToCents(BigDecimal yuan) {
+ return yuan == null ? 0L : yuan.movePointRight(2).setScale(0, RoundingMode.HALF_UP).longValue();
+ }
+
/** 解析餐段名称,字典缺失时保留原始编码。 */
private String resolveHourName(String hourType, Map hourNames) {
return StrUtil.blankToDefault(hourNames.get(hourType), StrUtil.blankToDefault(hourType, "未标记餐段"));
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalServiceImpl.java
index f0c90ae6d..414e6cf00 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalServiceImpl.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalServiceImpl.java
@@ -4,6 +4,8 @@ import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.admin.api.dto.UserDTO;
+import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
+import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
import com.bsd.pjcloud.admin.api.vo.UserVO;
import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum;
@@ -200,6 +202,10 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ
target.setApptUseDateList(source.getApptUseDateList() == null
? null : source.getApptUseDateList().clone());
target.setNameOrPhone(source.getNameOrPhone());
+ target.setIdentityTypes(copyList(source.getIdentityTypes()));
+ target.setStudentInfo(copyStudentInfo(source.getStudentInfo()));
+ target.setTeacherInfo(copyTeacherInfo(source.getTeacherInfo()));
+ target.setHasFace(source.getHasFace());
target.setDeptIds(copyList(source.getDeptIds()));
target.setIsInclude(source.getIsInclude());
target.setMerchantId(source.getMerchantId());
@@ -221,13 +227,43 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ
return source == null ? null : new ArrayList<>(source);
}
+ /**
+ * 复制学生身份查询条件,避免分页预筛选改动请求对象持有的可变扩展资料。
+ *
+ * @param source 原始学生身份查询条件
+ * @return 独立的学生身份查询条件;源值为空时返回空值
+ */
+ private SysUserStudentDto copyStudentInfo(SysUserStudentDto source) {
+ if (source == null) return null;
+ SysUserStudentDto target = new SysUserStudentDto();
+ target.setStudentNo(source.getStudentNo());
+ target.setSchoolName(source.getSchoolName());
+ target.setClassName(source.getClassName());
+ target.setGuardianPhones(source.getGuardianPhones());
+ target.setExtJson(source.getExtJson());
+ return target;
+ }
+
+ /**
+ * 复制老师身份查询条件,避免分页预筛选改动请求对象持有的可变扩展资料。
+ *
+ * @param source 原始老师身份查询条件
+ * @return 独立的老师身份查询条件;源值为空时返回空值
+ */
+ private SysUserTeacherDto copyTeacherInfo(SysUserTeacherDto source) {
+ if (source == null) return null;
+ SysUserTeacherDto target = new SysUserTeacherDto();
+ target.setTeacherNo(source.getTeacherNo());
+ return target;
+ }
+
/**
* 按当前用户预约查询条件生成每日和查询范围总计。
*
* 统计先复用用户预约列表的权限用户、有效订单和状态筛选口径,再读取订单购物车快照计算菜品份数,
* 保证抽屉中的人数、订单和菜品不会因为分页而少算或多算。
*
- * @param query 用户、日期范围、食堂、餐段和状态筛选条件
+ * @param query 用户、身份、日期范围、食堂、餐段和状态筛选条件
* @return 按日期升序排列并包含范围总计的统计结果
*/
@Override
@@ -514,11 +550,15 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ
* 将预约查询中的用户筛选条件转换为 UPMS 用户分页条件。
*
* @param query 用户预约查询条件
- * @return 姓名手机号、部门包含关系和指定用户条件的独立请求对象
+ * @return 姓名手机号、身份、部门包含关系和指定用户条件的独立请求对象
*/
private UserDTO buildUserQuery(HardCanteenUserApptQueryDto query) {
UserDTO userQuery = new UserDTO();
userQuery.setNameOrPhone(query.getNameOrPhone());
+ userQuery.setIdentityTypes(copyList(query.getIdentityTypes()));
+ userQuery.setStudentInfo(copyStudentInfo(query.getStudentInfo()));
+ userQuery.setTeacherInfo(copyTeacherInfo(query.getTeacherInfo()));
+ userQuery.setHasFace(query.getHasFace());
userQuery.setDeptIds(copyList(query.getDeptIds()));
userQuery.setIsInclude(query.getIsInclude());
userQuery.setUserIds(copyList(query.getUserIds()));
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalExportServiceImplTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalExportServiceImplTest.java
new file mode 100644
index 000000000..67654c95a
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalExportServiceImplTest.java
@@ -0,0 +1,149 @@
+package com.bsd.pjcloud.hard.service.impl.order;
+
+import com.bsd.pjcloud.admin.api.entity.SysDept;
+import com.bsd.pjcloud.admin.api.entity.SysDictItem;
+import com.bsd.pjcloud.admin.api.feign.RemoteDeptService;
+import com.bsd.pjcloud.admin.api.feign.RemoteDictService;
+import com.bsd.pjcloud.canteen.api.feign.RemoteDailyMenuService;
+import com.bsd.pjcloud.canteen.vo.app.DailyMenuAppVO;
+import com.bsd.pjcloud.canteen.vo.app.OnlineSpuComboItemVO;
+import com.bsd.pjcloud.common.core.util.R;
+import com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductApptExportRecord;
+import com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductTotalVo;
+import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenProductApptExcelVo;
+import com.bsd.pjcloud.hard.mapper.order.HardCarMapper;
+import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
+import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
+import org.junit.jupiter.api.Test;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import java.time.LocalDate;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * 菜品预约导出服务测试,验证日期餐段和部门合计与套餐展开后的商品明细保持同一数量口径。
+ */
+class HardCanteenApptTotalExportServiceImplTest {
+
+ /**
+ * 套餐商品展开后,日期餐段汇总应跨部门累计组成商品数量和金额,而不是累计套餐份数。
+ */
+ @Test
+ void shouldAggregateMealSegmentTotalsAcrossDepartmentsFromExpandedProductRows() {
+ HardCarMapper hardCarMapper = mock(HardCarMapper.class);
+ RemoteDailyMenuService remoteDailyMenuService = mock(RemoteDailyMenuService.class);
+ RemoteDeptService remoteDeptService = mock(RemoteDeptService.class);
+ RemoteDictService remoteDictService = mock(RemoteDictService.class);
+ RemoteMchService remoteMchService = mock(RemoteMchService.class);
+ HardCanteenApptTotalExportServiceImpl service = mock(HardCanteenApptTotalExportServiceImpl.class,
+ org.mockito.Answers.CALLS_REAL_METHODS);
+ ReflectionTestUtils.setField(service, "hardCarMapper", hardCarMapper);
+ ReflectionTestUtils.setField(service, "remoteDailyMenuService", remoteDailyMenuService);
+ ReflectionTestUtils.setField(service, "remoteDeptService", remoteDeptService);
+ ReflectionTestUtils.setField(service, "remoteDictService", remoteDictService);
+ ReflectionTestUtils.setField(service, "remoteMchService", remoteMchService);
+
+ LocalDate date = LocalDate.of(2026, 8, 24);
+ HardCanteenProductTotalVo summary = new HardCanteenProductTotalVo();
+ summary.setCreateTime(date.atStartOfDay());
+ summary.setHourType("1");
+ summary.setPayUserCount(2L);
+ summary.setTimeCount(2L);
+ summary.setPayCount(5L);
+ summary.setTotalPrice(800L);
+
+ HardCanteenProductApptExportRecord merchant = new HardCanteenProductApptExportRecord();
+ merchant.setApptDate(date.toString());
+ merchant.setHourType("1");
+ merchant.setMerchantId(10L);
+ merchant.setUserDeptId(20L);
+ merchant.setPayUserCount(1L);
+ merchant.setTimeCount(1L);
+ merchant.setPayCount(1L);
+ merchant.setTotalPrice(300L);
+
+ HardCanteenProductApptExportRecord product = new HardCanteenProductApptExportRecord();
+ product.setApptDate(date.toString());
+ product.setHourType("1");
+ product.setMerchantId(10L);
+ product.setUserDeptId(20L);
+ product.setProductId(100L);
+ product.setProductName("套餐");
+ product.setPayCount(1L);
+ product.setTotalPrice(300L);
+
+ HardCanteenProductApptExportRecord secondMerchant = new HardCanteenProductApptExportRecord();
+ secondMerchant.setApptDate(date.toString());
+ secondMerchant.setHourType("1");
+ secondMerchant.setMerchantId(10L);
+ secondMerchant.setUserDeptId(30L);
+ secondMerchant.setPayUserCount(1L);
+ secondMerchant.setTimeCount(1L);
+ secondMerchant.setPayCount(4L);
+ secondMerchant.setTotalPrice(500L);
+
+ HardCanteenProductApptExportRecord secondProduct = new HardCanteenProductApptExportRecord();
+ secondProduct.setApptDate(date.toString());
+ secondProduct.setHourType("1");
+ secondProduct.setMerchantId(10L);
+ secondProduct.setUserDeptId(30L);
+ secondProduct.setProductId(200L);
+ secondProduct.setProductName("单品");
+ secondProduct.setPayCount(4L);
+ secondProduct.setTotalPrice(500L);
+
+ DailyMenuAppVO menu = new DailyMenuAppVO();
+ menu.setSpuId(100L);
+ OnlineSpuComboItemVO first = new OnlineSpuComboItemVO();
+ first.setName("主食");
+ first.setQuantity(1);
+ first.setPrice(100);
+ OnlineSpuComboItemVO second = new OnlineSpuComboItemVO();
+ second.setName("配菜");
+ second.setQuantity(2);
+ second.setPrice(100);
+ menu.setComboItems(List.of(first, second));
+
+ MchManageVo merchantVo = new MchManageVo();
+ merchantVo.setId(10L);
+ merchantVo.setName("测试食堂");
+ SysDept dept = new SysDept();
+ dept.setDeptId(20L);
+ dept.setName("测试部门");
+ SysDept secondDept = new SysDept();
+ secondDept.setDeptId(30L);
+ secondDept.setName("第二部门");
+ SysDictItem dictItem = new SysDictItem();
+ dictItem.setItemValue("1");
+ dictItem.setLabel("早餐");
+
+ when(hardCarMapper.listApptExportSummary(any())).thenReturn(List.of(summary));
+ when(hardCarMapper.listApptExportMerchant(any())).thenReturn(List.of(merchant, secondMerchant));
+ when(hardCarMapper.listApptExportProduct(any())).thenReturn(List.of(product, secondProduct));
+ when(remoteDailyMenuService.getCanteenList(date.toString(), 1, null, 10L)).thenReturn(R.ok(List.of(menu)));
+ when(remoteMchService.getByIds(List.of(10L))).thenReturn(R.ok(List.of(merchantVo)));
+ when(remoteDeptService.getDeptByDeptIds(List.of(20L, 30L)))
+ .thenReturn(R.ok(java.util.Map.of(20L, dept, 30L, secondDept)));
+ when(remoteDictService.getDictByType("daily_menu")).thenReturn(R.ok(List.of(dictItem)));
+
+ List rows = ReflectionTestUtils.invokeMethod(service, "buildProductRows",
+ null, date);
+
+ assertEquals(6, rows.size());
+ assertEquals(7L, rows.get(0).getSummaryPayCount());
+ assertEquals("8.00", rows.get(0).getSummaryTotalPrice().toPlainString());
+ assertEquals(3L, rows.get(1).getMerchantPayCount());
+ assertEquals("3.00", rows.get(1).getMerchantTotalPrice().toPlainString());
+ assertEquals(1L, rows.get(2).getProductCount());
+ assertEquals(2L, rows.get(3).getProductCount());
+ assertEquals(4L, rows.get(4).getMerchantPayCount());
+ assertEquals("5.00", rows.get(4).getMerchantTotalPrice().toPlainString());
+ assertEquals(4L, rows.get(5).getProductCount());
+ assertEquals("早餐", rows.get(0).getSummaryHourType());
+ }
+}
diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/UserDTO.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/UserDTO.java
index fcd0e1fdb..ca66205a8 100644
--- a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/UserDTO.java
+++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/UserDTO.java
@@ -23,6 +23,7 @@ import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.admin.api.entity.SysUser;
import com.bsd.pjcloud.admin.api.vo.base.BaseIsIncludeTreeInterface;
+import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityQueryInterface;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
@@ -37,7 +38,7 @@ import java.util.List;
@Data
@Schema(description = "系统用户传输对象")
@EqualsAndHashCode(callSuper = true)
-public class UserDTO extends SysUser implements BaseIsIncludeTreeInterface {
+public class UserDTO extends SysUser implements BaseIsIncludeTreeInterface, BaseUserIdentityQueryInterface {
/**
* 角色ID
diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserService.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserService.java
index 2d9f122dc..d71761cf0 100644
--- a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserService.java
+++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserService.java
@@ -59,7 +59,7 @@ public interface RemoteUserService {
/**
* 按当前登录人的数据权限分页查询用户基础信息和身份资料。
*
- * 该接口不查询角色、岗位、人脸和审批流程,适用于只需要用户、部门及身份展示信息的业务列表。
+ * 该接口不返回角色、岗位、人脸和审批资料,但支持按人脸录入状态筛选,适用于只需要用户、部门及身份展示信息的业务列表。
*
* @param current 当前页码,从 1 开始
* @param size 每页用户数量
diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/base/BaseUserIdentityQueryInterface.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/base/BaseUserIdentityQueryInterface.java
new file mode 100644
index 000000000..e2d05c1eb
--- /dev/null
+++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/vo/base/BaseUserIdentityQueryInterface.java
@@ -0,0 +1,71 @@
+package com.bsd.pjcloud.admin.api.vo.base;
+
+import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
+import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
+
+import java.util.List;
+
+/**
+ * 用户身份查询条件公共契约。
+ *
+ * 管理端用户查询和跨服务业务查询都通过这组字段筛选用户身份及人脸录入状态。身份类型使用 UPMS
+ * 的持久化/接口编码;学生、老师扩展资料中的字段沿用各自 DTO 的查询语义。
+ */
+public interface BaseUserIdentityQueryInterface {
+
+ /**
+ * 获取用户身份类型筛选集合。
+ *
+ * @return UPMS 身份协议编码集合;为空时不限制身份类型
+ */
+ List getIdentityTypes();
+
+ /**
+ * 设置用户身份类型筛选集合。
+ *
+ * @param identityTypes UPMS 身份协议编码集合;为空时不限制身份类型
+ */
+ void setIdentityTypes(List identityTypes);
+
+ /**
+ * 获取学生身份扩展筛选条件。
+ *
+ * @return 学号、学校、班级或监护人手机号条件;对象为空或字段均为空时不限制学生资料
+ */
+ SysUserStudentDto getStudentInfo();
+
+ /**
+ * 设置学生身份扩展筛选条件。
+ *
+ * @param studentInfo 学生身份查询条件;为空或字段均为空时不限制学生资料
+ */
+ void setStudentInfo(SysUserStudentDto studentInfo);
+
+ /**
+ * 获取老师身份扩展筛选条件。
+ *
+ * @return 老师学号条件;对象为空或学号为空时不限制老师资料
+ */
+ SysUserTeacherDto getTeacherInfo();
+
+ /**
+ * 设置老师身份扩展筛选条件。
+ *
+ * @param teacherInfo 老师身份查询条件;为空或学号为空时不限制老师资料
+ */
+ void setTeacherInfo(SysUserTeacherDto teacherInfo);
+
+ /**
+ * 获取人脸录入状态筛选条件。
+ *
+ * @return {@code 1} 表示存在未删除人脸记录,{@code 0} 表示不存在,空值表示不限制
+ */
+ String getHasFace();
+
+ /**
+ * 设置人脸录入状态筛选条件。
+ *
+ * @param hasFace {@code 1} 表示已录入,{@code 0} 表示未录入,空值表示全部
+ */
+ void setHasFace(String hasFace);
+}
diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java
index b70bff65d..5c8dc2589 100644
--- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java
+++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java
@@ -334,7 +334,7 @@ public class SysUserController {
/**
* 分页查询当前操作者数据权限内的用户基础信息和身份资料。
*
- * 该接口供无需角色、岗位、人脸和审批信息的业务列表调用,避免复用管理端完整用户分页产生额外查询。
+ * 该接口供无需返回角色、岗位、人脸和审批信息、但可能按人脸录入状态筛选的业务列表调用,避免复用管理端完整用户分页产生额外查询。
*
* @param page 分页参数
* @param userDTO 用户关键字、部门树和指定用户筛选条件
diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java
index d6d4f9c60..3bebaef55 100644
--- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java
+++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java
@@ -370,7 +370,7 @@ public class SysUserServiceImpl extends ServiceImpl impl
* 分页查询用户基础信息并批量补齐身份资料。
*
* 预约等业务列表只需要用户、部门和身份信息,因此这里使用无嵌套角色、岗位映射的专用 SQL,
- * 同时跳过人脸和审批远程查询。
+ * 同时不返回人脸资料和审批信息,但保留人脸录入状态筛选。
*
* @param page 分页参数
* @param userDTO 用户关键字、部门树和指定用户筛选条件
diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserMapper.xml b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserMapper.xml
index c747579ee..bc0634f37 100644
--- a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserMapper.xml
+++ b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserMapper.xml
@@ -376,6 +376,23 @@
+
+
+
+
+ AND EXISTS (
+ SELECT 1 FROM sys_user_face f
+ WHERE f.user_id = u.user_id AND f.del_flag = '0'
+ )
+
+
+ AND NOT EXISTS (
+ SELECT 1 FROM sys_user_face f
+ WHERE f.user_id = u.user_id AND f.del_flag = '0'
+ )
+
+
+
ORDER BY u.create_time DESC, u.user_id DESC