feat:餐段购买统计日期

This commit is contained in:
dzc committed 2026-11-02 13:16:22 +08:00
1 parent 0c08d59457
commit 3b937f2e58
9 files changed
+371 -21

No files matched your search

@@ -0,0 +1,14 @@
package com.bsd.pjcloud.hard.api.constant.order;
/**
* 食堂销售统计中已核销预约的日期归属口径,仅用于查询协议,不持久化到订单表。
* 线下购买始终按支付时间统计,不受此枚举影响。
*/
public enum HardCanteenReservationDateBasisEnum {
/** 按实际核销时间统计;未传口径或显式传空时沿用此历史行为。 */
VERIFICATION_TIME,
/** 仅保留已核销订单,按预约用餐日期统计;预约日期为空时不计入。 */
APPT_USE_DATE
}
@@ -16,7 +16,7 @@ public enum HardSaleSourceEnum {
/** 订单类型 1 的线下购买,统计时间取订单支付时间。 */
OFFLINE_PURCHASE("OFFLINE_PURCHASE", "线下购买"),
/** 订单类型 2 且已完成核销的预约,统计时间取订单核销时间。 */
/** 订单类型 2 且已完成核销的预约;默认按核销时间统计,也可由查询口径指定预约用餐日期。 */
RESERVATION_VERIFIED("RESERVATION_VERIFIED", "预约核销");
/** 统计接口和前端标签页使用的来源编码。 */
@@ -2,6 +2,7 @@ package com.bsd.pjcloud.hard.api.dto.order;
import com.bsd.pjcloud.admin.api.vo.base.BaseIsDeepDeptTreeInterface;
import com.bsd.pjcloud.admin.api.vo.base.BaseIsIncludeTreeInterface;
import com.bsd.pjcloud.hard.api.constant.order.HardCanteenReservationDateBasisEnum;
import com.bsd.pjcloud.hard.api.entity.order.HardCarEntity;
import lombok.Data;
@@ -37,9 +38,12 @@ public class HardCarDto extends HardCarEntity implements BaseIsIncludeTreeInterf
/** 预计使用日期范围。 */
private LocalDateTime[] apptUseDateTimeList;
/** 菜品销售统计日期范围,线下按支付时间、预约按核销时间过滤;不落库。 */
/** 菜品销售统计日期闭区间,线下按支付时间、已核销预约按 reservationDateBasis 选择的日期过滤,不落库。 */
private LocalDateTime[] statisticsDateTimeList;
/** 已核销预约的统计日期归属;默认或空值按核销时间,APPT_USE_DATE 按预约用餐日期,不落库。 */
private HardCanteenReservationDateBasisEnum reservationDateBasis = HardCanteenReservationDateBasisEnum.VERIFICATION_TIME;
/** 菜品销售统计来源编码集合,取值来自 HardSaleSourceEnum;为空表示全部来源。 */
private List<String> saleSourceCodes;
@@ -5,6 +5,7 @@ import com.bsd.pjcloud.admin.api.vo.base.BaseUserIdentityQueryInterface;
import com.bsd.pjcloud.admin.api.dto.user.SysUserStudentDto;
import com.bsd.pjcloud.admin.api.dto.user.SysUserTeacherDto;
import com.bsd.pjcloud.common.core.constant.enums.DateRangeTypeEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardCanteenReservationDateBasisEnum;
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
import lombok.Data;
@@ -54,10 +55,13 @@ public class HardOrderDto extends HardOrderEntity implements BaseIsIncludeTreeIn
private LocalDateTime[] payTimeList;
/**
* 食堂销售统计日期范围,线下来源按支付时间、预约来源按核销时间过滤;不落库。
* 食堂销售统计日期闭区间;线下按支付时间,已核销预约按 reservationDateBasis 选择的日期过滤,不落库。
*/
private LocalDateTime[] statisticsDateTimeList;
/** 已核销预约的统计日期归属;默认或空值按核销时间,APPT_USE_DATE 按预约用餐日期,不落库。 */
private HardCanteenReservationDateBasisEnum reservationDateBasis = HardCanteenReservationDateBasisEnum.VERIFICATION_TIME;
/**
* 食堂销售统计来源编码集合,取值来自 HardSaleSourceEnum;为空表示同时查询两种来源。
*/
@@ -32,6 +32,7 @@ import com.bsd.pjcloud.common.excel.stream.BatchExcelExportTemplate;
import com.bsd.pjcloud.common.security.service.PjcloudUser;
import com.bsd.pjcloud.common.security.util.SecurityUtils;
import com.bsd.pjcloud.hard.api.constant.order.HardCanteenStatisticsLevelEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardCanteenReservationDateBasisEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardSaleSourceEnum;
import com.bsd.pjcloud.hard.api.constant.total.DateRangeTypeEnum;
@@ -124,6 +125,9 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService {
*/
private static final String DEPARTMENT_SALES_EXPORT_TASK_NAME = "食堂菜品部门统计导出";
/** 预约用餐日口径的导出文件前缀及任务展示名称,避免与核销日口径的文件混淆。 */
private static final String DINING_DATE_EXPORT_NAME = "食堂用餐日期统计";
/**
* 异步导出提交时使用的占位总数,后台开始后会替换为实际日期批次数。
*/
@@ -773,15 +777,17 @@ public class HardCanteenTotalServiceImpl implements HardCanteenTotalService {
}
/**
* 提交部门统计异步分批导出任务。
* 提交部门统计异步分批导出任务;预约用餐日口径使用独立的展示名称和文件前缀。
*
* @param dto 页面当前的统计日期、来源、餐段、食堂和部门筛选条件
* @param dto 页面当前的统计日期、预约日期口径、来源、餐段、食堂和部门筛选条件
* @return 可供页面轮询的初始任务进度
*/
@Override
public AsyncTaskProgressVO submitDepartmentSalesExport(HardOrderDto dto) {
return submitSalesExport(dto, DEPARTMENT_SALES_EXPORT_REDIS_KEY, DEPARTMENT_SALES_EXPORT_TASK_NAME,
"食堂菜品部门统计", DEPARTMENT_SALES_EXPORT_FILE_TYPE);
boolean diningDateBasis = dto != null && dto.getReservationDateBasis() == HardCanteenReservationDateBasisEnum.APPT_USE_DATE;
return submitSalesExport(dto, DEPARTMENT_SALES_EXPORT_REDIS_KEY,
diningDateBasis ? DINING_DATE_EXPORT_NAME + "导出" : DEPARTMENT_SALES_EXPORT_TASK_NAME,
diningDateBasis ? DINING_DATE_EXPORT_NAME : "食堂菜品部门统计", DEPARTMENT_SALES_EXPORT_FILE_TYPE);
}
/**
@@ -119,7 +119,7 @@
</sql>
<!--
来源分支可以安全下推的公共订单筛选。日期条件在各分支分别使用支付时间或核销时间,外层仍保留
来源分支可以安全下推的公共订单筛选。日期条件在各分支使用支付时间及所选预约日期口径,外层仍保留
同一组条件作为契约保护;这里先缩小订单和购物车连接范围,避免大日期表在派生表中无条件聚合。
-->
<sql id="canteenSaleSourceOrderPushdownWhere">
@@ -176,8 +176,16 @@
</if>
</sql>
<!-- 日期列仅从固定枚举分支选择,投影、退款范围和商品范围共用同一依据,避免提前核销漏扣退款。 -->
<sql id="canteenSaleReservationStatisticsTime">
<choose>
<when test="dto.reservationDateBasis != null and dto.reservationDateBasis.name() == 'APPT_USE_DATE'">o.appt_use_date</when>
<otherwise>o.verification_time</otherwise>
</choose>
</sql>
<!--
预约退款只聚合当前核销统计范围内订单涉及的父购物车,避免每次统计都扫描并汇总全部历史售后。
预约退款只聚合当前统计日期口径范围内订单涉及的父购物车,避免每次统计都扫描并汇总全部历史售后。
商品名称条件不在此应用,退款最终仍按父购物车和商品主键精确关联到已筛选的原购物车行。
-->
<sql id="canteenSaleReservationRefundTotals">
@@ -196,7 +204,7 @@
and o.hour_type is not null and o.hour_type not in ('-1')
where refund_item.del_flag = '0'
<if test="dto.statisticsDateTimeList != null and dto.statisticsDateTimeList.length == 2">
and o.verification_time between #{dto.statisticsDateTimeList[0]} and #{dto.statisticsDateTimeList[1]}
and <include refid="canteenSaleReservationStatisticsTime"/> between #{dto.statisticsDateTimeList[0]} and #{dto.statisticsDateTimeList[1]}
</if>
<include refid="canteenSaleSourceOrderPushdownWhere"/>
group by refund_item.parent_car_id, refund_item.product_id
@@ -204,7 +212,7 @@
</sql>
<!--
新版食堂销售统计按业务发生时间拆分来源:线下订单使用支付时间,预约订单只使用已核销订单的核销时间。
新版食堂销售统计按日期归属拆分来源:线下使用支付时间,已核销预约默认用核销时间,也可按预约用餐日期。
单来源查询只生成对应分支;预约分支先按已核销订单过滤再聚合购物车并扣减退款,避免全量购物车聚合。
-->
<sql id="canteenSaleStatsSource">
@@ -242,8 +250,8 @@
union all
</if>
<if test="dto.saleSourceCodes == null or dto.saleSourceCodes.size() == 0 or dto.saleSourceCodes.contains('RESERVATION_VERIFIED')">
select DATE_FORMAT(o.verification_time, '%Y-%m-%d') as stat_date,
o.verification_time as stat_time,
select DATE_FORMAT(<include refid="canteenSaleReservationStatisticsTime"/>, '%Y-%m-%d') as stat_date,
<include refid="canteenSaleReservationStatisticsTime"/> as stat_time,
o.id as order_id,
o.user_id,
o.merchant_id as mch_id,
@@ -271,18 +279,18 @@
and refund_car.product_id = a.product_id
where a.del_flag = '0'
<if test="dto.statisticsDateTimeList != null and dto.statisticsDateTimeList.length == 2">
and o.verification_time between #{dto.statisticsDateTimeList[0]} and #{dto.statisticsDateTimeList[1]}
and <include refid="canteenSaleReservationStatisticsTime"/> between #{dto.statisticsDateTimeList[0]} and #{dto.statisticsDateTimeList[1]}
</if>
<include refid="canteenSaleSourceOrderPushdownWhere"/>
<include refid="canteenSaleSourceProductPushdownWhere"/>
group by o.id, o.user_id, o.merchant_id, o.user_dept_id, o.dept_id, o.merchant_dept_id,
o.hour_type, o.verification_time, a.product_id, refund_car.product_count, refund_car.total_price
o.hour_type, <include refid="canteenSaleReservationStatisticsTime"/>, a.product_id, refund_car.product_count, refund_car.total_price
having sum(ifnull(a.product_count, 0)) > ifnull(refund_car.product_count, 0)
</if>
</trim>
</sql>
<!-- 新版统计统一在来源子查询外应用筛选,保证支付时间和核销时间使用同一日期控件。 -->
<!-- 新版统计统一在来源子查询外应用筛选,保证支付时间和所选预约日期口径使用同一日期控件。 -->
<sql id="canteenSaleStatsWhere">
<if test="dto.statisticsDateTimeList != null and dto.statisticsDateTimeList.length == 2">
and sale_stats.stat_time between #{dto.statisticsDateTimeList[0]} and #{dto.statisticsDateTimeList[1]}
@@ -287,7 +287,7 @@
</foreach>
or a.refund_status is null)
</if>
<!-- 统计人员明细必须按业务发生时间筛选:线下按支付时间,预约仅按已核销时间。 -->
<!-- 统计人员明细继承汇总口径:线下按支付时间,已核销预约按所选的核销时间或预约用餐日期。 -->
<if test="dto.statisticsDateTimeList != null and dto.statisticsDateTimeList.length == 2">
and
<choose>
@@ -302,7 +302,7 @@
and (a.appt_id is not null or a.appt_rule_id is not null)
and a.order_status = '20'
and a.verification_time is not null
and a.verification_time between #{dto.statisticsDateTimeList[0]} and #{dto.statisticsDateTimeList[1]}
and <include refid="canteenSaleReservationStatisticsTime"/> between #{dto.statisticsDateTimeList[0]} and #{dto.statisticsDateTimeList[1]}
)
</when>
<otherwise>
@@ -314,13 +314,21 @@
and (a.appt_id is not null or a.appt_rule_id is not null)
and a.order_status = '20'
and a.verification_time is not null
and a.verification_time between #{dto.statisticsDateTimeList[0]} and #{dto.statisticsDateTimeList[1]})
and <include refid="canteenSaleReservationStatisticsTime"/> between #{dto.statisticsDateTimeList[0]} and #{dto.statisticsDateTimeList[1]})
)
</otherwise>
</choose>
</if>
</sql>
<!-- 明细查询使用订单别名 a;与购物车统计保持相同的固定日期口径分支,不拼接外部列名。 -->
<sql id="canteenSaleReservationStatisticsTime">
<choose>
<when test="dto.reservationDateBasis != null and dto.reservationDateBasis.name() == 'APPT_USE_DATE'">a.appt_use_date</when>
<otherwise>a.verification_time</otherwise>
</choose>
</sql>
<!--
App 订单可见范围:本人订单;关系图谱用户(与钱包相关人列表同口径)的全部类型订单;
食堂预约订单额外扩展付款人和有效被监护人。管理端查询不设置 appViewerUserId,本片段不生效。
@@ -0,0 +1,207 @@
package com.bsd.pjcloud.hard.mapper.order;
import com.bsd.pjcloud.hard.api.constant.order.HardCanteenReservationDateBasisEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardSaleSourceEnum;
import com.bsd.pjcloud.hard.api.dto.order.HardCarDto;
import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
import org.apache.ibatis.builder.xml.XMLMapperBuilder;
import org.apache.ibatis.io.Resources;
import org.apache.ibatis.session.Configuration;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.InputStream;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 使用真实 Mapper XML 生成动态 SQL,验证预约日期口径贯穿投影、筛选、分组、退款和订单明细。
* 不连接数据库;数据结果和 MySQL 执行计划仍需在联调环境验证。
*/
class HardCanteenReservationDateBasisSqlTest {
/** 统计查询 Mapper 的完整命名空间。 */
private static final String CAR_MAPPER = "com.bsd.pjcloud.hard.mapper.order.HardCarMapper.";
/** 订单明细查询 Mapper 的完整命名空间。 */
private static final String ORDER_MAPPER = "com.bsd.pjcloud.hard.mapper.order.HardOrderMapper.";
/** 每个用例独立解析真实 Mapper,防止动态 SQL 状态或映射注册互相影响。 */
private Configuration configuration;
/**
* 从应用资源加载真实 Mapper XML,包含其所有公共 SQL 片段。
*
* @throws Exception 资源不可读取或 MyBatis 解析失败时抛出
*/
@BeforeEach
void loadMappers() throws Exception {
configuration = new Configuration();
for (String mapper : List.of("HardCarMapper", "HardOrderMapper")) {
String path = "mapper/order/" + mapper + ".xml";
try (InputStream stream = Resources.getResourceAsStream(path)) {
new XMLMapperBuilder(stream, configuration, path, configuration.getSqlFragments()).parse();
}
}
}
/** 验证提前或延后核销均不限制核销时间范围,汇总、部门餐段和导出的日期统一取预约日。 */
@Test
void appointmentDateIsUsedByEveryStatisticsQueryAndRefundSubquery() {
HardOrderDto dto = query(HardCanteenReservationDateBasisEnum.APPT_USE_DATE);
for (String statement : List.of("listByDeptStats", "listByHourTypeStats", "listSalesExportRows")) {
String sql = sql(CAR_MAPPER + statement, dto);
assertTrue(sql.contains("date_format(o.appt_use_date,'%y-%m-%d') as stat_date"), statement);
assertTrue(sql.contains("o.appt_use_date as stat_time"), statement);
assertEquals(2, occurrences(sql, "o.appt_use_date between ? and ?"), statement);
assertFalse(sql.contains("o.verification_time between"), statement);
assertTrue(sql.contains("o.verification_time is not null"), statement);
assertTrue(sql.contains("o.hour_type,o.appt_use_date,a.product_id"), statement);
assertTrue(sql.contains("o.pay_time between ? and ?"), statement);
assertFalse(sql.contains("coalesce(o.appt_use_date"), statement);
}
}
/** 验证未选择新口径的历史请求和显式空值继续按核销时间查询,线下仍使用支付时间。 */
@Test
void defaultAndNullBasisKeepVerificationTimeBehavior() {
HardOrderDto defaults = new HardOrderDto();
assertEquals(HardCanteenReservationDateBasisEnum.VERIFICATION_TIME, defaults.getReservationDateBasis());
assertEquals(HardCanteenReservationDateBasisEnum.VERIFICATION_TIME, new HardCarDto().getReservationDateBasis());
for (HardCanteenReservationDateBasisEnum basis : new HardCanteenReservationDateBasisEnum[]{
defaults.getReservationDateBasis(), null}) {
String sql = sql(CAR_MAPPER + "listByDeptStats", query(basis));
assertTrue(sql.contains("date_format(o.verification_time,'%y-%m-%d') as stat_date"));
assertEquals(2, occurrences(sql, "o.verification_time between ? and ?"));
assertFalse(sql.contains("o.appt_use_date between"));
assertTrue(sql.contains("o.pay_time between ? and ?"));
}
}
/** 验证仅预约来源时不会拼接线下分支,退款完成状态及净份数、净金额扣减保持原有语义。 */
@Test
void reservationSourceKeepsCompletedRefundDeductionAndVerificationRequirement() {
HardOrderDto dto = query(HardCanteenReservationDateBasisEnum.APPT_USE_DATE);
dto.setSaleSourceCodes(List.of(HardSaleSourceEnum.RESERVATION_VERIFIED.getCode()));
String sql = sql(CAR_MAPPER + "listByDeptStats", dto);
assertFalse(sql.contains("'offline_purchase' as sale_source"));
assertTrue(sql.contains("o.order_status = '20'"));
assertTrue(sql.contains("o.verification_time is not null"));
assertTrue(sql.contains("refund_order.status = '50'"));
assertTrue(sql.contains("greatest(sum(ifnull(a.product_count,0)) - ifnull(refund_car.product_count,0),0)"));
assertTrue(sql.contains("greatest(sum(ifnull(a.total_price,0)) - ifnull(refund_car.total_price,0),0)"));
assertTrue(sql.contains("having sum(ifnull(a.product_count,0)) > ifnull(refund_car.product_count,0)"));
}
/** 验证纯线下查询不受预约日期口径影响,不额外生成预约或退款分支。 */
@Test
void offlineSourceAlwaysUsesPaymentTime() {
HardOrderDto dto = query(HardCanteenReservationDateBasisEnum.APPT_USE_DATE);
dto.setSaleSourceCodes(List.of(HardSaleSourceEnum.OFFLINE_PURCHASE.getCode()));
String sql = sql(CAR_MAPPER + "listByDeptStats", dto);
assertTrue(sql.contains("o.pay_time as stat_time"));
assertTrue(sql.contains("o.pay_time between ? and ?"));
assertFalse(sql.contains("o.appt_use_date"));
assertFalse(sql.contains("hard_after_sale_car"));
}
/** 验证菜品明细使用 HardCarDto 的同一口径,同时保留食堂、部门、餐段和商品筛选。 */
@Test
void productDetailsUseAppointmentDateAndOriginalDimensions() {
HardCarDto dto = new HardCarDto();
dto.setReservationDateBasis(HardCanteenReservationDateBasisEnum.APPT_USE_DATE);
dto.setStatisticsDateTimeList(query(null).getStatisticsDateTimeList());
dto.setSaleSourceCodes(List.of(HardSaleSourceEnum.RESERVATION_VERIFIED.getCode()));
dto.setMerchantId(10L);
dto.setUserDeptIds(List.of(20L));
dto.setIsInclude(true);
dto.setHourType("1");
dto.setProductName("测试菜品");
String sql = sql(CAR_MAPPER + "proudctQueryStats", dto);
assertEquals(2, occurrences(sql, "o.appt_use_date between ? and ?"));
assertFalse(sql.contains("o.verification_time between"));
assertTrue(sql.contains("o.merchant_id = ?"));
assertTrue(sql.contains("o.user_dept_id in"));
assertTrue(sql.contains("o.hour_type = ?"));
assertTrue(sql.contains("sale_stats.product_name like"));
}
/** 验证全部来源与单预约来源的订单明细均按预约日期过滤,同时要求已完成核销。 */
@Test
void orderDetailsShareAppointmentDateForBothSourceSelections() {
HardOrderDto dto = query(HardCanteenReservationDateBasisEnum.APPT_USE_DATE);
for (List<String> sources : List.of(dto.getSaleSourceCodes(), List.of(HardSaleSourceEnum.RESERVATION_VERIFIED.getCode()))) {
dto.setSaleSourceCodes(sources);
String sql = sql(ORDER_MAPPER + "pageJoin", dto);
assertTrue(sql.contains("a.appt_use_date between ? and ?"));
assertTrue(sql.contains("a.verification_time is not null"));
assertTrue(sql.contains("a.order_status = '20'"));
assertFalse(sql.contains("a.verification_time between"));
}
dto.setReservationDateBasis(null);
assertTrue(sql(ORDER_MAPPER + "pageJoin", dto).contains("a.verification_time between ? and ?"));
}
/** 验证部门树的包含和排除继续约束主查询与退款子查询,合计人数仍由数据库独立去重。 */
@Test
void departmentScopeAndDistinctTotalsRemainEffective() {
HardOrderDto dto = query(HardCanteenReservationDateBasisEnum.APPT_USE_DATE);
dto.setUserDeptIds(List.of(20L));
dto.setIsInclude(true);
String included = sql(CAR_MAPPER + "listByDeptStats", dto);
assertTrue(included.contains("o.user_dept_id in"));
assertTrue(included.contains("count(distinct sale_stats.user_id)"));
assertTrue(included.contains("count(distinct sale_stats.order_id)"));
dto.setIsInclude(false);
assertTrue(sql(CAR_MAPPER + "listByDeptStats", dto).contains("o.user_dept_id not in"));
}
/**
* 构造跨三天的查询,核销时间无独立限制,便于覆盖跨日核销的真实查询条件。
*
* @param basis 预约日期口径,允许空值以验证历史兼容
* @return 同时包含线下与预约来源的统计请求
*/
private HardOrderDto query(HardCanteenReservationDateBasisEnum basis) {
HardOrderDto dto = new HardOrderDto();
dto.setReservationDateBasis(basis);
dto.setStatisticsDateTimeList(new LocalDateTime[]{
LocalDateTime.of(2026, 11, 1, 0, 0), LocalDateTime.of(2026, 11, 3, 23, 59, 59)});
dto.setSaleSourceCodes(HardSaleSourceEnum.getCodes());
return dto;
}
/**
* 生成真实 MyBatis BoundSql 并仅规范化空白,避免依赖 XML 的缩进样式。
*
* @param statement 完整的 Mapper 方法标识
* @param dto 对应订单或菜品查询 DTO
* @return 小写且括号内部、逗号两侧没有多余空白的 SQL;保留运算符前后的分隔
*/
private String sql(String statement, Object dto) {
return configuration.getMappedStatement(statement).getBoundSql(Map.of("dto", dto)).getSql()
.replaceAll("\\s+", " ").replaceAll("\\(\\s+", "(").replaceAll("\\s+\\)", ")")
.replaceAll("\\s*,\\s*", ",").toLowerCase(Locale.ROOT);
}
/**
* 统计关键过滤条件出现次数,确保预约商品与退款子查询都采用同一日期范围。
*
* @param sql 已规范化的完整 SQL
* @param fragment 待检查的固定业务过滤片段
* @return 片段在 SQL 中出现的次数
*/
private int occurrences(String sql, String fragment) {
return (sql.length() - sql.replace(fragment, "").length()) / fragment.length();
}
}
@@ -15,9 +15,13 @@ import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressReporter;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskResult;
import com.bsd.pjcloud.common.asynctask.service.AsyncTaskResultAction;
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
import com.bsd.pjcloud.common.security.service.PjcloudUser;
import com.bsd.pjcloud.hard.api.constant.order.HardCanteenStatisticsLevelEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardCanteenReservationDateBasisEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardSaleSourceEnum;
import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
import com.bsd.pjcloud.hard.api.dto.order.HardCarDto;
import com.bsd.pjcloud.hard.api.vo.order.HardCarVo;
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;
@@ -179,7 +183,7 @@ class HardCanteenTotalServiceImplTest {
departmentRow(HardCanteenStatisticsLevelEnum.HOUR_SOURCE, HardSaleSourceEnum.OFFLINE_PURCHASE, "1", 20L),
departmentRow(HardCanteenStatisticsLevelEnum.HOUR_SOURCE, HardSaleSourceEnum.RESERVATION_VERIFIED, "1", 20L)));
List<HardCanteenProductTotalVo> result = service.listByDept(dto, null, null);
List<HardCanteenProductTotalVo> result = service.listByDept(dto, mockDepartmentQueryUser(), null);
assertEquals(1, result.size());
assertEquals("当日合计", result.get(0).getStatisticsLevelName());
@@ -210,7 +214,7 @@ class HardCanteenTotalServiceImplTest {
departmentRow(HardCanteenStatisticsLevelEnum.HOUR_TOTAL, null, "1", 20L),
departmentRow(HardCanteenStatisticsLevelEnum.HOUR_SOURCE, HardSaleSourceEnum.RESERVATION_VERIFIED, "1", 20L)));
List<HardCanteenProductTotalVo> result = service.listByDept(dto, null, null);
List<HardCanteenProductTotalVo> result = service.listByDept(dto, mockDepartmentQueryUser(), null);
assertEquals(1, result.size());
assertTrue(result.get(0).getSourceRows().isEmpty());
@@ -350,6 +354,89 @@ class HardCanteenTotalServiceImplTest {
eq("食堂菜品部门统计导出"), eq(1), any(AsyncTaskResultAction.class));
}
/** 验证预约日期口径完整传入主表与部门餐段下钻查询,不在服务层退回核销日期。 */
@Test
void diningDateBasisReachesDepartmentAndMealQueries() {
HardOrderDto dto = statisticsDto();
dto.setReservationDateBasis(HardCanteenReservationDateBasisEnum.APPT_USE_DATE);
dto.setTotalIsFindChildren(true);
mockCanteenScope();
when(hardCarMapper.listByDeptStats(any(HardOrderDto.class), isNull())).thenReturn(List.of(
departmentRow(HardCanteenStatisticsLevelEnum.DATE_TOTAL, null, null, null)));
when(hardCarMapper.listByHourTypeStats(any(HardOrderDto.class))).thenReturn(List.of(
departmentRow(HardCanteenStatisticsLevelEnum.HOUR_TOTAL, null, "1", 20L)));
List<HardCanteenProductTotalVo> rows = service.listByDept(dto, mockDepartmentQueryUser(), null);
ArgumentCaptor<HardOrderDto> mainQuery = ArgumentCaptor.forClass(HardOrderDto.class);
ArgumentCaptor<HardOrderDto> childQuery = ArgumentCaptor.forClass(HardOrderDto.class);
verify(hardCarMapper).listByDeptStats(mainQuery.capture(), isNull());
verify(hardCarMapper).listByHourTypeStats(childQuery.capture());
assertEquals(HardCanteenReservationDateBasisEnum.APPT_USE_DATE, mainQuery.getValue().getReservationDateBasis());
assertEquals(HardCanteenReservationDateBasisEnum.APPT_USE_DATE, childQuery.getValue().getReservationDateBasis());
assertEquals(1, rows.get(0).getChildren().size());
assertEquals(rows.get(0).getCreateTime(), rows.get(0).getChildren().get(0).getCreateTime());
}
/** 验证菜品明细使用相同的预约日期口径及来源,仍走现有退款扣减统计查询。 */
@Test
void diningDateBasisReachesProductDetails() {
HardCarDto dto = new HardCarDto();
dto.setStatisticsDateTimeList(statisticsDto().getStatisticsDateTimeList());
dto.setReservationDateBasis(HardCanteenReservationDateBasisEnum.APPT_USE_DATE);
dto.setSaleSourceCodes(List.of(HardSaleSourceEnum.RESERVATION_VERIFIED.getCode()));
Page<HardCarVo> page = new Page<>(1, 10);
when(hardCarMapper.proudctQueryStats(any(Page.class), any(HardCarDto.class))).thenReturn(page);
service.proudctQuery(page, dto);
ArgumentCaptor<HardCarDto> query = ArgumentCaptor.forClass(HardCarDto.class);
verify(hardCarMapper).proudctQueryStats(eq(page), query.capture());
assertEquals(HardCanteenReservationDateBasisEnum.APPT_USE_DATE, query.getValue().getReservationDateBasis());
assertEquals(List.of(HardSaleSourceEnum.RESERVATION_VERIFIED.getCode()), query.getValue().getSaleSourceCodes());
verify(hardCarMapper, never()).proudctQuery(any(Page.class), any(HardCarDto.class));
}
/**
* 验证跨月导出冻结预约日期口径;提交后更改原始条件,不影响各自然日批次和文件命名。
*
* @throws Exception 执行捕获的后台任务失败时抛出
*/
@Test
void diningDateExportFreezesBasisAcrossMonthBoundary() throws Exception {
HardOrderDto dto = statisticsDto();
dto.setReservationDateBasis(HardCanteenReservationDateBasisEnum.APPT_USE_DATE);
dto.setStatisticsDateTimeList(new LocalDateTime[]{
LocalDateTime.of(2026, 10, 31, 0, 0), LocalDateTime.of(2026, 11, 2, 23, 59, 59)});
dto.setSaleSourceCodes(new ArrayList<>(List.of(HardSaleSourceEnum.RESERVATION_VERIFIED.getCode())));
mockCanteenScope();
when(hardCarMapper.listSalesExportRows(any(HardOrderDto.class), isNull())).thenReturn(List.of());
when(remoteFileService.innerUpload(any(), eq("hard-export"), isNull(), eq("HARD_CANTEEN_DEPARTMENT_SALES_EXPORT")))
.thenReturn(R.ok(Map.of("url", "https://file.example/dining-date.xlsx")));
service.submitDepartmentSalesExport(dto);
dto.setReservationDateBasis(HardCanteenReservationDateBasisEnum.VERIFICATION_TIME);
dto.getSaleSourceCodes().clear();
dto.getStatisticsDateTimeList()[0] = LocalDateTime.of(2026, 11, 2, 0, 0);
ArgumentCaptor<AsyncTaskResultAction> action = ArgumentCaptor.forClass(AsyncTaskResultAction.class);
verify(asyncTaskExecutorService).submitWithResult(eq("hard:canteen-sales:export:department"),
eq("食堂用餐日期统计导出"), eq(1), action.capture());
AsyncTaskResult result = action.getValue().execute(mock(AsyncTaskProgressReporter.class));
ArgumentCaptor<HardOrderDto> batches = ArgumentCaptor.forClass(HardOrderDto.class);
verify(hardCarMapper, times(3)).listSalesExportRows(batches.capture(), isNull());
assertEquals(List.of(LocalDate.of(2026, 10, 31), LocalDate.of(2026, 11, 1), LocalDate.of(2026, 11, 2)),
batches.getAllValues().stream().map(batch -> batch.getStatisticsDateTimeList()[0].toLocalDate()).toList());
for (HardOrderDto batch : batches.getAllValues()) {
assertEquals(HardCanteenReservationDateBasisEnum.APPT_USE_DATE, batch.getReservationDateBasis());
assertEquals(List.of(HardSaleSourceEnum.RESERVATION_VERIFIED.getCode()), batch.getSaleSourceCodes());
assertEquals(batch.getStatisticsDateTimeList()[0].toLocalDate().atTime(23, 59, 59), batch.getStatisticsDateTimeList()[1]);
}
assertTrue(result.fileName().startsWith("食堂用餐日期统计_"));
assertEquals("https://file.example/dining-date.xlsx", result.fileUrl());
}
/**
* 构造覆盖 2026-08-24 完整自然日的来源统计请求。
*
@@ -395,6 +482,18 @@ class HardCanteenTotalServiceImplTest {
when(remoteDeptService.getDeptByDeptIds(List.of(20L))).thenReturn(R.ok(java.util.Map.of()));
}
/**
* 为部门统计提供真实调用必需的登录用户和自营食堂范围,避免测试使用空用户绕过权限前提。
*
* @return 可查询编号 10 食堂的模拟用户
*/
private PjcloudUser mockDepartmentQueryUser() {
PjcloudUser user = mock(PjcloudUser.class);
when(user.getId()).thenReturn(1L);
when(remoteMchService.getSelfMchIdList(eq(1L), any())).thenReturn(R.ok(List.of(10L)));
return user;
}
/**
* 构造部门统计指定展示层级的 Mapper 行。
*