diff --git a/db/20260822_add_expired_reserve_refund.sql b/db/20260822_add_expired_reserve_refund.sql
new file mode 100644
index 00000000..728aa179
--- /dev/null
+++ b/db/20260822_add_expired_reserve_refund.sql
@@ -0,0 +1,49 @@
+-- 每日菜谱预约订单过期自动退款扩展。
+-- 只增加新快照和任务字段,不回填历史订单;执行前应确认目标字段/索引不存在。
+
+ALTER TABLE `pjcloudx_wallet`.`mch_auto_refund_config`
+ ADD COLUMN `expired_reserve_refund_enabled` char(1) NOT NULL DEFAULT '0'
+ COMMENT '过期预约自动退款开关,0关闭,1开启' AFTER `pay_time_limit_hours`,
+ ADD COLUMN `expired_reserve_refund_delay_hours` int(11) NOT NULL DEFAULT 2
+ COMMENT '餐段结束后的过期退款延迟小时数,允许1至168' AFTER `expired_reserve_refund_enabled`,
+ ADD COLUMN `expired_reserve_refund_restore_inventory` char(1) NOT NULL DEFAULT '1'
+ COMMENT '过期预约退款是否恢复库存,0不恢复,1恢复' AFTER `expired_reserve_refund_delay_hours`;
+
+ALTER TABLE `pjcloudx_hardware`.`hard_order`
+ ADD COLUMN `expired_refund_config_id` bigint(20) DEFAULT NULL
+ COMMENT '过期预约退款配置ID快照,仅新支付成功订单写入' AFTER `auto_refund_deadline`,
+ ADD COLUMN `expired_refund_usage_end_time` datetime DEFAULT NULL
+ COMMENT '支付成功时快照的实际餐段结束时间,无法解析时为空' AFTER `expired_refund_config_id`,
+ ADD COLUMN `expired_refund_deadline` datetime DEFAULT NULL
+ COMMENT '实际餐段结束时间加延迟后的过期退款触发时间' AFTER `expired_refund_usage_end_time`,
+ ADD COLUMN `expired_refund_restore_inventory` char(1) DEFAULT NULL
+ COMMENT '过期退款退库存快照,0不恢复,1恢复' AFTER `expired_refund_deadline`,
+ ADD KEY `idx_hard_order_expired_refund_scan`
+ (`tenant_id`, `expired_refund_deadline`, `order_status`, `del_flag`);
+
+ALTER TABLE `pjcloudx_hardware`.`hard_auto_refund_task`
+ MODIFY COLUMN `after_sale_id` bigint(20) DEFAULT NULL COMMENT '售后单ID,过期预约任务首次执行前为空',
+ MODIFY COLUMN `rule_type` varchar(32) DEFAULT NULL COMMENT '普通售后任务规则快照,过期预约任务为空',
+ MODIFY COLUMN `eligibility_deadline` datetime DEFAULT NULL COMMENT '普通售后免审资格截止时间,过期预约任务为空',
+ ADD COLUMN `task_type` char(10) NOT NULL DEFAULT '10'
+ COMMENT '任务场景,10用户免审售后,20过期预约退款' AFTER `after_sale_id`,
+ ADD COLUMN `trigger_time` datetime DEFAULT NULL
+ COMMENT '过期预约任务按订单快照触发时间,旧售后任务为空' AFTER `eligibility_deadline`,
+ ADD COLUMN `restore_inventory` char(1) DEFAULT NULL
+ COMMENT '任务退库存快照,0不恢复,1恢复' AFTER `trigger_time`;
+
+-- MySQL 5.7 不支持函数索引,使用可索引的存储生成列承载场景业务主键:
+-- 普通售后取 after_sale_id,允许同一订单存在多个售后任务;过期预约取 order_id,保证一单一任务。
+ALTER TABLE `pjcloudx_hardware`.`hard_auto_refund_task`
+ ADD COLUMN `scene_biz_id` bigint(20)
+ GENERATED ALWAYS AS (CASE WHEN `task_type` = '20' THEN `order_id` ELSE `after_sale_id` END) STORED
+ COMMENT '任务场景业务唯一值,系统生成,禁止业务代码写入' AFTER `restore_inventory`,
+ DROP INDEX `uk_hard_auto_refund_task_after_sale`,
+ ADD UNIQUE KEY `uk_hard_auto_refund_task_scene_biz`
+ (`tenant_id`, `task_type`, `scene_biz_id`, `del_flag`),
+ ADD KEY `idx_hard_auto_refund_task_scan_type`
+ (`tenant_id`, `task_type`, `status`, `next_retry_time`, `del_flag`);
+
+ALTER TABLE `pjcloudx_hardware`.`hard_auto_refund_attempt_log`
+ MODIFY COLUMN `after_sale_id` bigint(20) DEFAULT NULL COMMENT '售后单ID,过期任务创建售后前为空',
+ MODIFY COLUMN `refund_code` varchar(128) NOT NULL COMMENT '稳定钱包退款业务号,过期任务按订单生成';
diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/api/feign/RemoteCanteenSpuService.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/api/feign/RemoteCanteenSpuService.java
index 7e8738ce..e7c8483d 100644
--- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/api/feign/RemoteCanteenSpuService.java
+++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/api/feign/RemoteCanteenSpuService.java
@@ -26,6 +26,7 @@ import com.bsd.pjcloud.canteen.entity.CanteenSpuEntity;
import com.bsd.pjcloud.canteen.vo.app.OnlineSpuComboItemVO;
import com.bsd.pjcloud.common.core.constant.ServiceNameConstants;
import com.bsd.pjcloud.common.core.util.R;
+import com.bsd.pjcloud.common.feign.annotation.NoToken;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
@@ -67,6 +68,16 @@ public interface RemoteCanteenSpuService {
@PostMapping("/canteenSpu/ad/updateSkuStock")
R updateSkuStock(@RequestBody CanteenSpuUpdateStockReqDTO updateStockReqDTO);
+ /**
+ * 按稳定业务号原子应用或回滚每日菜谱基础 SPU 库存变更。
+ *
+ * @param updateStockReqDTO 商品增量、操作类型、业务号和操作前库存快照
+ * @return 操作成功时返回成功响应,重复请求按幂等成功处理
+ */
+ @PostMapping("/canteenSpu/inner/updateSkuStockIdempotent")
+ @NoToken
+ R updateSkuStockIdempotent(@RequestBody CanteenSpuUpdateStockReqDTO updateStockReqDTO);
+
/**
* 根据ids查询菜品
diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenSpuUpdateStockReqDTO.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenSpuUpdateStockReqDTO.java
index ca682974..c09b7c08 100644
--- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenSpuUpdateStockReqDTO.java
+++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenSpuUpdateStockReqDTO.java
@@ -25,9 +25,10 @@ public class CanteenSpuUpdateStockReqDTO {
private String bizId;
/**
- * DEDUCT 扣减,ROLLBACK 恢复;仅在 bizId 非空时参与幂等控制。
+ * 库存幂等操作类型。DEDUCT 保留给在线菜品旧协议,APPLY 按 incrCount 正负值应用基础菜品库存变更,
+ * ROLLBACK 使用同一业务号反向撤销 APPLY/DEDUCT;仅在 bizId 非空时参与幂等控制。
*/
- @Pattern(regexp = "DEDUCT|ROLLBACK", message = "库存操作类型错误")
+ @Pattern(regexp = "DEDUCT|APPLY|ROLLBACK", message = "库存操作类型错误")
private String operation;
/**
diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenSpuController.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenSpuController.java
index 27a6466e..6b7d4a9c 100644
--- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenSpuController.java
+++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenSpuController.java
@@ -30,6 +30,7 @@ import com.bsd.pjcloud.common.log.annotation.SysLog;
import com.bsd.pjcloud.canteen.entity.CanteenSpuEntity;
import com.bsd.pjcloud.canteen.service.CanteenSpuService;
import com.bsd.pjcloud.common.security.util.SecurityUtils;
+import com.bsd.pjcloud.common.security.annotation.Inner;
import org.springframework.security.access.prepost.PreAuthorize;
import com.bsd.pjcloud.common.excel.annotation.ResponseExcel;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
@@ -68,6 +69,19 @@ public class CanteenSpuController {
private final CanteenMerchantScopeHelper merchantScopeHelper;
+ /**
+ * 每日菜谱订单按业务号应用或回滚基础菜品库存,调用方必须携带操作前库存快照。
+ *
+ * @param updateStockReqDTO 库存业务号、操作类型及商品增量
+ * @return 幂等库存操作结果
+ */
+ @Inner
+ @PostMapping("/inner/updateSkuStockIdempotent")
+ @Transactional(rollbackFor = Exception.class)
+ public R updateSkuStockIdempotent(@RequestBody CanteenSpuUpdateStockReqDTO updateStockReqDTO) {
+ return canteenSpuService.updateSkuStockIdempotent(updateStockReqDTO);
+ }
+
/**
* 分页查询
*
diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenSpuMapper.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenSpuMapper.java
index 227f5f9a..706a6b64 100644
--- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenSpuMapper.java
+++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenSpuMapper.java
@@ -13,11 +13,11 @@ public interface CanteenSpuMapper extends PjcloudBaseMapper {
* 更新商品 SPU 库存
*
* @param id 商品 SPU 编号
- * @param incrCount 增加的库存数量
+ * @param incrCount 库存增量,正数恢复库存、负数扣减库存
*/
default int updateStock(Long id, Integer incrCount) {
// 拼接 SQL
- if (incrCount == 0) {
+ if (id == null || incrCount == null || incrCount == 0 || incrCount == Integer.MIN_VALUE) {
return 0;
}
String sql;
@@ -29,7 +29,36 @@ public interface CanteenSpuMapper extends PjcloudBaseMapper {
// 执行更新
LambdaUpdateWrapper updateWrapper = new LambdaUpdateWrapper()
.setSql(sql)
- .eq(CanteenSpuEntity::getId, id);
+ .eq(CanteenSpuEntity::getId, id)
+ // 扣减条件必须落到同一条 UPDATE,避免并发请求都通过前置查询后把库存扣成负数。
+ .ge(incrCount < 0, CanteenSpuEntity::getStock, Math.abs(incrCount));
return update(null, updateWrapper);
}
-}
\ No newline at end of file
+
+ /**
+ * 仅在数据库当前库存仍等于业务快照时更新库存和销量,避免不同业务号并发操作同一 SPU 时丢失更新。
+ *
+ * @param id 商品 SPU 编号
+ * @param incrCount 库存增量,负数扣减、正数恢复
+ * @param expectedStock 本次业务读取到的操作前库存
+ * @return 影响行数;返回 0 表示库存已变化或扣减后会小于 0
+ */
+ default int updateStockIfMatch(Long id, Integer incrCount, Integer expectedStock) {
+ if (id == null || incrCount == null || incrCount == 0 || incrCount == Integer.MIN_VALUE
+ || expectedStock == null || expectedStock < 0) {
+ return 0;
+ }
+ String sql;
+ if (incrCount > 0) {
+ sql = " stock = stock + " + incrCount + ", sales_count = sales_count - " + incrCount;
+ } else {
+ sql = " stock = stock - " + Math.abs(incrCount) + ", sales_count = sales_count + " + Math.abs(incrCount);
+ }
+ LambdaUpdateWrapper updateWrapper = new LambdaUpdateWrapper()
+ .setSql(sql)
+ .eq(CanteenSpuEntity::getId, id)
+ .eq(CanteenSpuEntity::getStock, expectedStock)
+ .ge(incrCount < 0, CanteenSpuEntity::getStock, Math.abs(incrCount));
+ return update(null, updateWrapper);
+ }
+}
diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenSpuService.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenSpuService.java
index 6164cd82..de78463e 100644
--- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenSpuService.java
+++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenSpuService.java
@@ -32,6 +32,14 @@ public interface CanteenSpuService extends IService {
*/
R updateSkuStock(CanteenSpuUpdateStockReqDTO updateStockReqDTO);
+ /**
+ * 按业务号原子应用或回滚基础菜品库存,供每日菜谱下单和退款跨服务调用。
+ *
+ * @param updateStockReqDTO 商品库存增量、操作前快照、业务号和操作类型
+ * @return 成功、幂等命中或库存校验失败的统一响应
+ */
+ R updateSkuStockIdempotent(CanteenSpuUpdateStockReqDTO updateStockReqDTO);
+
/**
* 根据菜品分类id获取菜品信息
@@ -106,4 +114,4 @@ public interface CanteenSpuService extends IService {
* 批量加载套餐组成(App/每日菜谱展示)
*/
Map> loadComboItemVos(List comboSpuIds);
-}
\ No newline at end of file
+}
diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImpl.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImpl.java
index 20913428..008f3853 100644
--- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImpl.java
+++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImpl.java
@@ -2,6 +2,7 @@ package com.bsd.pjcloud.canteen.service.impl;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
+import com.baomidou.lock.annotation.Lock4j;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
@@ -26,10 +27,14 @@ import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import lombok.AllArgsConstructor;
import org.apache.commons.lang3.StringUtils;
+import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.util.CollectionUtils;
+import java.time.Duration;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
@@ -44,6 +49,17 @@ import java.util.stream.Collectors;
@AllArgsConstructor
public class CanteenSpuServiceImpl extends ServiceImpl implements CanteenSpuService {
+ /** 每日菜谱基础 SPU 库存幂等状态键前缀,租户隔离由 Redis 基础设施统一处理。 */
+ private static final String ORDER_STOCK_BIZ_KEY_PREFIX = "canteen:spu:order-stock:";
+
+ /** 库存幂等状态最长保留时间,覆盖远程超时重试和延迟请求后自动释放 Redis 空间。 */
+ private static final Duration ORDER_STOCK_BIZ_STATE_TTL = Duration.ofDays(7);
+
+ /** 已确认 APPLY 成功且随后完成反向补偿;允许业务任务使用同一幂等号显式重试。 */
+ private static final String ORDER_STOCK_BIZ_STATE_COMPENSATED = "COMPENSATED";
+
+ /** 库存业务状态存储,用于识别跨服务超时后的重复应用和补偿请求。 */
+ private final StringRedisTemplate stringRedisTemplate;
private final CanteenDailyMenuMapper canteenDailyMenuMapper;
private final CanteenCategoryMapper canteenCategoryMapper;
@@ -58,29 +74,152 @@ public class CanteenSpuServiceImpl extends ServiceImpl {
- // 查询当前库存
+ if (updateStockReqDTO == null || CollUtil.isEmpty(updateStockReqDTO.getItems())) {
+ return R.failed("库存商品不能为空");
+ }
+ // 必须先校验完整批次再更新,后一个商品失败时由本地事务保证前面的商品不会单独提交。
+ for (CanteenSpuUpdateStockReqDTO.Item item : updateStockReqDTO.getItems()) {
+ if (item == null || item.getId() == null || item.getIncrCount() == null
+ || item.getIncrCount() == 0 || item.getIncrCount() == Integer.MIN_VALUE) {
+ return R.failed("库存商品参数错误");
+ }
CanteenSpuEntity spu = baseMapper.selectById(item.getId());
if (spu == null) {
- R.failed(spu.getName() + "商品不存在");
+ return R.failed("商品不存在");
}
- // 检查库存是否足够
- if (item.getIncrCount() < 0 && Math.abs(item.getIncrCount()) > spu.getStock()) {
- R.failed(spu.getName() + "库存不足,无法完成操作");
+ if (item.getIncrCount() == null || item.getIncrCount() == 0) {
+ return R.failed(StrUtil.blankToDefault(spu.getName(), "商品") + "库存变化数量无效");
}
- int i = baseMapper.updateStock(item.getId(), item.getIncrCount());
-// if (item.getIncrCount() <= 0) {
-// if (i == 0) {
-// R.failed("商品库存不足");
-// }
-// }
- });
+ if (item.getIncrCount() < 0 && (spu.getStock() == null || Math.abs(item.getIncrCount()) > spu.getStock())) {
+ return R.failed(StrUtil.blankToDefault(spu.getName(), "商品") + "库存不足,无法完成操作");
+ }
+ }
+ for (CanteenSpuUpdateStockReqDTO.Item item : updateStockReqDTO.getItems()) {
+ if (baseMapper.updateStock(item.getId(), item.getIncrCount()) == 0) {
+ throw new RuntimeException("库存不足,无法完成操作");
+ }
+ }
return R.ok();
}
+ /**
+ * 对同一业务号串行应用或反向补偿基础 SPU 库存。APPLY 同时支持下单负增量和退款正增量,
+ * ROLLBACK 必须携带原 APPLY 的增量与操作前快照,避免远程响应超时时重复扣减或重复恢复。
+ *
+ * @param dto 商品增量、操作前库存快照、稳定业务号和操作类型
+ * @return 成功、幂等命中或库存状态冲突的统一响应
+ */
+ @Override
+ @Lock4j(keys = {"#dto.bizId"})
+ @Transactional(rollbackFor = Exception.class)
+ public R updateSkuStockIdempotent(CanteenSpuUpdateStockReqDTO dto) {
+ String operation = dto == null ? null : StrUtil.trim(dto.getOperation());
+ boolean applyOperation = "APPLY".equals(operation);
+ boolean rollbackOperation = "ROLLBACK".equals(operation);
+ if (dto == null || StrUtil.isBlank(dto.getBizId()) || CollUtil.isEmpty(dto.getItems())
+ || (!applyOperation && !rollbackOperation)
+ || dto.getItems().stream().anyMatch(item -> item == null || item.getId() == null
+ || item.getIncrCount() == null || item.getIncrCount() == 0 || item.getBeforeStock() == null)) {
+ return R.failed("库存业务参数错误");
+ }
+ if (dto.getItems().stream().map(CanteenSpuUpdateStockReqDTO.Item::getId).distinct().count() != dto.getItems().size()) {
+ return R.failed("库存商品不能重复");
+ }
+
+ String stateKey = ORDER_STOCK_BIZ_KEY_PREFIX + dto.getBizId();
+ String state = stringRedisTemplate.opsForValue().get(stateKey);
+ // ROLLED_BACK 表示补偿先于 APPLY 或无法确认 APPLY 成功,必须阻断延迟请求;
+ // COMPENSATED 表示已确认 APPLY 后又完成反向补偿,任务重试可以使用新库存快照重新执行。
+ if (applyOperation && ("APPLIED".equals(state) || "ROLLED_BACK".equals(state))) {
+ return R.ok();
+ }
+ if (rollbackOperation && ("ROLLED_BACK".equals(state)
+ || ORDER_STOCK_BIZ_STATE_COMPENSATED.equals(state))) {
+ return R.ok();
+ }
+ for (CanteenSpuUpdateStockReqDTO.Item item : dto.getItems()) {
+ CanteenSpuEntity spu = baseMapper.selectById(item.getId());
+ if (spu == null || spu.getStock() == null) {
+ return R.failed("商品不存在或库存异常");
+ }
+ int expectedStock = item.getBeforeStock();
+ if (expectedStock < 0) {
+ return R.failed("库存快照异常");
+ }
+ int targetStock = Math.addExact(expectedStock, item.getIncrCount());
+ if (targetStock < 0) {
+ return R.failed(StrUtil.blankToDefault(spu.getName(), "商品") + "库存不足,无法完成操作");
+ }
+ if (applyOperation) {
+ // 不得把“库存恰好等于目标值”当成本业务已经执行;该值可能由另一个业务号并发扣减得到。
+ // 幂等成功只能依赖上方读取到的 APPLIED 状态,状态缺失时必须从调用方快照原子更新。
+ if (spu.getStock() != expectedStock) {
+ return R.failed(StrUtil.blankToDefault(spu.getName(), "商品") + "库存已变化,请重新操作");
+ }
+ } else if ("APPLIED".equals(state)) {
+ int rollbackDelta = -item.getIncrCount();
+ if (rollbackDelta < 0 && spu.getStock() < Math.abs(rollbackDelta)) {
+ return R.failed(StrUtil.blankToDefault(spu.getName(), "商品") + "库存不足,无法自动补偿");
+ }
+ }
+ }
+
+ for (CanteenSpuUpdateStockReqDTO.Item item : dto.getItems()) {
+ int expectedStock = item.getBeforeStock();
+ int delta = applyOperation ? item.getIncrCount() : -item.getIncrCount();
+ if (rollbackOperation && "APPLIED".equals(state)) {
+ // 补偿不能再要求库存等于本业务 APPLY 后的快照:期间可能已有其他订单合法改变同一 SPU。
+ // 同一业务号由锁和 APPLIED 状态保证只反向一次,数据库条件更新只负责阻止库存扣成负数。
+ if (baseMapper.updateStock(item.getId(), delta) == 0) {
+ throw new RuntimeException("库存补偿失败,请重试");
+ }
+ continue;
+ }
+ // 状态缺失的 ROLLBACK 说明没有可确认的成功 APPLY,只写入终态阻断延迟请求,不能按库存值猜测并恢复。
+ if (applyOperation && baseMapper.updateStockIfMatch(item.getId(), delta, expectedStock) == 0) {
+ throw new RuntimeException("库存已变化,请重新操作");
+ }
+ }
+ String targetState = applyOperation ? "APPLIED"
+ : "APPLIED".equals(state) ? ORDER_STOCK_BIZ_STATE_COMPENSATED : "ROLLED_BACK";
+ updateStockBizState(stateKey, state, targetState);
+ return R.ok();
+ }
+
+ /**
+ * 写入库存幂等状态,并在食堂本地数据库事务回滚时恢复原状态,防止出现“状态成功、库存未提交”。
+ *
+ * @param stateKey 当前租户下的库存业务状态键
+ * @param previousState 调用前状态,空值表示此前没有记录
+ * @param targetState 本次库存操作完成后的状态
+ */
+ private void updateStockBizState(String stateKey, String previousState, String targetState) {
+ if (!TransactionSynchronizationManager.isSynchronizationActive()) {
+ throw new RuntimeException("库存事务状态异常,请稍后重试");
+ }
+ TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+ /**
+ * 数据库事务回滚后同步撤销本次 Redis 状态,后续请求才能根据实际库存重新判断。
+ *
+ * @param status Spring 本地事务完成状态
+ */
+ @Override
+ public void afterCompletion(int status) {
+ if (status != TransactionSynchronization.STATUS_ROLLED_BACK) {
+ return;
+ }
+ if (previousState == null) {
+ stringRedisTemplate.delete(stateKey);
+ } else {
+ stringRedisTemplate.opsForValue().set(stateKey, previousState, ORDER_STOCK_BIZ_STATE_TTL);
+ }
+ }
+ });
+ stringRedisTemplate.opsForValue().set(stateKey, targetState, ORDER_STOCK_BIZ_STATE_TTL);
+ }
+
@Override
public R getCanteenSpuList(String categoryId) {
List canteenSpuEntities = baseMapper.selectListByScope(new LambdaQueryWrapper()
@@ -481,4 +620,4 @@ public class CanteenSpuServiceImpl extends ServiceImpl mchIds;
/** 预计使用日期范围。 */
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardAutoRefundTaskEntity.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardAutoRefundTaskEntity.java
index 21024beb..ad5e6842 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardAutoRefundTaskEntity.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardAutoRefundTaskEntity.java
@@ -18,7 +18,8 @@ import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
- * Hardware 自动退款持久化任务。一个售后单最多创建一条任务,失败后由该记录持续驱动恢复。
+ * Hardware 自动退款持久化任务。售后免审任务创建时绑定售后单,过期预约任务在实际执行前允许暂不绑定,
+ * 失败后由该记录持续驱动恢复。
*/
@Data
@Builder
@@ -38,11 +39,15 @@ public class HardAutoRefundTaskEntity extends Model {
private Long id;
/**
- * 售后单 ID,是任务唯一业务键和钱包退款幂等键。
+ * 售后单 ID;普通售后任务创建时必填,过期预约任务创建系统售后单前为空,完成后作为钱包退款幂等键。
*/
@Schema(description = "售后单ID")
private Long afterSaleId;
+ /** 任务场景编码;10为用户免审售后,20为过期预约退款。 */
+ @Schema(description = "任务场景")
+ private String taskType;
+
/**
* 原 Hardware 订单 ID,用于订单级分布式锁和退款状态聚合。
*/
@@ -73,6 +78,14 @@ public class HardAutoRefundTaskEntity extends Model {
@Schema(description = "免审资格截止时间")
private LocalDateTime eligibilityDeadline;
+ /** 过期预约任务的订单快照触发时间;旧售后任务为空。 */
+ @Schema(description = "过期退款触发时间")
+ private LocalDateTime triggerTime;
+
+ /** 任务创建时快照的退库存开关,1恢复、0不恢复。 */
+ @Schema(description = "退库存快照")
+ private String restoreInventory;
+
/**
* 当前任务状态编码,对应 HardAutoRefundTaskStatusEnum。
*/
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEntity.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEntity.java
index b866cf6c..7b73513d 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEntity.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/entity/order/HardOrderEntity.java
@@ -159,6 +159,22 @@ public class HardOrderEntity extends Model {
@Schema(description = "自动退款申请截止时间")
private LocalDateTime autoRefundDeadline;
+ /** 每日菜谱过期退款配置 ID 快照;仅支付成功且下单时开关开启的新订单写入。 */
+ @Schema(description = "过期预约退款配置ID快照")
+ private Long expiredRefundConfigId;
+
+ /** 预约订单支付成功时解析出的实际餐段结束时间;为空表示无法安全确定过期资格。 */
+ @Schema(description = "过期预约餐段实际结束时间快照")
+ private LocalDateTime expiredRefundUsageEndTime;
+
+ /** 实际餐段结束时间加配置延迟后的过期退款触发时间。 */
+ @Schema(description = "过期预约退款触发时间")
+ private LocalDateTime expiredRefundDeadline;
+
+ /** 过期退款是否恢复库存的订单快照,1恢复、0不恢复。 */
+ @Schema(description = "过期预约退款退库存快照")
+ private String expiredRefundRestoreInventory;
+
/**
* 订单设备类型
*/
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAutoRefundTaskVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAutoRefundTaskVo.java
index bb2c3106..e44b13d4 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAutoRefundTaskVo.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/order/HardAutoRefundTaskVo.java
@@ -42,4 +42,12 @@ public class HardAutoRefundTaskVo extends HardAutoRefundTaskEntity {
*/
@Schema(description = "售后状态")
private String afterSaleStatus;
+
+ /** 预约订单实际使用日期,过期候选列表展示用。 */
+ @Schema(description = "预约日期")
+ private java.time.LocalDate apptUseDate;
+
+ /** 预约餐段编码,过期候选列表展示用。 */
+ @Schema(description = "餐段")
+ private String hourType;
}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAutoRefundTaskController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAutoRefundTaskController.java
index a66b061a..faecc1da 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAutoRefundTaskController.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardAutoRefundTaskController.java
@@ -80,6 +80,35 @@ public class HardAutoRefundTaskController {
return R.ok(taskService.pageTasks(page, dto));
}
+ /**
+ * 查询已到期且仍可整单退款的每日菜谱预约订单;失败或重试耗尽任务不混入候选。
+ */
+ @GetMapping("/expired-candidates")
+ @Operation(summary = "查询待过期预约退款订单")
+ @PreAuthorize("@pms.hasPermission('hard_hardAfterSale_view')")
+ public R> expiredCandidates(@ParameterObject Page page,
+ @ParameterObject HardAutoRefundTaskPageDto dto) {
+ List scopedMchIds = listScopedMerchantIds();
+ if (scopedMchIds.isEmpty()) {
+ Page empty = new Page<>(page.getCurrent(), page.getSize(), 0L);
+ empty.setRecords(List.of());
+ return R.ok(empty);
+ }
+ dto.setMchIds(scopedMchIds);
+ return R.ok(taskService.pageExpiredCandidates(page, dto));
+ }
+
+ /** 管理端确认后逐单执行过期预约整单退款。 */
+ @PostMapping("/{id}/expired-refund")
+ @Operation(summary = "过期预约订单退款")
+ @Idempotent(expireTime = 5, info = "正在退款,请勿重复操作")
+ @PreAuthorize("@pms.hasPermission('hard_hardAfterSale_adminRefund')")
+ public R expiredRefund(@PathVariable Long id) {
+ validateTaskScope(id);
+ taskService.manualExpiredRefund(id, SecurityUtils.getUser().getId());
+ return R.ok(true);
+ }
+
/**
* 查询指定任务全部只读尝试日志。
*
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenApptTotalController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenApptTotalController.java
index 097bb40a..26048912 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenApptTotalController.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardCanteenApptTotalController.java
@@ -16,8 +16,12 @@ import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
import com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductTotalVo;
import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptOrderVo;
import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptVo;
+import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptStatisticsVo;
import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenUserApptExcelVo;
+import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenProductApptExcelVo;
+import com.bsd.pjcloud.hard.excel.HardCanteenProductApptExportMergeHandler;
import com.bsd.pjcloud.hard.service.HardCanteenApptTotalService;
+import com.bsd.pjcloud.hard.service.HardCanteenApptTotalExportService;
import com.bsd.pjcloud.hard.service.HardCanteenTotalService;
import com.bsd.pjcloud.wallet.api.dto.MchManageDto;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchService;
@@ -51,11 +55,19 @@ import java.util.List;
@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
public class HardCanteenApptTotalController {
+ /** 无可见食堂时使用的不可命中商户主键,避免空集合被 SQL 当作“不筛选”而造成越权导出。 */
+ private static final Long NO_VISIBLE_CANTEEN_ID = Long.MIN_VALUE;
+
/**
* 食堂预约统计服务,负责菜品和用户维度的预约聚合。
*/
private final HardCanteenApptTotalService canteenApptTotalService;
+ /**
+ * 食堂预约导出服务,负责异步任务、分批写入和文件上传,不让统计服务承担长耗时导出职责。
+ */
+ private final HardCanteenApptTotalExportService canteenApptTotalExportService;
+
/**
* 通用食堂统计服务,复用部门聚合以及商户、部门信息补全能力。
*/
@@ -170,6 +182,21 @@ public class HardCanteenApptTotalController {
return R.ok(canteenApptTotalService.userReservationDetail(userId, apptUseDate));
}
+ /**
+ * 查询当前用户预约筛选条件对应的每日和范围总计。
+ *
+ * @param query 用户、日期范围、食堂、餐段和状态筛选条件
+ * @return 预约统计结果
+ */
+ @Operation(summary = "用户预约统计", description = "按日统计用户预约人数、餐段及菜品份数")
+ @ApiCryptoAes
+ @PostMapping("/user-reserv-statistics")
+ @PreAuthorize("@pms.hasPermission('hard_canteenUserApptTotal_page')")
+ public R userReservationStatistics(
+ @Valid @RequestBody HardCanteenUserApptQueryDto query) {
+ return R.ok(canteenApptTotalService.userReservationStatistics(query));
+ }
+
/**
* 提交用户每日预约异步导出任务。
*
@@ -182,7 +209,7 @@ public class HardCanteenApptTotalController {
@PostMapping("/user-reserv-export-task")
public R submitUserReservationExport(
@Valid @RequestBody HardCanteenUserApptQueryDto query) {
- return R.ok(canteenApptTotalService.submitUserReservationExport(query), "用户预约导出任务已提交");
+ return R.ok(canteenApptTotalExportService.submitUserReservationExport(query), "用户预约导出任务已提交");
}
/**
@@ -198,7 +225,70 @@ public class HardCanteenApptTotalController {
@PostMapping("/user-reserv-export")
public List userReservationExport(
@Valid @RequestBody HardCanteenUserApptQueryDto query) {
- return canteenApptTotalService.userReservationExport(query);
+ return canteenApptTotalExportService.userReservationExport(query);
+ }
+
+ /**
+ * 提交菜品预约统计异步导出任务。
+ *
+ * @param query 菜品预约日期、餐段、食堂和部门筛选条件
+ * @return 供前端轮询的初始任务进度
+ */
+ @Operation(summary = "菜品预约统计异步导出", description = "按日期分批生成菜品预约三级统计 Excel")
+ @SysLog("菜品预约统计异步导出")
+ @PreAuthorize("@pms.hasPermission('hard_canteenProductApptTotal_export')")
+ @PostMapping("/product-reserv-export-task")
+ public R submitProductReservationExport(@RequestBody HardOrderDto query) {
+ applyCanteenMerchantScope(query);
+ return R.ok(canteenApptTotalExportService.submitProductReservationExport(query), "菜品预约导出任务已提交");
+ }
+
+ /**
+ * 同步菜品预约统计导出接口,保留给已有调用方;管理端页面使用异步任务接口。
+ *
+ * @param query 菜品预约统计筛选条件
+ * @return 按日期餐段、商户部门和商品三级组织的自定义 Excel 行
+ */
+ @Operation(summary = "菜品预约统计导出", description = "导出日期餐段、商户部门和商品三级菜品预约统计")
+ @SysLog("菜品预约统计导出")
+ @PreAuthorize("@pms.hasPermission('hard_canteenProductApptTotal_export')")
+ @ResponseExcel(name = "菜品预约统计", inMemory = true, writeHandler = HardCanteenProductApptExportMergeHandler.class)
+ @PostMapping("/product-reserv-export")
+ public List productReservationExport(@RequestBody HardOrderDto query) {
+ applyCanteenMerchantScope(query);
+ return canteenApptTotalExportService.productReservationExport(query);
+ }
+
+ /**
+ * 为后台菜品预约导出补充当前操作者可见的食堂范围。
+ *
+ * 分页查询接口已有相同约束;导出任务在异步线程执行前必须冻结这份范围,避免未选择食堂时
+ * 直接按空 {@code mchIds} 查询到当前权限之外的商户数据。
+ *
+ * @param query 菜品预约导出的请求条件;为空时保留给导出服务做参数校验
+ */
+ private void applyCanteenMerchantScope(HardOrderDto query) {
+ if (query == null) {
+ return;
+ }
+ MchManageDto mchManageDto = new MchManageDto();
+ mchManageDto.setTypesList(List.of(MerchantTypeEnum.CANTEEN.getType()));
+ R> scopeResponse = remoteMchService.scopeList(mchManageDto);
+ List mchList = scopeResponse == null ? null : scopeResponse.getData();
+ List visibleMerchantIds = ObjectUtil.isEmpty(mchList) ? List.of()
+ : mchList.stream().map(MchManageVo::getId).filter(ObjectUtil::isNotEmpty).distinct().toList();
+ if (ObjectUtil.isEmpty(visibleMerchantIds)) {
+ query.setMchIds(List.of(NO_VISIBLE_CANTEEN_ID));
+ return;
+ }
+ if (ObjectUtil.isEmpty(query.getMchIds())) {
+ query.setMchIds(visibleMerchantIds);
+ return;
+ }
+ // 即使调用方手工携带 mchIds,也只能导出当前操作者实际可见的食堂。
+ List allowedMerchantIds = query.getMchIds().stream().filter(visibleMerchantIds::contains).toList();
+ query.setMchIds(ObjectUtil.isEmpty(allowedMerchantIds)
+ ? List.of(NO_VISIBLE_CANTEEN_ID) : allowedMerchantIds);
}
}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAutoRefundTaskMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAutoRefundTaskMapper.java
index 41ea25c2..911c3d71 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAutoRefundTaskMapper.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardAutoRefundTaskMapper.java
@@ -6,6 +6,9 @@ import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
import com.bsd.pjcloud.hard.api.dto.order.HardAutoRefundTaskPageDto;
import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity;
import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.time.LocalDateTime;
/**
* 自动退款任务 Mapper,任务执行通过主键行锁串行化多实例竞争。
@@ -39,6 +42,7 @@ public interface HardAutoRefundTaskMapper extends PjcloudBaseMapper pageExpiredCandidates(Page page,
+ @Param("dto") HardAutoRefundTaskPageDto dto,
+ @Param("now") LocalDateTime now);
}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardCarMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardCarMapper.java
index 40f4ac68..b7ec5a8b 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardCarMapper.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardCarMapper.java
@@ -12,6 +12,7 @@ import com.bsd.pjcloud.hard.api.dto.order.HardOrderDto;
import com.bsd.pjcloud.hard.api.entity.order.HardCarEntity;
import com.bsd.pjcloud.hard.api.vo.order.HardCarTotalVo;
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.app.HardAppProductByCanteenVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductByClassifyVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppProductLikeTopVo;
@@ -37,6 +38,15 @@ public interface HardCarMapper extends PjcloudBaseMapper {
Page productTotalByApptUseDate(Page page, @Param("dto") HardOrderDto dto, DataScope dataScope);
+ /** 按预约日期和餐段查询导出所需的日期餐段总计。 */
+ List listApptExportSummary(@Param("dto") HardOrderDto dto);
+
+ /** 按预约日期、餐段、商户和部门查询导出商户部门统计。 */
+ List listApptExportMerchant(@Param("dto") HardOrderDto dto);
+
+ /** 按预约日期、餐段、商户、部门和商品查询导出商品明细。 */
+ List listApptExportProduct(@Param("dto") HardOrderDto dto);
+
Page proudctQuery(Page page, @Param("dto") HardCarDto dto);
/**
@@ -48,6 +58,22 @@ public interface HardCarMapper extends PjcloudBaseMapper {
List listByHourType(@Param("dto") HardOrderDto dto);
+ /**
+ * 按预约订单父购物车主键批量读取商品快照,供预约统计计算真实菜品份数。
+ *
+ * @param parentCarIds 预约订单关联的父购物车主键集合
+ * @return 未删除的购物车商品快照;传入空集合时返回空列表
+ */
+ default List listByParentCarIds(List parentCarIds) {
+ if (parentCarIds == null || parentCarIds.isEmpty()) {
+ return List.of();
+ }
+ return selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper()
+ .in(HardCarEntity::getParentCarId, parentCarIds)
+ .orderByAsc(HardCarEntity::getParentCarId)
+ .orderByAsc(HardCarEntity::getId));
+ }
+
/**
* 菜品销量按照分类查询
*/
@@ -62,4 +88,4 @@ public interface HardCarMapper extends PjcloudBaseMapper {
* 个人菜品喜好排行
*/
List productLikeTop(@Param("dto") HardAppProductLikeTopDto dto);
-}
\ No newline at end of file
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleService.java
index 50431a38..90a46764 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleService.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAfterSaleService.java
@@ -72,6 +72,33 @@ public interface HardAfterSaleService extends IService {
*/
void completeAutoRefund(Long afterSaleId, Long autoRefundConfigId);
+ /**
+ * 按过期预约任务创建并完成每日菜谱整单系统退款;任务快照决定是否恢复库存。
+ *
+ * @param task 过期预约任务,afterSaleId 为空时由本方法创建系统售后
+ */
+ Long completeExpiredReserveRefund(com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity task);
+
+ /**
+ * 完成过期预约整单退款,并允许调用方声明是否需要校验过期自动退款开关。
+ * 管理端逐单业务退款即使商户关闭自动开关也必须可用,因此该路径不执行自动开关校验;
+ * 调度器和自动重试路径必须传入 {@code true},继续由 Wallet 做最终开关校验。
+ *
+ * @param task 过期预约退款任务,afterSaleId 为空时由本方法创建系统售后
+ * @param enforceAutoSwitch 是否按自动任务场景校验当前过期退款开关
+ * @return 完成或复用的系统售后单 ID
+ */
+ Long completeExpiredReserveRefund(com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity task,
+ boolean enforceAutoSwitch);
+
+ /**
+ * 在资金退款前独立提交过期预约系统售后单及商品快照,确保跨服务退款成功后本地回滚仍可恢复。
+ *
+ * @param task 过期预约任务,必须尚未关联售后单
+ * @return 新建或已存在的系统售后单 ID
+ */
+ Long prepareExpiredReserveRefund(com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity task);
+
/**
* 同意售后
*/
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptReservationService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptReservationService.java
index 4d5f3bbc..fa8a34ee 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptReservationService.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptReservationService.java
@@ -39,8 +39,9 @@ public interface HardApptReservationService {
* @param apptDate 实际用餐日期
* @param apptRuleId 周期预约规则 ID;动态模式为空
* @param eligibilityDeadline 当前已校验预约窗口的截止时间,使用服务器现有预约时间口径
+ * @param usageEndDateTime 实际用餐餐段的结束时间;无法从有效餐段唯一解析时为空,禁止用于过期退款
*/
record ReservationValidation(HardApptEntity appt, String hourType, LocalDate apptDate, Long apptRuleId,
- LocalDateTime eligibilityDeadline) {
+ LocalDateTime eligibilityDeadline, LocalDateTime usageEndDateTime) {
}
}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAutoRefundTaskService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAutoRefundTaskService.java
index c88ac2c1..212fb749 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAutoRefundTaskService.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardAutoRefundTaskService.java
@@ -27,6 +27,14 @@ public interface HardAutoRefundTaskService extends IService pageTasks(Page page, HardAutoRefundTaskPageDto dto);
+ /** 查询已到期且仍可整单退款的每日菜谱预约候选任务。 */
+ Page pageExpiredCandidates(Page page, HardAutoRefundTaskPageDto dto);
+
/**
* 查询任务的只读尝试日志,按尝试序号升序返回。
*
@@ -68,6 +79,14 @@ public interface HardAutoRefundTaskService extends IService userReservationDetail(Long userId, LocalDate apptUseDate);
/**
- * 提交用户每日预约异步导出任务,后台按权限用户分批查询并流式写入 Excel。
+ * 按当前用户预约查询条件统计每日和查询范围内的预约人数、餐段及菜品。
*
- * @param query 用户、日期范围和预约状态筛选条件
- * @return 供前端轮询的初始任务进度
+ * @param query 用户、日期范围、食堂、餐段和状态筛选条件
+ * @return 按日和总计组织的预约统计结果
*/
- AsyncTaskProgressVO submitUserReservationExport(HardCanteenUserApptQueryDto query);
+ HardCanteenUserApptStatisticsVo userReservationStatistics(HardCanteenUserApptQueryDto query);
- /**
- * 查询并转换用户预约导出数据,包含没有预约订单的权限范围用户。
- *
- * @param query 用户、日期范围和预约状态筛选条件
- * @return 用户和日期维度的 Excel 导出行,最后一行为餐段及当日金额合计
- */
- List userReservationExport(HardCanteenUserApptQueryDto query);
}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java
index 473680f2..19026a2b 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java
@@ -268,6 +268,16 @@ public interface HardOrderService extends IService {
void fastenerInventory(HardOrderEntity order, List refundCarList, YesNoEnum isReduce);
+ /**
+ * 按售后单隔离库存幂等业务号执行扣减或恢复;每日菜谱退款必须传售后单 ID,避免多次部分退款互相覆盖。
+ *
+ * @param order 原订单
+ * @param refundCarList 本次退款商品;为空表示整单商品
+ * @param isReduce {@link YesNoEnum#YES} 扣库存,{@link YesNoEnum#NO} 恢复库存
+ * @param afterSaleId 本次售后单 ID,非退款调用可为空
+ */
+ void fastenerInventory(HardOrderEntity order, List refundCarList, YesNoEnum isReduce, Long afterSaleId);
+
/**
* 核销硬件二维码
*/
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImpl.java
index 3c1e3be6..43a8c4ba 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImpl.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImpl.java
@@ -357,7 +357,8 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic
if (!availability.available()) {
throw new RuntimeException(availability.unavailableReason());
}
- return new ReservationValidation(appt, appt.getHourType(), targetDate, null, availability.endDateTime());
+ return new ReservationValidation(appt, appt.getHourType(), targetDate, null, availability.endDateTime(),
+ availability.endDateTime());
}
private ReservationValidation validateCycleReservation(Long mchId, Long apptRuleId, String hourType,
@@ -377,7 +378,34 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic
if (!match.availability().available()) {
throw new RuntimeException(match.availability().unavailableReason());
}
- return new ReservationValidation(null, hourType, apptDate, selected.getId(), match.availability().endDateTime());
+ return new ReservationValidation(null, hourType, apptDate, selected.getId(), match.availability().endDateTime(),
+ resolveCycleUsageEndDateTime(mchId, hourType, apptDate));
+ }
+
+ /**
+ * 周期预约的开放窗口结束时间不是餐段结束时间;这里按已启用的实际 hard_appt 餐段解析,
+ * 无法唯一解析时返回空值,调用方会放弃过期退款快照以避免误退。
+ *
+ * @param mchId 订单所属商户 ID
+ * @param hourType 订单选中的标准餐段编码
+ * @param apptDate 实际用餐日期
+ * @return 匹配餐段的实际结束时间;缺失、重复或时间配置异常时返回 {@code null}
+ */
+ private LocalDateTime resolveCycleUsageEndDateTime(Long mchId, String hourType, LocalDate apptDate) {
+ List candidates = hardApptService.list(new LambdaQueryWrapper()
+ .eq(HardApptEntity::getMchId, mchId)
+ .eq(HardApptEntity::getHourType, hourType)
+ .eq(HardApptEntity::getStatus, YesNoEnum.YES.getCode())
+ .orderByAsc(HardApptEntity::getId));
+ if (candidates.size() != 1) {
+ return null;
+ }
+ HardApptEntity appt = candidates.get(0);
+ HardApptTimeTypeEnum endType = HardApptTimeTypeEnum.valueOfByType(appt.getEndTimeType());
+ if (endType == null || appt.getEndTime() == null) {
+ return null;
+ }
+ return apptDate.plusDays(endType.getDay()).atTime(appt.getEndTime());
}
private CycleMatch matchCycleRule(HardApptCycleRuleVo rule, String hourType,
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/hard/HardThirdSelfZyServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/hard/HardThirdSelfZyServiceImpl.java
index 1b9958b1..ed069be6 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/hard/HardThirdSelfZyServiceImpl.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/hard/HardThirdSelfZyServiceImpl.java
@@ -164,9 +164,8 @@ public class HardThirdSelfZyServiceImpl implements HardThirdSelfZyService {
.content(HardSaleStatusEnum.COMPLETE.getContent())
.build();
hardAfterSaleLogMapper.insert(saveLogObj);
- // 执行退款
+ // 正元订单不属于每日菜谱,普通库存接口没有跨服务补偿能力,保持先退款后恢复库存。
hardAfterSaleService.refund(saveObj.getId(), "zy");
- // 退库存
hardOrderService.fastenerInventory(order, YesNoEnum.NO);
HardCallbackRefundVo result = new HardCallbackRefundVo();
result.setCode(HardCallbackRefundStatus.SUCCESS);
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java
index bf69eb2a..3d8c2bd7 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardAfterSaleServiceImpl.java
@@ -31,6 +31,7 @@ import com.bsd.pjcloud.hard.api.constant.refund.HardAfterSaleOperateTypeEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardRefundStatusEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardSaleStatusEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardSaleTypeEnum;
+import com.bsd.pjcloud.hard.api.constant.refund.HardAfterSaleWayEnum;
import com.bsd.pjcloud.hard.api.dto.order.*;
import com.bsd.pjcloud.hard.api.dto.order.app.HardAppRefundApplyDto;
import com.bsd.pjcloud.hard.api.dto.order.app.HardAppRefundItemDto;
@@ -519,6 +520,20 @@ public class HardAfterSaleServiceImpl extends ServiceImpl walletRefundResult = walletRechargeService.refund(hardRefundDto);
if (walletRefundResult == null || !walletRefundResult.isOk() || ObjectUtil.isEmpty(walletRefundResult.getData())) {
throw new RuntimeException("钱包退款错误");
@@ -639,7 +656,14 @@ public class HardAfterSaleServiceImpl extends ServiceImpl refundCars = hardAfterSaleCarService.getByRefundId(afterSaleId);
- if (ObjectUtil.isEmpty(refundCars)) {
- hardOrderService.fastenerInventory(order, YesNoEnum.NO);
- } else {
- hardOrderService.fastenerInventory(order, toInventoryCars(refundCars), YesNoEnum.NO);
- }
recomputeOrderRefundState(order.getId());
}
+ /**
+ * 过期预约任务的系统整单退款。订单和任务已由任务状态机串行保护,这里仍锁订单行并重新核对可退条件,
+ * 防止核销、人工退款或重复调度在任务到期前改变订单状态。退库存只读取任务快照,不读取当前商户配置。
+ *
+ * @param task 过期预约任务,首次执行时售后单 ID 为空
+ * @return 创建或复用的系统售后单 ID
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public Long completeExpiredReserveRefund(HardAutoRefundTaskEntity task) {
+ return completeExpiredReserveRefund(task, true);
+ }
+
+ /**
+ * 过期预约任务的系统整单退款实现。自动调度需要校验当前开关,管理端逐单业务退款不受商户关闭开关影响。
+ * 订单、售后和库存仍统一由订单锁与本地事务保护,避免人工操作绕过支付、核销和重复退款校验。
+ *
+ * @param task 过期预约任务,首次执行时售后单 ID 为空
+ * @param enforceAutoSwitch 自动调度/重试为 {@code true},管理端逐单退款为 {@code false}
+ * @return 创建或复用的系统售后单 ID
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public Long completeExpiredReserveRefund(HardAutoRefundTaskEntity task, boolean enforceAutoSwitch) {
+ if (task == null || task.getOrderId() == null) {
+ throw new RuntimeException("过期预约任务数据异常");
+ }
+ if (task.getTriggerTime() == null || task.getTriggerTime().isAfter(LocalDateTime.now())) {
+ throw new RuntimeException("过期预约退款尚未到达触发时间");
+ }
+ HardOrderEntity order = hardOrderMapper.selectByIdForUpdate(task.getOrderId());
+ if (order == null) {
+ throw new RuntimeException("订单不存在");
+ }
+ if (!isDailyMenuOrder(order) || !Objects.equals(order.getOrderType(), HardOrderTypeEnum.RESERVE.getType())
+ || !Objects.equals(order.getOrderStatus(), HardOrderStatusEnum.PAY.getType())
+ || !Objects.equals(order.getPayType(), HardPayTypeEnum.WALLET_DEFAULT.getType())) {
+ throw new RuntimeException("订单不满足过期预约退款条件");
+ }
+ List afterSales = listOrderAfterSales(order.getId());
+ if (afterSales.stream().anyMatch(item -> !Objects.equals(item.getId(), task.getAfterSaleId())
+ && Objects.equals(item.getStatus(), HardSaleStatusEnum.APPLY.getStatus()))) {
+ throw new RuntimeException("订单已有处理中售后");
+ }
+ int completedRefundPrice = sumRefundPrice(afterSales, HardSaleStatusEnum.COMPLETE.getStatus());
+ if (completedRefundPrice > 0 || safePrice(order.getPayPrice()) <= 0) {
+ throw new RuntimeException("订单已退款或金额异常");
+ }
+ if (task.getAfterSaleId() == null) {
+ throw new RuntimeException("过期预约退款售后单尚未准备完成");
+ }
+ HardAfterSaleEntity afterSale = baseMapper.selectById(task.getAfterSaleId());
+ if (afterSale == null || !Objects.equals(afterSale.getStatus(), HardSaleStatusEnum.APPLY.getStatus())) {
+ throw new RuntimeException("过期预约退款售后单状态异常");
+ }
+ if (Objects.equals(task.getRestoreInventory(), YesNoEnum.YES.getCode())) {
+ restoreRefundInventory(order, afterSale.getId());
+ }
+ refundInCurrentTransaction(afterSale.getId(), "hard_expired",
+ enforceAutoSwitch ? task.getConfigId() : null,
+ enforceAutoSwitch ? WalletRechargeRefundDto.AUTO_REFUND_TASK_TYPE_EXPIRED_RESERVE : null);
+ HardAfterSaleEntity completed = HardAfterSaleEntity.builder().id(afterSale.getId())
+ .status(HardSaleStatusEnum.COMPLETE.getStatus()).auditTime(LocalDateTime.now()).refundTime(LocalDateTime.now()).build();
+ if (baseMapper.updateById(completed) != 1) {
+ throw new RuntimeException("更新过期退款售后状态失败");
+ }
+ insertAfterSaleLog(afterSale.getId(), afterSale.getUserId(), HardAfterSaleOperateTypeEnum.SYSTEM_EXPIRED_RESERVE_REFUND.getType(),
+ HardSaleStatusEnum.APPLY.getStatus(), HardSaleStatusEnum.COMPLETE.getStatus(),
+ HardAfterSaleOperateTypeEnum.SYSTEM_EXPIRED_RESERVE_REFUND.getContent());
+ recomputeOrderRefundState(order.getId());
+ return afterSale.getId();
+ }
+
+ /**
+ * 在独立事务中创建过期预约系统售后单和商品快照;不调用钱包,提交后由任务状态机执行资金退款。
+ *
+ * @param task 过期预约任务,订单已由任务状态机按订单锁串行
+ * @return 新建系统售后单 ID
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public Long prepareExpiredReserveRefund(HardAutoRefundTaskEntity task) {
+ if (task == null || task.getOrderId() == null || task.getAfterSaleId() != null) {
+ throw new RuntimeException("过期预约退款任务数据异常");
+ }
+ if (task.getTriggerTime() == null || task.getTriggerTime().isAfter(LocalDateTime.now())) {
+ throw new RuntimeException("过期预约退款尚未到达触发时间");
+ }
+ HardOrderEntity order = hardOrderMapper.selectByIdForUpdate(task.getOrderId());
+ if (order == null) {
+ throw new RuntimeException("订单不存在");
+ }
+ if (!isDailyMenuOrder(order) || !Objects.equals(order.getOrderType(), HardOrderTypeEnum.RESERVE.getType())
+ || !Objects.equals(order.getOrderStatus(), HardOrderStatusEnum.PAY.getType())
+ || !Objects.equals(order.getPayType(), HardPayTypeEnum.WALLET_DEFAULT.getType())) {
+ throw new RuntimeException("订单不满足过期预约退款条件");
+ }
+ List afterSales = listOrderAfterSales(order.getId());
+ if (afterSales.stream().anyMatch(item -> Objects.equals(item.getStatus(), HardSaleStatusEnum.APPLY.getStatus()))) {
+ throw new RuntimeException("订单已有处理中售后");
+ }
+ if (sumRefundPrice(afterSales, HardSaleStatusEnum.COMPLETE.getStatus()) > 0 || safePrice(order.getPayPrice()) <= 0) {
+ throw new RuntimeException("订单已退款或金额异常");
+ }
+ HardAfterSaleEntity afterSale = HardAfterSaleEntity.builder()
+ .status(HardSaleStatusEnum.APPLY.getStatus())
+ .no("hard_expired_" + order.getId())
+ .orderId(order.getId())
+ .orderNo(String.valueOf(order.getId()))
+ .userId(order.getUserId())
+ .way(HardAfterSaleWayEnum.REFUND.getType())
+ .type(HardSaleTypeEnum.IN_SALE.getType())
+ .refundPrice(safePrice(order.getPayPrice()))
+ .applyReason("预约订单过期自动退款")
+ .applyDescription("系统按预约餐段结束时间自动退款")
+ .build();
+ if (baseMapper.insert(afterSale) != 1) {
+ throw new RuntimeException("创建过期预约退款售后单失败");
+ }
+ hardAfterSaleCarService.create(buildRemainingRefundCars(order, getOriginalCars(order)), order, afterSale);
+ insertAfterSaleLog(afterSale.getId(), order.getUserId(), HardAfterSaleOperateTypeEnum.SYSTEM_EXPIRED_RESERVE_REFUND.getType(),
+ order.getOrderStatus(), HardSaleStatusEnum.APPLY.getStatus(), HardAfterSaleOperateTypeEnum.SYSTEM_EXPIRED_RESERVE_REFUND.getContent());
+ return afterSale.getId();
+ }
+
@Override
public Boolean agreeAfterSale(Long userId, Long id) {
HardAfterSaleEntity current = baseMapper.selectById(id);
@@ -693,13 +836,13 @@ public class HardAfterSaleServiceImpl extends ServiceImpl refundCars = hardAfterSaleCarService.getByRefundId(id);
- if (ObjectUtil.isEmpty(refundCars)) {
- hardOrderService.fastenerInventory(order, YesNoEnum.NO);
- } else {
- hardOrderService.fastenerInventory(order, toInventoryCars(refundCars), YesNoEnum.NO);
+ if (!isDailyMenuOrder(order)) {
+ restoreOrderInventory(order, id);
}
recomputeOrderRefundState(order.getId());
return true;
@@ -779,33 +922,90 @@ public class HardAfterSaleServiceImpl extends ServiceImpl refundSuccessCarList = hardAfterSaleCarService.getOccupiedByCarParentId(order.getParentCarId());
+ Map requestedCountByProductId = new LinkedHashMap<>();
for (HardAfterSaleCarEntity refundCar : refundCarList) {
- HardCarEntity orderCar = orderCarList.stream()
- .filter(v -> ObjectUtil.equal(v.getProductId(), refundCar.getProductId())
- || ObjectUtil.equal(v.getId(), refundCar.getId()))
- .findFirst().orElse(null);
+ if (refundCar == null || (refundCar.getProductId() == null && refundCar.getId() == null)) {
+ throw new RuntimeException("退款商品不能为空");
+ }
+ HardCarEntity orderCar = resolveRefundOrderCar(orderCarList, refundCar);
if (orderCar == null) throw new RuntimeException("退款商品<" + refundCar.getProductName() + ">不存在");
- int refundCount = 0;
- if (ObjectUtil.isNotEmpty(refundCar.getProductCount())) {
- refundCount = refundCar.getProductCount();
+ // 后续金额、售后快照和库存恢复都以服务端商品 ID 为准,兼容后台历史提交的购物车行 ID。
+ refundCar.setProductId(orderCar.getProductId());
+ // 按数量商品必须提交有效份数;称重商品历史请求可能只提交重量,数量按零参与数量校验。
+ if (HardProductNatureEnum.DEFAULT.getType().equals(orderCar.getProductNature())
+ && refundCar.getProductCount() == null) {
+ throw new RuntimeException("退款商品<" + orderCar.getProductName() + ">数量不能为空");
}
- // 已经退货成功的商品列表
- if (ObjectUtil.isNotEmpty(refundSuccessCarList)) {
- // 售后明细 ID 是退款记录自身 ID,不能与原购物车 ID 比较;使用服务端订单商品 ID 防止重复超额退款。
- HardAfterSaleCarEntity refundSuccessCar = refundSuccessCarList.stream()
- .filter(v -> ObjectUtil.equal(v.getProductId(), orderCar.getProductId()))
- .findFirst()
- .orElse(null);
- if (ObjectUtil.isNotEmpty(refundSuccessCar)) {
- refundCount += refundSuccessCar.getProductCount();
- }
+ if (refundCar.getProductCount() != null && refundCar.getProductCount() < 0) {
+ throw new RuntimeException("退款商品<" + orderCar.getProductName() + ">数量不能为负数");
}
- int originalCount = orderCarList.stream().filter(item -> ObjectUtil.equal(item.getProductId(), orderCar.getProductId()))
- .map(HardCarEntity::getProductCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum();
- if (originalCount < refundCount) {
- throw new RuntimeException("退款商品<" + refundCar.getProductName() + ">数量超出");
+ int requestCount = refundCar.getProductCount() == null ? 0 : refundCar.getProductCount();
+ try {
+ requestedCountByProductId.merge(orderCar.getProductId(), requestCount, Math::addExact);
+ } catch (ArithmeticException exception) {
+ throw new RuntimeException("退款商品<" + orderCar.getProductName() + ">数量异常");
}
}
+ Map occupiedCountByProductId = refundSuccessCarList.stream()
+ .filter(item -> item != null && item.getProductId() != null)
+ .collect(Collectors.toMap(HardAfterSaleCarEntity::getProductId,
+ item -> item.getProductCount() == null ? 0 : item.getProductCount(),
+ (left, right) -> {
+ try {
+ return Math.addExact(left, right);
+ } catch (ArithmeticException exception) {
+ throw new RuntimeException("历史退款商品数量异常");
+ }
+ }, LinkedHashMap::new));
+ Map originalCountByProductId = orderCarList.stream()
+ .filter(item -> item.getProductId() != null)
+ .collect(Collectors.toMap(HardCarEntity::getProductId,
+ item -> item.getProductCount() == null ? 0 : item.getProductCount(),
+ (left, right) -> {
+ try {
+ return Math.addExact(left, right);
+ } catch (ArithmeticException exception) {
+ throw new RuntimeException("订单商品数量异常");
+ }
+ }, LinkedHashMap::new));
+ requestedCountByProductId.forEach((productId, requestedCount) -> {
+ int occupiedCount = occupiedCountByProductId.getOrDefault(productId, 0);
+ int originalCount = originalCountByProductId.getOrDefault(productId, 0);
+ final int totalRefundCount;
+ try {
+ totalRefundCount = Math.addExact(occupiedCount, requestedCount);
+ } catch (ArithmeticException exception) {
+ throw new RuntimeException("退款商品数量异常");
+ }
+ if (totalRefundCount > originalCount) {
+ HardCarEntity orderCar = orderCarList.stream()
+ .filter(item -> ObjectUtil.equal(item.getProductId(), productId))
+ .findFirst().orElse(null);
+ throw new RuntimeException("退款商品<" + (orderCar == null ? productId : orderCar.getProductName()) + ">数量超出");
+ }
+ });
+ }
+
+ /**
+ * 将退款明细解析到原订单购物车行,优先使用商品 ID,兼容后台历史提交的购物车行 ID。
+ *
+ * @param orderCarList 原订单购物车快照
+ * @param refundCar 客户端提交或服务端组装的退款明细
+ * @return 匹配的原订单购物车行;无法匹配时返回 {@code null}
+ */
+ private HardCarEntity resolveRefundOrderCar(List orderCarList, HardAfterSaleCarEntity refundCar) {
+ if (refundCar == null) {
+ return null;
+ }
+ HardCarEntity byProductId = orderCarList.stream()
+ .filter(item -> ObjectUtil.equal(item.getProductId(), refundCar.getProductId()))
+ .findFirst().orElse(null);
+ if (byProductId != null) {
+ return byProductId;
+ }
+ return orderCarList.stream()
+ .filter(item -> ObjectUtil.equal(item.getId(), refundCar.getId()))
+ .findFirst().orElse(null);
}
/**
@@ -1445,13 +1645,22 @@ public class HardAfterSaleServiceImpl extends ServiceImpl refundCars = isDailyMenuOrder(order)
+ ? buildDailyMenuAdminRefundCars(order, dto.getCarList()) : dto.getCarList();
+ hardAfterSaleCarService.createAdmin(refundCars, order, saveObj);
}
// 添加售后进度
HardAfterSaleLogEntity saveLogObj = HardAfterSaleLogEntity.builder()
@@ -1551,8 +1762,13 @@ public class HardAfterSaleServiceImpl extends ServiceImpl carList = dto.getCarList().stream().map((item) ->
HardCarEntity.builder()
.productId(item.getProductId())
@@ -1561,10 +1777,12 @@ public class HardAfterSaleServiceImpl extends ServiceImpl售后商品快照是服务端核定的可信数量:存在快照时仅恢复本次部分退款商品,
+ * 快照为空时兼容整单退款并恢复父购物车全部商品。库存服务使用售后单 ID 做幂等隔离,
+ * 使重复回调不会重复增加库存。
+ *
+ * @param order 原每日菜谱预约订单
+ * @param afterSaleId 本次售后单 ID,不能为空
+ */
+ private void restoreRefundInventory(HardOrderEntity order, Long afterSaleId) {
+ if (afterSaleId == null) {
+ throw new RuntimeException("每日菜谱退款库存缺少售后单 ID");
+ }
+ List refundCars = hardAfterSaleCarService.getByRefundId(afterSaleId);
+ if (ObjectUtil.isEmpty(refundCars)) {
+ hardOrderService.fastenerInventory(order, null, YesNoEnum.NO, afterSaleId);
+ return;
+ }
+ hardOrderService.fastenerInventory(order, toInventoryCars(refundCars), YesNoEnum.NO, afterSaleId);
+ }
+
+ /**
+ * 恢复普通商品库存,保持旧库存服务的调用协议和退款后执行顺序。
+ *
+ * @param order 原订单
+ * @param afterSaleId 售后单 ID,仅用于读取部分退款商品快照
+ */
+ private void restoreOrderInventory(HardOrderEntity order, Long afterSaleId) {
+ List refundCars = hardAfterSaleCarService.getByRefundId(afterSaleId);
+ if (ObjectUtil.isEmpty(refundCars)) {
+ hardOrderService.fastenerInventory(order, YesNoEnum.NO);
+ } else {
+ hardOrderService.fastenerInventory(order, toInventoryCars(refundCars), YesNoEnum.NO);
+ }
+ }
+
/**
* 使用下单时保存的购物车成交价快照计算每日菜谱退款金额,退款时不再依赖可能已下架或改价的商品主数据。
* 请求仅提供退款数量,商品和价格均以服务端订单购物车为准。
@@ -1599,26 +1854,116 @@ public class HardAfterSaleServiceImpl extends ServiceImpl refundCountByProductId = new LinkedHashMap<>();
try {
for (HardAfterSaleCarEntity refundCar : refundCarList) {
+ HardCarEntity orderCar = resolveRefundOrderCar(orderCarList, refundCar);
+ if (orderCar == null || orderCar.getProductId() == null) {
+ throw new RuntimeException("退款商品不存在");
+ }
int refundCount = refundCar.getProductCount() == null ? 0 : refundCar.getProductCount();
- if (refundCount <= 0) {
+ if (refundCount < 0) {
+ throw new RuntimeException("退款商品数量不能为负数");
+ }
+ if (refundCount == 0) {
continue;
}
- HardCarEntity orderCar = orderCarList.stream()
- .filter(item -> ObjectUtil.equal(item.getId(), refundCar.getId()))
- .findFirst()
- .orElseThrow(() -> new RuntimeException("退款商品不存在"));
- if (orderCar.getProductPrice() == null || orderCar.getProductPrice() < 0) {
- throw new RuntimeException("退款商品价格异常");
- }
- refundPrice = Math.addExact(refundPrice, Math.multiplyExact(orderCar.getProductPrice(), refundCount));
+ refundCountByProductId.merge(orderCar.getProductId(), refundCount, Math::addExact);
}
+
+ int refundPrice = 0;
+ for (Map.Entry entry : refundCountByProductId.entrySet()) {
+ int remainingCount = entry.getValue();
+ List productCars = orderCarList.stream()
+ .filter(item -> ObjectUtil.equal(item.getProductId(), entry.getKey()))
+ .toList();
+ for (HardCarEntity orderCar : productCars) {
+ if (remainingCount <= 0) {
+ break;
+ }
+ int originalCount = orderCar.getProductCount() == null ? 0 : orderCar.getProductCount();
+ int currentCount = Math.min(remainingCount, Math.max(originalCount, 0));
+ if (currentCount <= 0) {
+ continue;
+ }
+ if (orderCar.getProductPrice() == null || orderCar.getProductPrice() < 0) {
+ throw new RuntimeException("退款商品价格异常");
+ }
+ refundPrice = Math.addExact(refundPrice,
+ Math.multiplyExact(orderCar.getProductPrice(), currentCount));
+ remainingCount -= currentCount;
+ }
+ if (remainingCount > 0) {
+ throw new RuntimeException("退款商品数量超出订单数量");
+ }
+ }
+ return refundPrice;
} catch (ArithmeticException exception) {
throw new RuntimeException("退款金额超出计算范围", exception);
}
- return refundPrice;
+ }
+
+ /**
+ * 按原订单购物车生成后台每日菜谱退款快照,丢弃客户端携带的名称、价格和总价,避免后续整单退款读取不可信金额。
+ * 同一商品多行时先聚合数量,库存恢复和历史退款占用保持同一商品维度。
+ *
+ * @param order 每日菜谱预约订单
+ * @param refundCarList 后台提交的退款商品及数量
+ * @return 使用原订单商品资料和成交价计算的可信售后明细
+ */
+ private List buildDailyMenuAdminRefundCars(HardOrderEntity order,
+ List refundCarList) {
+ List orderCarList = hardCarService.getByParentCarId(order.getParentCarId());
+ if (ObjectUtil.isEmpty(orderCarList)) {
+ throw new RuntimeException("订单购物车不存在");
+ }
+ Map orderCarMap = orderCarList.stream()
+ .filter(item -> item.getProductId() != null)
+ .collect(Collectors.toMap(HardCarEntity::getProductId, item -> item, (left, right) -> left,
+ LinkedHashMap::new));
+ Map refundCountByProductId = new LinkedHashMap<>();
+ for (HardAfterSaleCarEntity refundCar : refundCarList) {
+ HardCarEntity orderCar = resolveRefundOrderCar(orderCarList, refundCar);
+ int refundCount = refundCar == null || refundCar.getProductCount() == null ? 0 : refundCar.getProductCount();
+ if (orderCar == null || orderCar.getProductId() == null) {
+ throw new RuntimeException("退款商品不存在");
+ }
+ if (refundCount < 0) {
+ throw new RuntimeException("退款商品数量不能为负数");
+ }
+ if (refundCount == 0) {
+ continue;
+ }
+ try {
+ refundCountByProductId.merge(orderCar.getProductId(), refundCount, Math::addExact);
+ } catch (ArithmeticException exception) {
+ throw new RuntimeException("退款商品数量异常", exception);
+ }
+ }
+ List result = new ArrayList<>(refundCountByProductId.size());
+ for (Map.Entry entry : refundCountByProductId.entrySet()) {
+ HardCarEntity orderCar = orderCarMap.get(entry.getKey());
+ if (orderCar == null || orderCar.getProductPrice() == null || orderCar.getProductPrice() < 0) {
+ throw new RuntimeException("退款商品价格异常");
+ }
+ long totalPrice;
+ try {
+ totalPrice = Math.multiplyExact((long) orderCar.getProductPrice(), entry.getValue());
+ } catch (ArithmeticException exception) {
+ throw new RuntimeException("退款金额超出计算范围", exception);
+ }
+ HardAfterSaleCarEntity trustedCar = new HardAfterSaleCarEntity();
+ trustedCar.setProductId(orderCar.getProductId());
+ trustedCar.setProductName(orderCar.getProductName());
+ trustedCar.setProductPic(orderCar.getProductPic());
+ trustedCar.setProductPrice(orderCar.getProductPrice().longValue());
+ trustedCar.setProductNature(orderCar.getProductNature());
+ trustedCar.setProductCount(entry.getValue());
+ trustedCar.setProductWeight(orderCar.getProductWeight());
+ trustedCar.setTotalPrice(totalPrice);
+ result.add(trustedCar);
+ }
+ return result;
}
/**
@@ -1719,10 +2064,16 @@ public class HardAfterSaleServiceImpl extends ServiceImpl()
+ .eq(HardAutoRefundTaskEntity::getOrderId, order.getId())
+ .eq(HardAutoRefundTaskEntity::getTaskType, HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType()));
+ if (existing != null) {
+ return existing;
+ }
+ HardAutoRefundTaskEntity task = HardAutoRefundTaskEntity.builder()
+ .taskType(HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())
+ .orderId(order.getId()).mchId(order.getMerchantId())
+ .configId(order.getExpiredRefundConfigId())
+ .triggerTime(order.getExpiredRefundDeadline())
+ .restoreInventory(order.getExpiredRefundRestoreInventory())
+ .status(HardAutoRefundTaskStatusEnum.PENDING.getStatus())
+ .autoAttemptCount(0).totalAttemptCount(0)
+ .nextRetryTime(order.getExpiredRefundDeadline())
+ .build();
+ if (baseMapper.insert(task) != 1) {
+ throw new RuntimeException("创建过期预约退款任务失败");
+ }
+ return task;
+ }
+
/**
* 事务提交成功后在当前请求线程同步执行首次退款,执行失败只更新任务,不改变创建接口返回值。
*
@@ -240,6 +284,17 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl= MAX_AUTO_ATTEMPTS) {
+ if (List.of(HardAutoRefundTriggerTypeEnum.MANUAL, HardAutoRefundTriggerTypeEnum.EXPIRED_MANUAL).contains(triggerType)
+ || autoAttempts >= MAX_AUTO_ATTEMPTS) {
task.setStatus(HardAutoRefundTaskStatusEnum.RETRY_EXHAUSTED.getStatus());
task.setNextRetryTime(null);
} else {
@@ -595,12 +729,22 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl pageExpiredCandidates(Page page,
+ HardAutoRefundTaskPageDto dto) {
+ LocalDateTime now = LocalDateTime.now();
+ Page entityPage = baseMapper.pageExpiredCandidates(page, dto, now);
+ List records = BeanUtils.toBean(entityPage.getRecords(), HardAutoRefundTaskVo.class);
+ fillAfterSaleData(records);
+ fillOrderData(records);
+ fillUserData(records);
+ fillMerchantData(records);
+ Page result = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal());
+ result.setRecords(records);
+ return result;
+ }
+
+ /** 批量补充过期候选订单日期和餐段,避免把配置变更后的规则重新解析为退款时间。 */
+ private void fillOrderData(List records) {
+ List orderIds = records.stream().map(HardAutoRefundTaskVo::getOrderId).filter(Objects::nonNull).distinct().toList();
+ if (orderIds.isEmpty()) return;
+ Map orders = hardOrderMapper.selectBatchIds(orderIds).stream()
+ .collect(Collectors.toMap(HardOrderEntity::getId, Function.identity(), (first, second) -> first));
+ records.forEach(item -> {
+ HardOrderEntity order = orders.get(item.getOrderId());
+ if (order != null) {
+ item.setUserId(order.getUserId());
+ item.setRefundPrice(order.getPayPrice());
+ item.setApptUseDate(order.getApptUseDate());
+ item.setHourType(order.getHourType());
+ }
+ });
+ }
+
/**
* 按售后唯一键查询任务。
*
@@ -870,9 +1081,19 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl()
.and(wrapper -> wrapper.eq(HardAutoRefundTaskEntity::getStatus,
HardAutoRefundTaskStatusEnum.PENDING.getStatus())
+ .and(nested -> nested.ne(HardAutoRefundTaskEntity::getTaskType,
+ HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())
+ .or(expired -> expired.eq(HardAutoRefundTaskEntity::getTaskType,
+ HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())
+ .le(HardAutoRefundTaskEntity::getTriggerTime, now)))
.or(nested -> nested.eq(HardAutoRefundTaskEntity::getStatus,
HardAutoRefundTaskStatusEnum.WAITING_RETRY.getStatus())
- .le(HardAutoRefundTaskEntity::getNextRetryTime, now)))
+ .le(HardAutoRefundTaskEntity::getNextRetryTime, now)
+ .and(expired -> expired.ne(HardAutoRefundTaskEntity::getTaskType,
+ HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())
+ .or(expiredType -> expiredType.eq(HardAutoRefundTaskEntity::getTaskType,
+ HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())
+ .le(HardAutoRefundTaskEntity::getTriggerTime, now)))))
.orderByAsc(HardAutoRefundTaskEntity::getNextRetryTime)
.orderByAsc(HardAutoRefundTaskEntity::getId)
.last("limit " + limit)));
@@ -899,6 +1120,9 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl菜品导出以预约使用日期为批次边界,每完成一个日期就推进一次 Redis 进度;Excel 数据始终通过
+ * Easy Excel 追加写入,避免把整个日期范围的商品明细一次性保留在内存中。
+ */
+@Slf4j
+@Validated
+@Service
+@RequiredArgsConstructor
+public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTotalExportService {
+
+ /** 用户预约导出任务的 Redis Key 命名空间。 */
+ private static final String USER_RESERVATION_EXPORT_REDIS_KEY = "hard:canteen-user-appt:export";
+
+ /** 用户预约导出任务名称。 */
+ private static final String USER_RESERVATION_EXPORT_TASK_NAME = "用户预约查询导出";
+
+ /** 用户预约导出创建时的占位总数。 */
+ private static final int USER_RESERVATION_EXPORT_INITIAL_TOTAL = 1;
+
+ /** 用户预约导出每批读取的权限用户数。 */
+ private static final int USER_RESERVATION_EXPORT_BATCH_SIZE = 200;
+
+ /** 文件上传和任务收尾占用的额外进度步数。 */
+ private static final int USER_RESERVATION_EXPORT_EXTRA_PROGRESS_STEPS = 2;
+
+ /** 用户预约导出表头及合计行占用的固定行数。 */
+ private static final int USER_RESERVATION_EXPORT_HEADER_ROW_COUNT = 2;
+ private static final int USER_RESERVATION_EXPORT_SUMMARY_ROW_COUNT = 1;
+
+ /** 用户预约导出工作表允许写入的最大明细行数。 */
+ private static final int MAX_USER_RESERVATION_EXPORT_ROWS =
+ BatchExcelExportTemplate.calculateMaxDetailRowCount(USER_RESERVATION_EXPORT_HEADER_ROW_COUNT,
+ USER_RESERVATION_EXPORT_SUMMARY_ROW_COUNT);
+
+ /** 用户预约导出工作表名称。 */
+ private static final String USER_RESERVATION_EXPORT_SHEET_NAME = "用户预约查询";
+
+ /** 用户预约导出文件目录。 */
+ private static final String USER_RESERVATION_EXPORT_FILE_DIR = "hard-export";
+
+ /** 用户预约导出文件类型编码。 */
+ private static final String USER_RESERVATION_EXPORT_FILE_TYPE = "HARD_CANTEEN_USER_APPT_EXPORT";
+
+ /** 用户预约导出文件 MIME 类型。 */
+ private static final String USER_EXPORT_EXCEL_CONTENT_TYPE =
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
+
+ /** 用户预约导出文件名时间格式。 */
+ private static final DateTimeFormatter USER_EXPORT_FILE_TIME_FORMATTER =
+ DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
+
+ /** 用户预约明细中核销时间的 Excel 展示格式。 */
+ private static final DateTimeFormatter EXCEL_DATE_TIME_FORMATTER =
+ DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
+ /** 菜品预约导出进度的 Redis Key 命名空间。 */
+ private static final String PRODUCT_EXPORT_REDIS_KEY = "hard:canteen-product-appt:export";
+
+ /** 菜品预约导出任务名称。 */
+ private static final String PRODUCT_EXPORT_TASK_NAME = "菜品预约统计导出";
+
+ /** 异步任务创建时的占位总数,真实日期数量在后台查询后更新。 */
+ private static final int INITIAL_PROGRESS_TOTAL = 1;
+
+ /** 菜品预约导出工作表名称。 */
+ private static final String PRODUCT_EXPORT_SHEET_NAME = "菜品预约统计";
+
+ /** 文件中心存储目录。 */
+ private static final String EXPORT_FILE_DIR = "hard-export";
+
+ /** 文件中心业务类型编码。 */
+ private static final String PRODUCT_EXPORT_FILE_TYPE = "HARD_CANTEEN_PRODUCT_APPT_EXPORT";
+
+ /** Excel 文件 MIME 类型。 */
+ private static final String EXCEL_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
+
+ /** 用餐时间段字典类型。 */
+ private static final String MEAL_SEGMENT_DICT_TYPE = "daily_menu";
+
+ /** Excel 文件名时间格式。 */
+ private static final DateTimeFormatter FILE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
+
+ /** 用户预约查询允许的最大自然月跨度,开始和结束日期均计入查询范围。 */
+ private static final long MAX_QUERY_MONTHS = 2L;
+
+ /** 页面协议中的已订餐状态。 */
+ private static final String RESERVATION_STATUS_RESERVED = "RESERVED";
+
+ /** 页面协议中的未订餐状态。 */
+ private static final String RESERVATION_STATUS_NOT_RESERVED = "NOT_RESERVED";
+
+ /** 页面协议中的无需核销状态。 */
+ private static final String VERIFICATION_STATUS_NONE = "NONE";
+
+ /** 页面协议中的全部待核销状态。 */
+ private static final String VERIFICATION_STATUS_UNVERIFIED = "UNVERIFIED";
+
+ /** 页面协议中的部分预约已核销状态。 */
+ private static final String VERIFICATION_STATUS_PARTIAL = "PARTIAL";
+
+ /** 页面协议中的全部已核销状态。 */
+ private static final String VERIFICATION_STATUS_VERIFIED = "VERIFIED";
+
+ /** 允许的订餐状态筛选值。 */
+ private static final Set RESERVATION_STATUS_VALUES = Set.of(
+ RESERVATION_STATUS_RESERVED, RESERVATION_STATUS_NOT_RESERVED);
+
+ /** 允许的核销状态筛选值。 */
+ private static final Set VERIFICATION_STATUS_VALUES = Set.of(
+ VERIFICATION_STATUS_NONE, VERIFICATION_STATUS_UNVERIFIED,
+ VERIFICATION_STATUS_PARTIAL, VERIFICATION_STATUS_VERIFIED);
+
+ /** 有效预约订单状态,只统计已支付和已完成核销的订单。 */
+ private static final List VALID_RESERVATION_ORDER_STATUSES = HardOrderStatusEnum.getPaySuccessCodes();
+
+ /** 用户预约导出和订单关联的单批用户数量。 */
+ private static final long USER_BATCH_SIZE = 500L;
+
+ /** 单次订单查询允许的用户主键数量。 */
+ private static final int ORDER_USER_BATCH_SIZE = 500;
+
+ /** 用户远程服务,负责导出权限范围内的完整用户资料。 */
+ private final RemoteUserService remoteUserService;
+
+ /** 预约订单查询 Mapper,供用户预约导出直接读取预约订单。 */
+ private final HardOrderMapper hardOrderMapper;
+
+ /** 菜品预约聚合 Mapper。 */
+ private final HardCarMapper hardCarMapper;
+
+ /** 每日菜谱远程服务,用于展开套餐组成。 */
+ private final RemoteDailyMenuService remoteDailyMenuService;
+
+ /** 部门远程服务,用于补齐预约人所属部门名称。 */
+ private final RemoteDeptService remoteDeptService;
+
+ /** 餐段字典远程服务,用于输出餐段名称。 */
+ private final RemoteDictService remoteDictService;
+
+ /** 商户远程服务,用于补齐食堂名称。 */
+ private final RemoteMchService remoteMchService;
+
+ /** 文件中心远程服务。 */
+ private final RemoteFileService remoteFileService;
+
+ /** 通用异步任务执行器。 */
+ private final AsyncTaskExecutorService asyncTaskExecutorService;
+
+ /**
+ * 提交用户每日预约异步导出任务。
+ *
+ * 请求线程只校验并复制筛选条件;后台任务恢复当前租户和登录用户上下文后,按权限用户分页读取并
+ * 分批写入 Excel,避免长时间占用 HTTP 连接和一次性持有全部导出行。
+ *
+ * @param query 用户、日期范围、食堂、餐段和状态筛选条件
+ * @return 已创建任务的初始进度
+ */
+ @Override
+ public AsyncTaskProgressVO submitUserReservationExport(HardCanteenUserApptQueryDto query) {
+ validateQuery(query);
+ HardCanteenUserApptQueryDto querySnapshot = copyUserReservationQuery(query);
+ return asyncTaskExecutorService.submitWithResult(USER_RESERVATION_EXPORT_REDIS_KEY,
+ USER_RESERVATION_EXPORT_TASK_NAME, USER_RESERVATION_EXPORT_INITIAL_TOTAL,
+ reporter -> exportUserReservations(querySnapshot, reporter));
+ }
+
+ /**
+ * 查询用户预约 Excel 数据。
+ *
+ * @param query 用户、日期范围和预约状态筛选条件
+ * @return 用户和日期维度的导出行,最后一行为餐段及当日金额合计
+ */
+ @Override
+ public List userReservationExport(HardCanteenUserApptQueryDto query) {
+ validateQuery(query);
+ HardCanteenUserApptQueryDto exportQuery = copyUserReservationQuery(query);
+ if (!narrowQueryToReservationCandidates(exportQuery)) {
+ return List.of();
+ }
+ Map mealSegmentLabelMap = fetchMealSegmentLabelMap();
+ List reservationRows =
+ listAllFilteredExportRows(exportQuery, getQueryDates(exportQuery));
+ if (reservationRows.isEmpty()) {
+ return List.of();
+ }
+ UserReservationExportSummary summary =
+ new UserReservationExportSummary(exportQuery, mealSegmentLabelMap);
+ summary.addRows(reservationRows);
+ List excelRows = new ArrayList<>(reservationRows.size() + 1);
+ reservationRows.stream().map(row -> toExcelRow(row, exportQuery, mealSegmentLabelMap))
+ .forEach(excelRows::add);
+ excelRows.add(toExcelSummaryRow(summary, mealSegmentLabelMap));
+ return excelRows;
+ }
+
+ /**
+ * 在异步线程中分批查询用户预约、生成 Excel 并上传文件中心。
+ *
+ * @param query 后台线程独占的用户预约筛选条件
+ * @param reporter 当前异步任务进度报告器
+ * @return 文件中心下载地址和业务文件名
+ */
+ private AsyncTaskResult exportUserReservations(HardCanteenUserApptQueryDto query,
+ AsyncTaskProgressReporter reporter) {
+ if (!narrowQueryToReservationCandidates(query)) {
+ throw new IllegalStateException("没有符合当前筛选条件的用户预约记录");
+ }
+ Page firstUserPage = fetchExportUserPage(1L, USER_RESERVATION_EXPORT_BATCH_SIZE, query);
+ long userTotal = firstUserPage.getTotal();
+ if (userTotal <= 0L) {
+ throw new IllegalStateException("没有符合当前条件的用户预约数据");
+ }
+ validateUserReservationExportRowCapacity(userTotal, query);
+ int progressTotal = calculateUserReservationExportProgressTotal(userTotal);
+ reporter.updateTotal(progressTotal, "已确定 " + userTotal + " 位符合条件用户,将分批生成预约文件");
+
+ String fileName = "用户预约查询_" + LocalDateTime.now().format(USER_EXPORT_FILE_TIME_FORMATTER) + ".xlsx";
+ Path outputPath = null;
+ try {
+ outputPath = Files.createTempFile("canteen-user-appt-export-", ".xlsx");
+ long exportedRowTotal = writeUserReservationWorkbook(outputPath, query, firstUserPage, userTotal,
+ reporter);
+ String fileUrl = uploadUserReservationWorkbook(outputPath, fileName);
+ reporter.advance("用户预约导出文件已生成,共 " + exportedRowTotal + " 条");
+ return AsyncTaskResult.file(fileUrl, fileName);
+ } catch (IllegalStateException exception) {
+ throw exception;
+ } catch (Exception exception) {
+ log.error("|用户预约查询导出|生成或保存文件失败,fileName:{}", fileName, exception);
+ throw new IllegalStateException("生成用户预约导出文件失败", exception);
+ } finally {
+ deleteTemporaryExportFile(outputPath);
+ }
+ }
+
+ /**
+ * 按权限用户分页读取并把当前批次结果写入 EasyExcel 流式工作簿。
+ *
+ * 订餐和核销状态依赖订单关联结果,因此在每批用户展开日期后执行;批次写入完成即可释放,
+ * 不会把整个日期范围的全部用户行同时留在 JVM 堆中。
+ *
+ * @param outputPath 最终上传前使用的临时 XLSX 文件
+ * @param query 已冻结的筛选条件
+ * @param firstUserPage 已用于取得用户总数的第一页,避免重复远程调用
+ * @param userTotal 任务开始时取得的权限用户总数
+ * @param reporter 当前异步任务进度报告器
+ * @return 实际写入 Excel 的用户日期行数
+ */
+ private long writeUserReservationWorkbook(Path outputPath, HardCanteenUserApptQueryDto query,
+ Page firstUserPage, long userTotal,
+ AsyncTaskProgressReporter reporter) {
+ List queryDates = getQueryDates(query);
+ Map mealSegmentLabelMap = fetchMealSegmentLabelMap();
+ long processedUserTotal = 0L;
+ long exportedRowTotal = 0L;
+ long current = 1L;
+ UserReservationExportSummary summary = new UserReservationExportSummary(query, mealSegmentLabelMap);
+
+ try (ExcelWriter excelWriter = EasyExcel.write(outputPath.toFile(), HardCanteenUserApptExcelVo.class).build()) {
+ WriteSheet writeSheet = EasyExcel.writerSheet(USER_RESERVATION_EXPORT_SHEET_NAME).build();
+ Page userPage = firstUserPage;
+ while (processedUserTotal < userTotal) {
+ List pageRecords = userPage.getRecords() == null ? List.of() : userPage.getRecords();
+ if (pageRecords.isEmpty()) {
+ break;
+ }
+ int remainingUserCount = Math.toIntExact(Math.min(pageRecords.size(), userTotal - processedUserTotal));
+ List batchUsers = pageRecords.subList(0, remainingUserCount);
+ List batchReservationRows =
+ filterRows(buildRows(batchUsers, query, queryDates), query);
+ long nextExportedRowTotal = exportedRowTotal + batchReservationRows.size();
+ if (nextExportedRowTotal > MAX_USER_RESERVATION_EXPORT_ROWS) {
+ throw new IllegalStateException("导出数据超过单个 Excel 工作表支持的 "
+ + MAX_USER_RESERVATION_EXPORT_ROWS + " 条,请缩小查询范围后分批导出");
+ }
+ List batchRows = batchReservationRows.stream()
+ .map(row -> toExcelRow(row, query, mealSegmentLabelMap)).toList();
+ if (!batchRows.isEmpty()) {
+ excelWriter.write(batchRows, writeSheet);
+ summary.addRows(batchReservationRows);
+ }
+ exportedRowTotal = nextExportedRowTotal;
+ processedUserTotal += batchUsers.size();
+ reporter.advance("已处理 " + processedUserTotal + " / " + userTotal + " 位用户,已写入 "
+ + exportedRowTotal + " 条预约记录");
+ if (processedUserTotal >= userTotal) {
+ break;
+ }
+ userPage = fetchExportUserPage(++current, USER_RESERVATION_EXPORT_BATCH_SIZE, query);
+ }
+ if (processedUserTotal != userTotal) {
+ throw new IllegalStateException("导出用户数据在执行期间发生变化,预期 " + userTotal
+ + " 位,实际处理 " + processedUserTotal + " 位");
+ }
+ if (exportedRowTotal == 0L) {
+ throw new IllegalStateException("没有符合当前筛选条件的用户预约记录");
+ }
+ excelWriter.write(List.of(toExcelSummaryRow(summary, mealSegmentLabelMap)), writeSheet);
+ }
+ return exportedRowTotal;
+ }
+
+ /**
+ * 根据权限用户总数计算“用户查询批次数 + 文件上传和任务收尾”的进度总步数。
+ *
+ * @param userTotal 当前筛选条件下的权限用户总数
+ * @return 统一进度组件使用的总步数
+ */
+ private int calculateUserReservationExportProgressTotal(long userTotal) {
+ return BatchExcelExportTemplate.calculateProgressTotal(userTotal, USER_RESERVATION_EXPORT_BATCH_SIZE,
+ USER_RESERVATION_EXPORT_EXTRA_PROGRESS_STEPS);
+ }
+
+ /**
+ * 在没有订餐或核销状态筛选时,提前校验确定会生成的“用户 × 日期”明细行数。
+ *
+ * 存在关联状态筛选时最终行数只能在分批关联订单后确定,写入过程仍会执行单工作表上限保护。
+ *
+ * @param userTotal 当前用户筛选和数据权限范围内的用户总数
+ * @param query 已冻结的日期和预约状态筛选条件
+ */
+ private void validateUserReservationExportRowCapacity(long userTotal, HardCanteenUserApptQueryDto query) {
+ if (hasJoinedStatusFilter(query)) {
+ return;
+ }
+ long rowTotal;
+ try {
+ rowTotal = Math.multiplyExact(userTotal, (long) getQueryDates(query).size());
+ } catch (ArithmeticException exception) {
+ throw new IllegalStateException("用户预约导出数据量过大,请缩小查询范围后分批导出", exception);
+ }
+ if (rowTotal > MAX_USER_RESERVATION_EXPORT_ROWS) {
+ throw new IllegalStateException("导出数据超过单个 Excel 工作表支持的 "
+ + MAX_USER_RESERVATION_EXPORT_ROWS + " 条,请缩小查询范围后分批导出");
+ }
+ }
+
+ /**
+ * 将已关闭的完整 XLSX 临时文件上传到统一文件中心。
+ *
+ * @param outputPath 已完成写入的临时文件
+ * @param fileName 用户下载时使用的业务文件名
+ * @return 文件中心提供的下载地址
+ * @throws Exception 临时文件读取或远程上传失败时抛出
+ */
+ private String uploadUserReservationWorkbook(Path outputPath, String fileName) throws Exception {
+ MultipartFile exportFile = new MockMultipartFile("file", encodeUploadOriginalFilename(fileName),
+ USER_EXPORT_EXCEL_CONTENT_TYPE, Files.readAllBytes(outputPath));
+ R> uploadResult = remoteFileService.innerUpload(exportFile, USER_RESERVATION_EXPORT_FILE_DIR, null,
+ USER_RESERVATION_EXPORT_FILE_TYPE);
+ if (uploadResult == null || !uploadResult.isOk()
+ || !(uploadResult.getData() instanceof Map, ?> resultData)) {
+ throw new IllegalStateException("文件中心未返回有效的用户预约导出结果");
+ }
+ Object resultUrl = resultData.get("url");
+ if (resultUrl == null || StrUtil.isBlank(resultUrl.toString())) {
+ throw new IllegalStateException("文件中心未返回用户预约导出下载地址");
+ }
+ return resultUrl.toString();
+ }
+
+ /**
+ * 复制用户预约查询条件,避免异步任务与控制器请求对象共享可变数组或列表。
+ *
+ * @param source 已通过基础校验的请求筛选条件
+ * @return 后台线程独占的完整筛选条件副本
+ */
+ private HardCanteenUserApptQueryDto copyUserReservationQuery(HardCanteenUserApptQueryDto source) {
+ HardCanteenUserApptQueryDto target = new HardCanteenUserApptQueryDto();
+ target.setApptUseDateList(source.getApptUseDateList() == null
+ ? null : source.getApptUseDateList().clone());
+ target.setNameOrPhone(source.getNameOrPhone());
+ target.setDeptIds(copyList(source.getDeptIds()));
+ target.setIsInclude(source.getIsInclude());
+ target.setMerchantId(source.getMerchantId());
+ target.setHourType(source.getHourType());
+ target.setReservationStatus(source.getReservationStatus());
+ target.setVerificationStatus(source.getVerificationStatus());
+ target.setUserIds(copyList(source.getUserIds()));
+ return target;
+ }
+
+ /**
+ * 复制可能为空的筛选列表,保留空值与空集合的原始查询语义。
+ *
+ * @param source 请求对象中的可变列表
+ * @param 列表元素类型
+ * @return 空值保持为空,非空列表返回独立副本
+ */
+ private List copyList(List source) {
+ return source == null ? null : new ArrayList<>(source);
+ }
+
+ /**
+ * 按文件服务约定编码中文原始文件名,避免 ISO-8859-1 到 UTF-8 的转换产生乱码。
+ *
+ * @param fileName 用户看到的 UTF-8 文件名
+ * @return 文件中心上传接口使用的原始文件名
+ */
+ private String encodeUploadOriginalFilename(String fileName) {
+ return new String(fileName.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1);
+ }
+
+ /**
+ * 清理异步导出创建的最终临时文件;清理失败只记录告警,不覆盖原始任务结果。
+ *
+ * @param outputPath 待删除的临时文件路径,允许为空
+ */
+ private void deleteTemporaryExportFile(Path outputPath) {
+ if (outputPath == null) {
+ return;
+ }
+ try {
+ Files.deleteIfExists(outputPath);
+ } catch (Exception exception) {
+ log.warn("|用户预约查询导出|清理临时文件失败,path:{}", outputPath, exception);
+ }
+ }
+
+ /**
+ * 校验必填条件和前后端状态协议值,防止未知值被静默当成“全部”。
+ *
+ * @param query 待校验的查询条件
+ */
+ private void validateQuery(HardCanteenUserApptQueryDto query) {
+ if (query == null || query.getApptUseDateList() == null || query.getApptUseDateList().length != 2
+ || query.getApptUseDateList()[0] == null || query.getApptUseDateList()[1] == null) {
+ throw new IllegalArgumentException("用餐日期范围必须包含开始和结束日期");
+ }
+ LocalDate startDate = query.getApptUseDateList()[0];
+ LocalDate endDate = query.getApptUseDateList()[1];
+ if (endDate.isBefore(startDate)) {
+ throw new IllegalArgumentException("用餐结束日期不能早于开始日期");
+ }
+ if (endDate.isAfter(startDate.plusMonths(MAX_QUERY_MONTHS))) {
+ throw new IllegalArgumentException("用餐日期范围最多查询两个月");
+ }
+ if (StrUtil.isNotBlank(query.getReservationStatus())
+ && !RESERVATION_STATUS_VALUES.contains(query.getReservationStatus())) {
+ throw new IllegalArgumentException("订餐状态不正确");
+ }
+ if (StrUtil.isNotBlank(query.getVerificationStatus())
+ && !VERIFICATION_STATUS_VALUES.contains(query.getVerificationStatus())) {
+ throw new IllegalArgumentException("核销状态不正确");
+ }
+ }
+
+ /**
+ * 展开查询范围内的全部自然日,开始和结束日期均包含在结果中。
+ *
+ * @param query 已通过日期范围校验的查询条件
+ * @return 按日期升序排列的查询日期
+ */
+ private List getQueryDates(HardCanteenUserApptQueryDto query) {
+ LocalDate endDate = query.getApptUseDateList()[1];
+ List dates = new ArrayList<>();
+ LocalDate currentDate = query.getApptUseDateList()[0];
+ while (!currentDate.isAfter(endDate)) {
+ dates.add(currentDate);
+ if (currentDate.equals(endDate)) {
+ break;
+ }
+ currentDate = currentDate.plusDays(1L);
+ }
+ return dates;
+ }
+
+ /**
+ * 判断查询是否需要在用户与预约订单关联后再进行筛选。
+ *
+ * @param query 查询条件
+ * @return 存在订餐或核销状态条件时返回 true
+ */
+ private boolean hasJoinedStatusFilter(HardCanteenUserApptQueryDto query) {
+ return StrUtil.isNotBlank(query.getReservationStatus()) || StrUtil.isNotBlank(query.getVerificationStatus());
+ }
+
+ /**
+ * 判断当前筛选结果是否必然存在有效预约订单。
+ *
+ * 已订餐以及待核销、部分核销、已核销都可以先从订单表反查候选用户;未订餐和无需核销属于
+ * “订单不存在”条件,仍需以全部权限用户为主表执行左关联。
+ *
+ * @param query 预约状态和核销状态筛选条件
+ * @return 可以先按预约订单收窄用户范围时返回 true
+ */
+ private boolean requiresExistingReservation(HardCanteenUserApptQueryDto query) {
+ return Objects.equals(query.getReservationStatus(), RESERVATION_STATUS_RESERVED)
+ || (StrUtil.isNotBlank(query.getVerificationStatus())
+ && !Objects.equals(query.getVerificationStatus(), VERIFICATION_STATUS_NONE));
+ }
+
+ /**
+ * 对必然存在预约的列表或导出条件先查询候选用户,并写回独立查询快照。
+ *
+ * 候选 ID 只负责缩小 UPMS 查询范围,姓名、部门和当前操作者的数据权限仍由 UPMS 最终过滤;
+ * 原查询已指定用户时,订单查询也会先应用该范围,因此不会扩大导出对象。
+ *
+ * @param query 当前列表或导出流程独占、允许写入候选用户主键的查询快照
+ * @return 无需预筛或存在候选用户时返回 true,确定没有匹配预约时返回 false
+ */
+ private boolean narrowQueryToReservationCandidates(HardCanteenUserApptQueryDto query) {
+ if (!requiresExistingReservation(query)) {
+ return true;
+ }
+ List candidateUserIds = hardOrderMapper.listReservationUserIds(
+ buildReservationOrderQuery(query, copyList(query.getUserIds())));
+ if (candidateUserIds == null || candidateUserIds.isEmpty()) {
+ return false;
+ }
+ query.setUserIds(candidateUserIds.stream().filter(Objects::nonNull).distinct().toList());
+ return !query.getUserIds().isEmpty();
+ }
+
+ /**
+ * 获取完整用户导出资料并在关联预约订单后执行状态筛选。
+ *
+ * 与页面列表的轻量用户扫描分离,只有导出需要角色、岗位和账号扩展字段时才调用 UPMS 完整导出分页。
+ *
+ * @param query 查询条件
+ * @param queryDates 查询范围内按升序排列的全部日期
+ * @return 携带完整用户主资料和身份资料的预约导出行
+ */
+ private List listAllFilteredExportRows(HardCanteenUserApptQueryDto query,
+ List queryDates) {
+ List users = fetchAllExportUsers(query);
+ return filterRows(buildRows(users, query, queryDates), query);
+ }
+
+ /**
+ * 调用 UPMS 导出分页查询完整用户主资料、角色岗位和身份扩展。
+ *
+ * @param current 当前页码
+ * @param size 每页数量
+ * @param query 用户筛选条件
+ * @return 含准确总数和完整导出资料的用户分页
+ */
+ private Page fetchExportUserPage(long current, long size, HardCanteenUserApptQueryDto query) {
+ R> response = remoteUserService.getScopeExportUserPage(current, size,
+ buildUserQuery(query));
+ if (response == null || !response.isOk() || response.getData() == null) {
+ String message = response == null ? null : response.getMsg();
+ throw new IllegalStateException(StrUtil.isBlank(message) ? "查询用户导出资料失败" : message);
+ }
+ return response.getData();
+ }
+
+ /**
+ * 将预约查询中的用户筛选条件转换为 UPMS 用户分页条件。
+ *
+ * @param query 用户预约查询条件
+ * @return 姓名手机号、部门包含关系和指定用户条件的独立请求对象
+ */
+ private UserDTO buildUserQuery(HardCanteenUserApptQueryDto query) {
+ UserDTO userQuery = new UserDTO();
+ userQuery.setNameOrPhone(query.getNameOrPhone());
+ userQuery.setDeptIds(copyList(query.getDeptIds()));
+ userQuery.setIsInclude(query.getIsInclude());
+ userQuery.setUserIds(copyList(query.getUserIds()));
+ return userQuery;
+ }
+
+ /**
+ * 分页遍历完整用户导出资料,供兼容同步导出使用。
+ *
+ * 管理端默认走异步分批导出;该方法仅保持原同步接口与新二级表头字段一致。
+ *
+ * @param query 用户筛选条件
+ * @return 权限范围内携带角色岗位和身份资料的全部用户
+ */
+ private List fetchAllExportUsers(HardCanteenUserApptQueryDto query) {
+ List users = new ArrayList<>();
+ long current = 1L;
+ long total;
+ do {
+ Page page = fetchExportUserPage(current, USER_BATCH_SIZE, query);
+ List records = page.getRecords() == null ? List.of() : page.getRecords();
+ users.addAll(records);
+ total = page.getTotal();
+ if (records.isEmpty()) {
+ break;
+ }
+ current++;
+ } while (users.size() < total);
+ return users;
+ }
+
+ /**
+ * 将用户列表与日期范围内的预约订单按用户、日期左关联,每个用户每天至少生成一行。
+ *
+ * @param users 权限用户列表
+ * @param query 日期范围、食堂和餐段筛选条件
+ * @param queryDates 查询范围内按升序排列的全部日期
+ * @return 按用户顺序、日期升序排列的预约行
+ */
+ private List buildRows(List users, HardCanteenUserApptQueryDto query,
+ List queryDates) {
+ if (users == null || users.isEmpty()) {
+ return List.of();
+ }
+ List userIds = users.stream().map(UserVO::getUserId).filter(Objects::nonNull).distinct().toList();
+ List orders = fetchReservationOrders(userIds, query);
+ return buildRows(users, query, queryDates, orders);
+ }
+
+ /**
+ * 使用已加载的预约订单构造用户-日期行,供列表查询和统计查询共用同一行生成逻辑。
+ *
+ * @param users 权限范围内的用户
+ * @param query 日期、食堂和餐段筛选条件
+ * @param queryDates 查询范围内的自然日
+ * @param orders 已按相同筛选条件加载的有效预约订单
+ * @return 按用户顺序和日期顺序排列的预约行
+ */
+ private List buildRows(List users, HardCanteenUserApptQueryDto query,
+ List queryDates, List orders) {
+ if (users == null || users.isEmpty()) {
+ return List.of();
+ }
+ Map merchantNameMap = fetchMerchantNameMap(orders);
+ Map>> orderMap = orders.stream()
+ .filter(order -> order.getUserId() != null && order.getApptUseDate() != null)
+ .collect(Collectors.groupingBy(HardOrderVo::getUserId, LinkedHashMap::new,
+ Collectors.groupingBy(HardOrderVo::getApptUseDate, LinkedHashMap::new,
+ Collectors.mapping(order -> toOrderVo(order, merchantNameMap), Collectors.toList()))));
+
+ return users.stream().filter(user -> user.getUserId() != null).flatMap(user -> queryDates.stream().map(date -> {
+ Map> userOrderMap =
+ orderMap.getOrDefault(user.getUserId(), Map.of());
+ List userOrders = userOrderMap.getOrDefault(date, List.of());
+ HardCanteenUserApptVo row = new HardCanteenUserApptVo();
+ row.setUserId(user.getUserId());
+ row.setUserName(resolveUserName(user));
+ row.setUsername(user.getUsername());
+ row.setPhone(user.getPhone());
+ row.setAvatar(user.getAvatar());
+ row.setDeptId(user.getDeptId());
+ row.setDeptName(user.getDeptName());
+ row.setNickname(user.getNickname());
+ row.setGender(user.getGender());
+ row.setEmail(user.getEmail());
+ row.setCreateTime(user.getCreateTime());
+ row.setPostList(user.getPostList());
+ row.setRoleList(user.getRoleList());
+ row.setStaffingType(user.getStaffingType());
+ row.setLockFlag(user.getLockFlag());
+ // UPMS 的权限用户分页已批量补齐身份资料,这里直接复制,避免按用户再次远程查询。
+ row.setIdentityTypes(user.getIdentityTypes());
+ row.setStudentInfo(user.getStudentInfo());
+ row.setTeacherInfo(user.getTeacherInfo());
+ row.setApptUseDate(date);
+ row.setReservationStatus(userOrders.isEmpty()
+ ? RESERVATION_STATUS_NOT_RESERVED : RESERVATION_STATUS_RESERVED);
+ row.setVerificationStatus(resolveVerificationStatus(userOrders));
+ row.setTotalPrice(calculateTotalPrice(userOrders));
+ row.setOrders(userOrders);
+ return row;
+ })).toList();
+ }
+
+ /**
+ * 按用户主键分批查询指定日期范围内的有效食堂预约订单。
+ *
+ * @param userIds 权限范围内的用户主键
+ * @param query 日期范围、食堂和餐段筛选条件
+ * @return 当前租户内符合条件的有效预约订单
+ */
+ private List fetchReservationOrders(List userIds, HardCanteenUserApptQueryDto query) {
+ if (userIds.isEmpty()) {
+ return List.of();
+ }
+ List orders = new ArrayList<>();
+ for (int start = 0; start < userIds.size(); start += ORDER_USER_BATCH_SIZE) {
+ int end = Math.min(start + ORDER_USER_BATCH_SIZE, userIds.size());
+ HardOrderDto orderQuery = buildReservationOrderQuery(query,
+ new ArrayList<>(userIds.subList(start, end)));
+ orders.addAll(hardOrderMapper.scopeList(orderQuery, null));
+ }
+ return orders;
+ }
+
+ /**
+ * 分批读取预约订单关联的购物车快照,避免统计请求因用户范围过大而生成超长 IN 条件。
+ *
+ * @param orders 当前筛选口径下的预约订单
+ * @return 父购物车主键到商品快照的映射
+ */
+ private Map> fetchReservationCars(List orders) {
+ List parentCarIds = orders.stream().map(HardOrderVo::getParentCarId)
+ .filter(Objects::nonNull).distinct().toList();
+ if (parentCarIds.isEmpty()) {
+ return Map.of();
+ }
+ Map> result = new LinkedHashMap<>();
+ for (int start = 0; start < parentCarIds.size(); start += ORDER_USER_BATCH_SIZE) {
+ int end = Math.min(start + ORDER_USER_BATCH_SIZE, parentCarIds.size());
+ hardCarMapper.listByParentCarIds(parentCarIds.subList(start, end))
+ .forEach(car -> result.computeIfAbsent(car.getParentCarId(), key -> new ArrayList<>()).add(car));
+ }
+ return result;
+ }
+
+ /**
+ * 构造用户预约列表、候选用户预查询和导出明细共用的订单条件。
+ *
+ * 统一复用可保证预查询命中的用户与最终写入 Excel 的有效预约口径一致,避免日期、食堂、餐段、
+ * 订单状态或预约关联条件在两次查询间发生偏差。
+ *
+ * @param query 用户预约页面提交的日期、食堂和餐段筛选条件
+ * @param userIds 需要限制的用户主键;为空时查询当前租户内全部候选预约用户
+ * @return 可直接交给订单 Mapper 的完整预约条件
+ */
+ private HardOrderDto buildReservationOrderQuery(HardCanteenUserApptQueryDto query, List userIds) {
+ HardOrderDto orderQuery = new HardOrderDto();
+ orderQuery.setUserIds(userIds);
+ orderQuery.setMerchantType(MerchantTypeEnum.CANTEEN.getType());
+ orderQuery.setMerchantId(query.getMerchantId());
+ orderQuery.setHourType(query.getHourType());
+ orderQuery.setOrderType(HardOrderTypeEnum.RESERVE.getType());
+ orderQuery.setOrderStatusCodes(VALID_RESERVATION_ORDER_STATUSES);
+ orderQuery.setApptIdOrApptRuleIdNotNull(true);
+ orderQuery.setApptUseDateTimeList(new java.time.LocalDateTime[]{
+ query.getApptUseDateList()[0].atStartOfDay(),
+ query.getApptUseDateList()[1].atTime(LocalTime.MAX)
+ });
+ return orderQuery;
+ }
+
+ /**
+ * 批量补齐订单关联的食堂名称;商户服务异常时保留订单并返回空名称,避免影响预约事实查询。
+ *
+ * @param orders 有效预约订单
+ * @return 商户主键到食堂名称的映射
+ */
+ private Map fetchMerchantNameMap(List orders) {
+ List merchantIds = orders.stream().map(HardOrderVo::getMerchantId)
+ .filter(Objects::nonNull).distinct().toList();
+ if (merchantIds.isEmpty()) {
+ return Map.of();
+ }
+ try {
+ R> response = remoteMchService.getByIds(merchantIds);
+ if (response == null || !response.isOk() || response.getData() == null) {
+ log.warn("查询用户预约食堂名称失败,保留预约订单并使用空名称");
+ return Map.of();
+ }
+ return response.getData().stream().filter(merchant ->
+ merchant.getId() != null && StrUtil.isNotBlank(merchant.getName()))
+ .collect(Collectors.toMap(MchManageVo::getId, MchManageVo::getName,
+ (left, right) -> left, LinkedHashMap::new));
+ } catch (Exception exception) {
+ log.warn("查询用户预约食堂名称异常,保留预约订单并使用空名称,原因:{}", exception.getMessage());
+ return Map.of();
+ }
+ }
+
+ /**
+ * 将订单查询对象转换为页面所需的最小预约明细。
+ *
+ * @param order 有效预约订单
+ * @param merchantNameMap 食堂名称映射
+ * @return 预约明细
+ */
+ private HardCanteenUserApptOrderVo toOrderVo(HardOrderVo order, Map merchantNameMap) {
+ HardCanteenUserApptOrderVo detail = new HardCanteenUserApptOrderVo();
+ detail.setId(order.getId());
+ detail.setHourType(order.getHourType());
+ detail.setProductDesc(order.getProductDesc());
+ detail.setMerchantName(merchantNameMap.get(order.getMerchantId()));
+ detail.setTotalPrice(order.getTotalPrice());
+ detail.setOrderStatus(order.getOrderStatus());
+ detail.setUpdateTime(order.getUpdateTime());
+ detail.setUpdateBy(order.getUpdateBy());
+ return detail;
+ }
+
+ /**
+ * 汇总预约明细的商品总价,历史空金额按零分计算。
+ *
+ * @param orders 同一用户、同一用餐日期和当前查询口径下的有效预约明细
+ * @return 商品总价,单位为分;没有预约时返回零
+ */
+ private long calculateTotalPrice(List orders) {
+ return orders.stream().map(HardCanteenUserApptOrderVo::getTotalPrice).filter(Objects::nonNull)
+ .mapToLong(Integer::longValue).sum();
+ }
+
+ /**
+ * 依次按姓名、昵称和登录账号确定页面展示名。
+ *
+ * @param user 用户信息
+ * @return 非空展示名;用户资料均未维护时返回“未命名用户”
+ */
+ private String resolveUserName(UserVO user) {
+ if (StrUtil.isNotBlank(user.getName())) {
+ return user.getName();
+ }
+ if (StrUtil.isNotBlank(user.getNickname())) {
+ return user.getNickname();
+ }
+ return StrUtil.isBlank(user.getUsername()) ? "未命名用户" : user.getUsername();
+ }
+
+ /**
+ * 根据有效预约明细计算用户整体核销状态。
+ *
+ * @param orders 用户在当前筛选口径下的有效预约明细
+ * @return NONE、UNVERIFIED、PARTIAL 或 VERIFIED
+ */
+ private String resolveVerificationStatus(List orders) {
+ if (orders.isEmpty()) {
+ return VERIFICATION_STATUS_NONE;
+ }
+ long verifiedCount = orders.stream().filter(order ->
+ Objects.equals(order.getOrderStatus(), HardOrderStatusEnum.CANCEL.getType())).count();
+ if (verifiedCount == 0L) {
+ return VERIFICATION_STATUS_UNVERIFIED;
+ }
+ return verifiedCount == orders.size() ? VERIFICATION_STATUS_VERIFIED : VERIFICATION_STATUS_PARTIAL;
+ }
+
+ /**
+ * 按订餐和核销状态筛选已完成左关联的用户行。
+ *
+ * @param rows 用户预约行
+ * @param query 状态筛选条件
+ * @return 符合状态条件的行
+ */
+ private List filterRows(List rows,
+ HardCanteenUserApptQueryDto query) {
+ return rows.stream().filter(row ->
+ (StrUtil.isBlank(query.getReservationStatus())
+ || Objects.equals(query.getReservationStatus(), row.getReservationStatus()))
+ && (StrUtil.isBlank(query.getVerificationStatus())
+ || Objects.equals(query.getVerificationStatus(), row.getVerificationStatus())))
+ .toList();
+ }
+
+ /**
+ * 对关联状态筛选后的用户行执行内存分页。
+ *
+ * @param current 当前页码
+ * @param size 每页数量
+ * @param rows 全部筛选结果
+ * @return 标准 MyBatis-Plus 分页结果
+ */
+ private Page paginateRows(long current, long size,
+ List rows) {
+ long offset = (current - 1L) * size;
+ int fromIndex = offset >= rows.size() ? rows.size() : Math.toIntExact(offset);
+ long endOffset = Math.min(offset + size, rows.size());
+ int toIndex = Math.max(fromIndex, Math.toIntExact(endOffset));
+ Page result = new Page<>(current, size, rows.size());
+ result.setRecords(rows.subList(fromIndex, toIndex));
+ return result;
+ }
+
+ /**
+ * 将用户预约汇总转换为单行 Excel 数据,保持“一个用户 + 一个日期”导出口径。
+ *
+ * @param row 用户预约汇总
+ * @param query 原始查询条件,用于无订单时保留所选餐段
+ * @param mealSegmentLabelMap 餐段编码到字典标签的映射
+ * @return Excel 导出行
+ */
+ private HardCanteenUserApptExcelVo toExcelRow(HardCanteenUserApptVo row,
+ HardCanteenUserApptQueryDto query,
+ Map mealSegmentLabelMap) {
+ List orders = row.getOrders();
+ List verifiedOrders = orders.stream().filter(order ->
+ Objects.equals(order.getOrderStatus(), HardOrderStatusEnum.CANCEL.getType())).toList();
+ HardCanteenUserApptExcelVo excel = new HardCanteenUserApptExcelVo();
+ excel.setUserId(row.getUserId());
+ excel.setUserName(row.getUserName());
+ excel.setEmail(row.getEmail());
+ excel.setPhone(row.getPhone());
+ excel.setDeptName(row.getDeptName());
+ excel.setPostNames(joinPostNames(row.getPostList()));
+ excel.setIdentityTypeNames(joinIdentityNames(row.getIdentityTypes()));
+ if (row.getStudentInfo() != null) {
+ excel.setStudentNo(row.getStudentInfo().getStudentNo());
+ excel.setSchoolName(row.getStudentInfo().getSchoolName());
+ excel.setClassName(row.getStudentInfo().getClassName());
+ excel.setGuardianPhones(row.getStudentInfo().getGuardianPhones());
+ }
+ if (row.getTeacherInfo() != null) {
+ excel.setTeacherNo(row.getTeacherInfo().getTeacherNo());
+ }
+ excel.setApptUseDate(row.getApptUseDate());
+ excel.setReservationStatus(Objects.equals(row.getReservationStatus(), RESERVATION_STATUS_RESERVED)
+ ? "已订餐" : "未订餐");
+ excel.setHourTypes(orders.isEmpty()
+ ? (StrUtil.isBlank(query.getHourType()) ? "全部餐段"
+ : resolveMealSegmentLabel(query.getHourType(), mealSegmentLabelMap))
+ : joinDistinct(orders, order -> resolveMealSegmentLabel(order.getHourType(),
+ mealSegmentLabelMap)));
+ excel.setBreakfastReservation(buildMealSegmentReservation(orders, query, HardHourTypeEnum.MORNING));
+ excel.setLunchReservation(buildMealSegmentReservation(orders, query, HardHourTypeEnum.NOONING));
+ excel.setDinnerReservation(buildMealSegmentReservation(orders, query, HardHourTypeEnum.NIGHT));
+ excel.setLateNightReservation(buildMealSegmentReservation(orders, query, HardHourTypeEnum.LATE_NIGHT));
+ excel.setMealSegmentTotalPrices(buildMealSegmentTotalPrices(orders, query, mealSegmentLabelMap));
+ excel.setTotalPrice(centsToYuan(row.getTotalPrice()));
+ excel.setProductDescriptions(joinDistinct(orders, order ->
+ StrUtil.isBlank(order.getProductDesc()) ? "订餐内容未记录" : order.getProductDesc()));
+ excel.setMerchantNames(joinDistinct(orders, HardCanteenUserApptOrderVo::getMerchantName));
+ excel.setVerificationStatus(verificationStatusText(row.getVerificationStatus()));
+ excel.setVerificationTimes(joinDistinct(verifiedOrders, order -> order.getUpdateTime() == null
+ ? "核销时间未记录" : EXCEL_DATE_TIME_FORMATTER.format(order.getUpdateTime())));
+ return excel;
+ }
+
+ /**
+ * 生成用户预约导出文件末尾的金额合计行。
+ *
+ * 合计仍使用与明细相同的导出模型,姓名列标记“合计”,餐段金额和当日金额分别写入对应统计列,
+ * 其余人员及预约字段保持为空。
+ *
+ * @param summary 已累计全部导出分片的金额汇总
+ * @param mealSegmentLabelMap 餐段编码到字典标签的映射
+ * @return 可直接追加到 EasyExcel 工作表末尾的合计行
+ */
+ private HardCanteenUserApptExcelVo toExcelSummaryRow(UserReservationExportSummary summary,
+ Map mealSegmentLabelMap) {
+ HardCanteenUserApptExcelVo excel = new HardCanteenUserApptExcelVo();
+ excel.setUserName("合计");
+ excel.setMealSegmentTotalPrices(buildExportSummaryMealSegmentTotalPrices(summary,
+ mealSegmentLabelMap));
+ excel.setTotalPrice(centsToYuan(summary.totalPrice));
+ return excel;
+ }
+
+ /**
+ * 查询用餐时间段字典并保留字典排序;远程调用失败时由实际订单编码继续完成导出。
+ *
+ * @return 餐段编码到展示标签的有序映射
+ */
+ private Map fetchMealSegmentLabelMap() {
+ try {
+ R> response = remoteDictService.getDictByType(MEAL_SEGMENT_DICT_TYPE);
+ if (response == null || !response.isOk() || response.getData() == null) {
+ log.warn("查询用餐时间段字典失败,导出将使用订单餐段编码");
+ return Map.of();
+ }
+ return response.getData().stream().filter(item ->
+ StrUtil.isNotBlank(item.getItemValue()) && StrUtil.isNotBlank(item.getLabel()))
+ .collect(Collectors.toMap(SysDictItem::getItemValue, SysDictItem::getLabel,
+ (left, right) -> left, LinkedHashMap::new));
+ } catch (Exception exception) {
+ log.warn("查询用餐时间段字典异常,导出将使用订单餐段编码,原因:{}", exception.getMessage());
+ return Map.of();
+ }
+ }
+
+ /**
+ * 将餐段协议编码转换为导出文案,并兼容历史空编码。
+ *
+ * @param hourType 订单或查询条件中的餐段协议编码
+ * @param mealSegmentLabelMap 餐段编码到字典标签的映射
+ * @return 字典标签;未知编码保留原值,空编码返回“未标记餐段”
+ */
+ private String resolveMealSegmentLabel(String hourType, Map mealSegmentLabelMap) {
+ if (StrUtil.isBlank(hourType)) {
+ return "未标记餐段";
+ }
+ return mealSegmentLabelMap.getOrDefault(hourType, hourType);
+ }
+
+ /**
+ * 生成一个固定餐段的预约状态。
+ *
+ * @param orders 用户当天有效预约明细
+ * @param query 原始查询条件,用于区分未订餐和未参与查询的餐段
+ * @param hourType 固定餐段协议枚举
+ * @return “已订N单”“未订餐”或“未查询”
+ */
+ private String buildMealSegmentReservation(List orders,
+ HardCanteenUserApptQueryDto query,
+ HardHourTypeEnum hourType) {
+ if (StrUtil.isNotBlank(query.getHourType()) && !Objects.equals(query.getHourType(), hourType.getType())) {
+ return "未查询";
+ }
+ long orderCount = orders.stream().filter(order ->
+ Objects.equals(hourType.getType(), order.getHourType())).count();
+ return orderCount == 0L ? "未订餐" : "已订" + orderCount + "单";
+ }
+
+ /**
+ * 生成各餐段商品总价摘要,未订餐段明确输出零元。
+ *
+ * @param orders 用户当天有效预约明细
+ * @param query 原始查询条件,选择餐段时只生成该餐段金额
+ * @param mealSegmentLabelMap 餐段编码到字典标签的映射
+ * @return 例如“早餐:0.00元;午餐:18.50元”的摘要
+ */
+ private String buildMealSegmentTotalPrices(List orders,
+ HardCanteenUserApptQueryDto query,
+ Map mealSegmentLabelMap) {
+ Map segments = resolveMealSegments(orders, query, mealSegmentLabelMap);
+ if (segments.isEmpty()) {
+ return "全部餐段:0.00元";
+ }
+ return segments.entrySet().stream().map(entry -> {
+ long totalPrice = orders.stream().filter(order ->
+ Objects.equals(entry.getKey(), order.getHourType()))
+ .map(HardCanteenUserApptOrderVo::getTotalPrice).filter(Objects::nonNull)
+ .mapToLong(Integer::longValue).sum();
+ return entry.getValue() + ":" + formatYuan(totalPrice);
+ }).collect(Collectors.joining(";"));
+ }
+
+ /**
+ * 格式化全部导出明细累计后的各餐段金额。
+ *
+ * @param summary 跨分片累计的金额汇总
+ * @param mealSegmentLabelMap 餐段编码到字典标签的映射
+ * @return 例如“早餐:60.80元;午餐:119.00元”的餐段合计
+ */
+ private String buildExportSummaryMealSegmentTotalPrices(UserReservationExportSummary summary,
+ Map mealSegmentLabelMap) {
+ if (summary.mealSegmentTotalPrices.isEmpty()) {
+ return "全部餐段:0.00元";
+ }
+ return summary.mealSegmentTotalPrices.entrySet().stream()
+ .map(entry -> resolveMealSegmentLabel(entry.getKey(), mealSegmentLabelMap)
+ + ":" + formatYuan(entry.getValue()))
+ .collect(Collectors.joining(";"));
+ }
+
+ /**
+ * 合并当前字典餐段和订单中的历史餐段编码,供预约数量及金额摘要复用。
+ *
+ * @param orders 用户当天有效预约明细
+ * @param query 原始查询条件
+ * @param mealSegmentLabelMap 餐段编码到字典标签的映射
+ * @return 保持字典顺序并补齐历史编码的餐段映射
+ */
+ private Map resolveMealSegments(List orders,
+ HardCanteenUserApptQueryDto query,
+ Map mealSegmentLabelMap) {
+ Map segments = new LinkedHashMap<>();
+ if (StrUtil.isNotBlank(query.getHourType())) {
+ segments.put(query.getHourType(),
+ mealSegmentLabelMap.getOrDefault(query.getHourType(), query.getHourType()));
+ } else {
+ segments.putAll(mealSegmentLabelMap);
+ orders.stream().map(HardCanteenUserApptOrderVo::getHourType).filter(StrUtil::isNotBlank)
+ .forEach(hourType -> segments.putIfAbsent(hourType, hourType));
+ }
+ return segments;
+ }
+
+ /**
+ * 提取、去空、去重并连接预约明细字段。
+ *
+ * @param orders 预约明细列表
+ * @param mapper 字段提取函数
+ * @return 使用中文分号连接的字段值;没有有效值时返回空字符串
+ */
+ private String joinDistinct(List orders,
+ Function mapper) {
+ return orders.stream().map(mapper).filter(StrUtil::isNotBlank).distinct()
+ .collect(Collectors.joining(";"));
+ }
+
+ /**
+ * 将整数分转换为精确的元金额。
+ *
+ * @param price 商品总价,单位为分;为空时按零计算
+ * @return 以元为单位且固定两位小数精度的金额
+ */
+ private BigDecimal centsToYuan(Long price) {
+ return BigDecimal.valueOf(price == null ? 0L : price, 2);
+ }
+
+ /**
+ * 将整数分格式化为带元单位的两位小数文案。
+ *
+ * @param price 商品总价,单位为分
+ * @return 例如“18.50元”的金额文案
+ */
+ private String formatYuan(long price) {
+ return centsToYuan(price).toPlainString() + "元";
+ }
+
+ /**
+ * 拼接用户岗位名称,空岗位列表输出空字符串。
+ *
+ * @param posts 完整用户导出分页返回的岗位列表
+ * @return 以中文逗号分隔的岗位名称
+ */
+ private String joinPostNames(List posts) {
+ if (posts == null || posts.isEmpty()) {
+ return "";
+ }
+ return posts.stream().filter(Objects::nonNull).map(SysPost::getPostName)
+ .filter(StrUtil::isNotBlank).collect(Collectors.joining(","));
+ }
+
+ /**
+ * 按用户身份协议顺序拼接身份中文名称。
+ *
+ * @param identityTypes UPMS 返回的用户身份协议编码
+ * @return 以中文逗号分隔的身份名称;未知历史编码保留原值
+ */
+ private String joinIdentityNames(List identityTypes) {
+ if (identityTypes == null || identityTypes.isEmpty()) {
+ return "";
+ }
+ return identityTypes.stream().map(identityType -> {
+ UserIdentityTypeEnum identity = UserIdentityTypeEnum.valueOfByType(identityType);
+ return identity == null ? identityType : identity.getDescription();
+ }).filter(StrUtil::isNotBlank).collect(Collectors.joining(","));
+ }
+
+ /**
+ * 将核销状态协议值转换为导出文案。
+ *
+ * @param status 核销状态协议值
+ * @return 中文核销状态
+ */
+ private String verificationStatusText(String status) {
+ if (Objects.equals(status, VERIFICATION_STATUS_VERIFIED)) {
+ return "已核销";
+ }
+ if (Objects.equals(status, VERIFICATION_STATUS_PARTIAL)) {
+ return "部分核销";
+ }
+ if (Objects.equals(status, VERIFICATION_STATUS_UNVERIFIED)) {
+ return "待核销";
+ }
+ return "无需核销";
+ }
+
+ /**
+ * 用户预约导出跨分片金额汇总器。
+ *
+ * 所有金额均以分为单位累计,生成最终合计行时才统一转换为元,避免分片导出过程中出现精度损失。
+ */
+ private static final class UserReservationExportSummary {
+
+ /** 各餐段累计金额,键为订单餐段协议编码,值为商品总价,单位为分。 */
+ private final Map mealSegmentTotalPrices = new LinkedHashMap<>();
+
+ /** 全部导出明细的当日金额累计值,单位为分。 */
+ private long totalPrice;
+
+ /**
+ * 创建跨分片汇总器,并按查询餐段或餐段字典顺序预置零金额。
+ *
+ * @param query 本次导出的餐段筛选条件
+ * @param mealSegmentLabelMap 餐段编码到字典标签的有序映射
+ */
+ private UserReservationExportSummary(HardCanteenUserApptQueryDto query,
+ Map mealSegmentLabelMap) {
+ if (StrUtil.isNotBlank(query.getHourType())) {
+ mealSegmentTotalPrices.put(query.getHourType(), 0L);
+ } else {
+ mealSegmentLabelMap.keySet().forEach(hourType ->
+ mealSegmentTotalPrices.put(hourType, 0L));
+ }
+ }
+
+ /**
+ * 累计一批已经通过全部查询条件筛选的用户日期明细。
+ *
+ * @param rows 当前分片即将写入 Excel 的用户日期明细
+ */
+ private void addRows(List rows) {
+ rows.forEach(this::addRow);
+ }
+
+ /**
+ * 累计单个用户日期明细的当日总金额和各餐段金额。
+ *
+ * @param row 当前导出的一条用户日期明细
+ */
+ private void addRow(HardCanteenUserApptVo row) {
+ totalPrice = Math.addExact(totalPrice, row.getTotalPrice() == null ? 0L : row.getTotalPrice());
+ if (row.getOrders() == null || row.getOrders().isEmpty()) {
+ return;
+ }
+ row.getOrders().stream().filter(Objects::nonNull)
+ .filter(order -> StrUtil.isNotBlank(order.getHourType()))
+ .forEach(order -> mealSegmentTotalPrices.merge(order.getHourType(),
+ order.getTotalPrice() == null ? 0L : order.getTotalPrice().longValue(),
+ Math::addExact));
+ }
+ }
+
+ /**
+ * 提交菜品预约异步导出任务。
+ *
+ * @param query 菜品预约统计筛选条件
+ * @return 初始异步任务进度
+ */
+ @Override
+ public AsyncTaskProgressVO submitProductReservationExport(HardOrderDto query) {
+ HardOrderDto querySnapshot = copyProductQuery(query);
+ return asyncTaskExecutorService.submitWithResult(PRODUCT_EXPORT_REDIS_KEY, PRODUCT_EXPORT_TASK_NAME,
+ INITIAL_PROGRESS_TOTAL, reporter -> exportProducts(querySnapshot, reporter));
+ }
+
+ /**
+ * 同步查询菜品预约自定义 Excel 行。
+ *
+ * @param query 菜品预约统计筛选条件
+ * @return 三级层级导出行
+ */
+ @Override
+ public List productReservationExport(HardOrderDto query) {
+ HardOrderDto querySnapshot = copyProductQuery(query);
+ List rows = new ArrayList<>();
+ for (LocalDate date : getQueryDates(querySnapshot)) {
+ HardOrderDto dateQuery = copyProductQuery(querySnapshot);
+ dateQuery.setApptUseDateTimeList(new LocalDateTime[]{date.atStartOfDay(), date.atTime(23, 59, 59)});
+ rows.addAll(buildProductRows(dateQuery, date));
+ }
+ return rows;
+ }
+
+ /**
+ * 后台按日期批次生成菜品预约工作簿并上传文件中心。
+ *
+ * @param query 后台线程独占的查询快照
+ * @param reporter 当前异步任务进度报告器
+ * @return 文件中心结果
+ */
+ private AsyncTaskResult exportProducts(HardOrderDto query, AsyncTaskProgressReporter reporter) {
+ List dates = getQueryDates(query);
+ reporter.updateTotal(Math.max(1, dates.size() + 1), "已确定 " + dates.size() + " 个用餐日期,开始分批导出");
+ Path outputPath = null;
+ String fileName = "菜品预约统计_" + LocalDateTime.now().format(FILE_TIME_FORMATTER) + ".xlsx";
+ try {
+ outputPath = Files.createTempFile("canteen-product-appt-export-", ".xlsx");
+ long rowCount;
+ try (ExcelWriter writer = EasyExcel.write(outputPath.toFile(), HardCanteenProductApptExcelVo.class)
+ .registerWriteHandler(new HardCanteenProductApptExportMergeHandler()).build()) {
+ WriteSheet sheet = EasyExcel.writerSheet(PRODUCT_EXPORT_SHEET_NAME).build();
+ rowCount = 0L;
+ for (LocalDate date : dates) {
+ HardOrderDto dateQuery = copyProductQuery(query);
+ dateQuery.setApptUseDateTimeList(new LocalDateTime[]{date.atStartOfDay(), date.atTime(23, 59, 59)});
+ List batchRows = buildProductRows(dateQuery, date);
+ if (!batchRows.isEmpty()) {
+ writer.write(batchRows, sheet);
+ rowCount += batchRows.size();
+ }
+ reporter.advance("已处理 " + date + ",写入 " + rowCount + " 行");
+ }
+ if (rowCount == 0L) {
+ throw new IllegalStateException("没有符合当前条件的菜品预约数据");
+ }
+ }
+ String fileUrl = upload(outputPath, fileName);
+ reporter.advance("菜品预约导出文件已生成");
+ return AsyncTaskResult.file(fileUrl, fileName);
+ } catch (IllegalStateException exception) {
+ throw exception;
+ } catch (Exception exception) {
+ log.error("菜品预约统计导出失败,fileName:{}", fileName, exception);
+ throw new IllegalStateException("生成菜品预约导出文件失败", exception);
+ } finally {
+ if (outputPath != null) {
+ try {
+ Files.deleteIfExists(outputPath);
+ } catch (Exception exception) {
+ log.warn("删除菜品预约导出临时文件失败,path:{}", outputPath, exception);
+ }
+ }
+ }
+ }
+
+ /**
+ * 查询一个日期批次的三层数据并转换为 Excel 行。
+ *
+ * @param query 日期批次查询条件
+ * @param fixedDate 已知的批次日期;同步查询传空时从查询结果读取
+ * @return 当前批次的汇总、商户部门和商品明细行
+ */
+ private List buildProductRows(HardOrderDto query, LocalDate fixedDate) {
+ List summaries = hardCarMapper.listApptExportSummary(query);
+ List merchants = hardCarMapper.listApptExportMerchant(query);
+ List products = hardCarMapper.listApptExportProduct(query);
+ if (summaries == null || summaries.isEmpty()) {
+ return List.of();
+ }
+ Map merchantNames = fetchMerchantNames(merchants);
+ Map departmentNames = fetchDepartmentNames(merchants);
+ Map hourNames = fetchHourNames();
+ Map menuMap = fetchDailyMenus(products, fixedDate);
+ Map> merchantGroups = merchants.stream()
+ .collect(Collectors.groupingBy(this::summaryGroupKey, LinkedHashMap::new, Collectors.toList()));
+ Map> productGroups = products.stream()
+ .collect(Collectors.groupingBy(this::groupKey, LinkedHashMap::new, Collectors.toList()));
+ List rows = new ArrayList<>();
+ for (HardCanteenProductTotalVo summary : summaries) {
+ LocalDate date = fixedDate != null ? fixedDate
+ : summary.getCreateTime() == null ? null : summary.getCreateTime().toLocalDate();
+ if (date == null) {
+ log.warn("菜品预约导出汇总记录缺少预约日期,hourType:{},跳过该记录", summary.getHourType());
+ continue;
+ }
+ String hourType = summary.getHourType();
+ 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()));
+ 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(), "商户信息未记录"));
+ merchantRow.setDepartmentName(departmentNames.getOrDefault(merchant.getUserDeptId(), "部门信息未记录"));
+ merchantRow.setMerchantDate(date);
+ 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))));
+ }
+ }
+ }
+ return rows;
+ }
+
+ /**
+ * 将商品聚合行按预约当天每日菜谱中的套餐组成展开。
+ *
+ * 组成商品数量按“套餐预约数量 × 套餐内份数”计算;组成商品金额按菜谱单价权重使用最大余数法分摊,
+ * 从而保证所有组成行金额之和严格等于套餐订单快照金额,并避免极小金额因四舍五入产生负数尾差。
+ *
+ * @param product 订单购物车商品聚合记录,数量和金额均为套餐商品口径
+ * @param menu 与商户、预约日期、餐段和商品 SPU 匹配的每日菜谱;为空时保留原商品行
+ * @return 单品原始行,或按套餐组成顺序生成的商品明细行
+ */
+ private List toProductRows(HardCanteenProductApptExportRecord product, DailyMenuAppVO menu) {
+ List items = menu != null && menu.getComboItems() != null
+ && !menu.getComboItems().isEmpty() ? menu.getComboItems() : List.of();
+ if (items.isEmpty()) {
+ return List.of(toProductRow(product.getProductName(), valueOrZero(product.getPayCount()),
+ product.getTotalPrice()));
+ }
+ long packageCount = valueOrZero(product.getPayCount());
+ long totalCents = valueOrZero(product.getTotalPrice());
+ long sign = totalCents < 0 ? -1L : 1L;
+ long absoluteTotalCents = Math.abs(totalCents);
+ long baseAmount = items.stream().mapToLong(item -> Math.max(0, valueOrZero(item.getPrice()))
+ * Math.max(1, valueOrZero(item.getQuantity()))).sum();
+ long[] itemPrices = new long[items.size()];
+ long[] itemWeights = new long[items.size()];
+ long allocated = 0L;
+ List remainderIndexes = new ArrayList<>();
+ List result = new ArrayList<>();
+ for (int index = 0; index < items.size(); index++) {
+ OnlineSpuComboItemVO item = items.get(index);
+ long itemWeight = Math.max(0, valueOrZero(item.getPrice())) * Math.max(1, valueOrZero(item.getQuantity()));
+ itemWeights[index] = itemWeight;
+ if (baseAmount > 0) {
+ BigDecimal exactPrice = BigDecimal.valueOf(absoluteTotalCents)
+ .multiply(BigDecimal.valueOf(itemWeight))
+ .divide(BigDecimal.valueOf(baseAmount), 6, RoundingMode.DOWN);
+ itemPrices[index] = exactPrice.setScale(0, RoundingMode.FLOOR).longValue();
+ allocated += itemPrices[index];
+ remainderIndexes.add(index);
+ }
+ }
+ if (baseAmount > 0) {
+ long remainder = absoluteTotalCents - allocated;
+ // 每项先向下取整,再按小数余数从大到小补分,保证分摊值不为负且合计不丢分。
+ remainderIndexes.sort(Comparator.comparing((Integer index) -> {
+ BigDecimal exactPrice = BigDecimal.valueOf(absoluteTotalCents)
+ .multiply(BigDecimal.valueOf(itemWeights[index]))
+ .divide(BigDecimal.valueOf(baseAmount), 6, RoundingMode.DOWN);
+ return exactPrice.subtract(BigDecimal.valueOf(itemPrices[index]));
+ }).reversed());
+ for (int index = 0; index < remainderIndexes.size() && remainder > 0; index++, remainder--) {
+ itemPrices[remainderIndexes.get(index)]++;
+ }
+ } else if (!items.isEmpty()) {
+ itemPrices[items.size() - 1] = absoluteTotalCents;
+ }
+ for (int index = 0; index < items.size(); index++) {
+ OnlineSpuComboItemVO item = items.get(index);
+ long itemCount = packageCount * Math.max(1, valueOrZero(item.getQuantity()));
+ long itemPrice = itemPrices[index] * sign;
+ result.add(toProductRow(StrUtil.blankToDefault(item.getName(), "未命名商品"), itemCount, itemPrice));
+ }
+ return result;
+ }
+
+ /** 创建单个商品明细行。 */
+ private HardCanteenProductApptExcelVo toProductRow(String name, long count, long totalPrice) {
+ HardCanteenProductApptExcelVo row = new HardCanteenProductApptExcelVo();
+ row.setProductName(name);
+ row.setProductCount(count);
+ row.setProductTotalPrice(centsToYuan(totalPrice));
+ return row;
+ }
+
+ /** 查询当前批次涉及的每日菜谱,并以商户、日期、餐段和 SPU 组成缓存键。 */
+ private Map fetchDailyMenus(List products,
+ LocalDate fixedDate) {
+ Map result = new HashMap<>();
+ LocalDate date = fixedDate;
+ if (date == null) return result;
+ List merchantIds = products.stream().map(HardCanteenProductApptExportRecord::getMerchantId)
+ .filter(Objects::nonNull).distinct().toList();
+ for (Long merchantId : merchantIds) {
+ for (String hourType : products.stream().filter(item -> Objects.equals(item.getMerchantId(), merchantId))
+ .map(HardCanteenProductApptExportRecord::getHourType).filter(StrUtil::isNotBlank).distinct().toList()) {
+ try {
+ Integer hour = Integer.valueOf(hourType);
+ R> response = remoteDailyMenuService.getCanteenList(date.toString(), hour, null, merchantId);
+ if (response != null && response.isOk() && response.getData() != null) {
+ response.getData().forEach(menu -> {
+ if (menu.getSpuId() != null) result.put(merchantId + "|" + date + "|" + hourType + "|" + menu.getSpuId(), menu);
+ });
+ }
+ } catch (Exception exception) {
+ log.warn("查询每日菜谱套餐组成失败,保留原商品行,merchantId:{},date:{},hourType:{}",
+ merchantId, date, hourType, exception.getMessage());
+ }
+ }
+ }
+ return result;
+ }
+
+ /** 批量查询商户名称。 */
+ private Map fetchMerchantNames(List rows) {
+ List ids = rows.stream().map(HardCanteenProductApptExportRecord::getMerchantId).filter(Objects::nonNull).distinct().toList();
+ if (ids.isEmpty()) return Map.of();
+ try {
+ R> response = remoteMchService.getByIds(ids);
+ if (response == null || !response.isOk() || response.getData() == null) return Map.of();
+ return response.getData().stream().filter(item -> item.getId() != null)
+ .collect(Collectors.toMap(MchManageVo::getId, MchManageVo::getName, (left, right) -> left));
+ } catch (Exception exception) {
+ log.warn("查询导出商户名称失败,使用空名称", exception);
+ return Map.of();
+ }
+ }
+
+ /** 批量查询部门名称。 */
+ private Map fetchDepartmentNames(List rows) {
+ List ids = rows.stream().map(HardCanteenProductApptExportRecord::getUserDeptId).filter(Objects::nonNull).distinct().toList();
+ if (ids.isEmpty()) return Map.of();
+ try {
+ R