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> response = remoteDeptService.getDeptByDeptIds(ids); + if (response == null || !response.isOk() || response.getData() == null) return Map.of(); + return response.getData().values().stream().filter(item -> item.getDeptId() != null) + .collect(Collectors.toMap(SysDept::getDeptId, SysDept::getName, (left, right) -> left)); + } catch (Exception exception) { + log.warn("查询导出部门名称失败,使用空名称", exception); + return Map.of(); + } + } + + /** 查询餐段字典。 */ + private Map fetchHourNames() { + try { + R> response = remoteDictService.getDictByType(MEAL_SEGMENT_DICT_TYPE); + if (response == null || !response.isOk() || response.getData() == null) return Map.of(); + return response.getData().stream().filter(item -> StrUtil.isNotBlank(item.getItemValue())) + .collect(Collectors.toMap(SysDictItem::getItemValue, SysDictItem::getLabel, (left, right) -> left)); + } catch (Exception exception) { + log.warn("查询导出餐段字典失败,使用餐段编码", exception); + return Map.of(); + } + } + + /** 复制菜品统计查询条件,避免异步任务与请求对象共享可变数组和集合。 */ + private HardOrderDto copyProductQuery(HardOrderDto source) { + if (source == null) throw new IllegalArgumentException("菜品预约导出查询条件不能为空"); + HardOrderDto target = new HardOrderDto(); + target.setApptUseDateTimeList(source.getApptUseDateTimeList() == null ? null : source.getApptUseDateTimeList().clone()); + target.setHourType(source.getHourType()); + target.setMerchantId(source.getMerchantId()); + target.setUserDeptIds(copyList(source.getUserDeptIds())); + target.setIsInclude(source.getIsInclude()); + target.setMerchantDeptIds(copyList(source.getMerchantDeptIds())); + target.setMchIds(copyList(source.getMchIds())); + target.setOrderType(HardOrderTypeEnum.RESERVE.getType()); + target.setApptIdOrApptRuleIdNotNull(true); + target.setOrderStatusCodes(HardOrderStatusEnum.getPaySuccessCodes()); + return target; + } + + /** 获取查询日期闭区间。 */ + private List getQueryDates(HardOrderDto query) { + if (query.getApptUseDateTimeList() == null || query.getApptUseDateTimeList().length != 2 + || query.getApptUseDateTimeList()[0] == null || query.getApptUseDateTimeList()[1] == null) { + throw new IllegalArgumentException("预约时间不能为空"); + } + LocalDate start = query.getApptUseDateTimeList()[0].toLocalDate(); + LocalDate end = query.getApptUseDateTimeList()[1].toLocalDate(); + if (end.isBefore(start)) throw new IllegalArgumentException("预约结束时间不能早于开始时间"); + List dates = new ArrayList<>(); + for (LocalDate date = start; !date.isAfter(end); date = date.plusDays(1)) dates.add(date); + return dates; + } + + /** 构造商户部门分组键。 */ + private String groupKey(HardCanteenProductApptExportRecord record) { + return record.getApptDate() + "|" + record.getHourType() + "|" + record.getMerchantId() + "|" + record.getUserDeptId(); + } + + /** 构造日期餐段分组键,用于从日期餐段汇总行定位其下属商户部门统计。 */ + private String summaryGroupKey(HardCanteenProductApptExportRecord record) { + return record.getApptDate() + "|" + record.getHourType(); + } + + /** 构造商品每日菜谱缓存键。 */ + private String menuKey(HardCanteenProductApptExportRecord record, LocalDate date) { + return record.getMerchantId() + "|" + date + "|" + record.getHourType() + "|" + record.getProductId(); + } + + /** 将空数值按零处理。 */ + private long valueOrZero(Number value) { + return value == null ? 0L : value.longValue(); + } + + /** 将分转换为元,保持两位小数。 */ + private BigDecimal centsToYuan(Number cents) { + return BigDecimal.valueOf(valueOrZero(cents)).movePointLeft(2).setScale(2, RoundingMode.HALF_UP); + } + + /** 解析餐段名称,字典缺失时保留原始编码。 */ + private String resolveHourName(String hourType, Map hourNames) { + return StrUtil.blankToDefault(hourNames.get(hourType), StrUtil.blankToDefault(hourType, "未标记餐段")); + } + + /** 上传已关闭的 Excel 临时文件并提取下载地址。 */ + private String upload(Path path, String fileName) throws Exception { + MultipartFile file = new MockMultipartFile("file", encodeFileName(fileName), EXCEL_CONTENT_TYPE, + Files.readAllBytes(path)); + R response = remoteFileService.innerUpload(file, EXPORT_FILE_DIR, null, PRODUCT_EXPORT_FILE_TYPE); + if (response == null || !response.isOk() || !(response.getData() instanceof Map data) + || data.get("url") == null || StrUtil.isBlank(data.get("url").toString())) { + throw new IllegalStateException("文件中心未返回菜品预约导出下载地址"); + } + return data.get("url").toString(); + } + + /** 按文件中心约定编码中文文件名。 */ + private String encodeFileName(String fileName) { + return new String(fileName.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1); + } + +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalServiceImpl.java index 228ebb91..f0c90ae6 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalServiceImpl.java @@ -2,36 +2,27 @@ package com.bsd.pjcloud.hard.service.impl.order; import cn.hutool.core.util.ObjectUtil; import cn.hutool.core.util.StrUtil; -import com.alibaba.excel.EasyExcel; -import com.alibaba.excel.ExcelWriter; -import com.alibaba.excel.write.metadata.WriteSheet; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; -import com.bsd.pjcloud.admin.api.constant.user.UserIdentityTypeEnum; import com.bsd.pjcloud.admin.api.dto.UserDTO; -import com.bsd.pjcloud.admin.api.entity.SysDictItem; -import com.bsd.pjcloud.admin.api.entity.SysPost; -import com.bsd.pjcloud.admin.api.feign.RemoteDictService; -import com.bsd.pjcloud.admin.api.feign.RemoteFileService; import com.bsd.pjcloud.admin.api.feign.RemoteUserService; import com.bsd.pjcloud.admin.api.vo.UserVO; -import com.bsd.pjcloud.common.asynctask.service.AsyncTaskExecutorService; -import com.bsd.pjcloud.common.asynctask.service.AsyncTaskProgressReporter; -import com.bsd.pjcloud.common.asynctask.service.AsyncTaskResult; -import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO; import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.data.datascope.DataScope; -import com.bsd.pjcloud.common.excel.stream.BatchExcelExportTemplate; -import com.bsd.pjcloud.hard.api.constant.order.HardHourTypeEnum; import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum; import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum; import com.bsd.pjcloud.hard.api.dto.order.HardCanteenUserApptQueryDto; 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.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.HardCanteenUserApptDailyStatisticsVo; +import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptDishStatisticsVo; +import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptMealStatisticsVo; +import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptStatisticsSummaryVo; +import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptStatisticsVo; import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo; -import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenUserApptExcelVo; import com.bsd.pjcloud.hard.mapper.order.HardCarMapper; import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper; import com.bsd.pjcloud.hard.service.HardCanteenApptTotalService; @@ -39,20 +30,13 @@ import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; import com.bsd.pjcloud.wallet.api.vo.MchManageVo; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.mock.web.MockMultipartFile; import org.springframework.stereotype.Service; import org.springframework.validation.annotation.Validated; -import org.springframework.web.multipart.MultipartFile; -import java.math.BigDecimal; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; import java.time.LocalDate; -import java.time.LocalDateTime; import java.time.LocalTime; -import java.time.format.DateTimeFormatter; import java.util.ArrayList; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -78,49 +62,6 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ /** 单次订单查询的用户主键数量,避免 SQL IN 条件过长。 */ private static final int ORDER_USER_BATCH_SIZE = 500; - /** 用户预约导出任务的 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"; - - /** 用户预约 Excel 文件上传时使用的标准 MIME 类型。 */ - private static final String EXCEL_CONTENT_TYPE = - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; - - /** 异步导出文件名使用的时间格式,避免连续任务产生同名文件。 */ - private static final DateTimeFormatter EXPORT_FILE_TIME_FORMATTER = - DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); - /** 用户预约查询允许的最大自然月跨度,开始和结束日期均计入查询范围。 */ private static final long MAX_QUERY_MONTHS = 2L; @@ -154,12 +95,6 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ /** 有效预约订单状态,只统计已支付待核销和已完成核销的订单。 */ private static final List VALID_RESERVATION_ORDER_STATUSES = HardOrderStatusEnum.getPaySuccessCodes(); - /** Excel 中核销时间的统一展示格式。 */ - private static final DateTimeFormatter EXCEL_DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); - - /** 用餐时间段字典类型,属于 PC、App 与后端共同使用的既有协议。 */ - private static final String MEAL_SEGMENT_DICT_TYPE = "daily_menu"; - /** 菜品预约聚合 Mapper。 */ private final HardCarMapper hardCarMapper; @@ -169,18 +104,9 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ /** 用户远程服务,负责返回当前操作者数据权限范围内的用户分页。 */ private final RemoteUserService remoteUserService; - /** 字典远程服务,用于在 Excel 中完整列出已配置和未预约的餐段。 */ - private final RemoteDictService remoteDictService; - - /** 商户远程服务,用于批量补齐预约所属食堂名称。 */ + /** 商户远程服务,用于补齐预约所属食堂名称。 */ private final RemoteMchService remoteMchService; - /** 通用异步任务执行器,负责恢复租户、安全及 Feign 上下文并持久化导出进度。 */ - private final AsyncTaskExecutorService asyncTaskExecutorService; - - /** 文件中心远程服务,用于保存异步生成的用户预约 Excel。 */ - private final RemoteFileService remoteFileService; - /** * 按预约使用日期和用餐时间段汇总已支付的预约菜品。 * @@ -264,223 +190,10 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ } /** - * 提交用户每日预约异步导出任务。 + * 复制用户预约查询条件,避免分页查询在预筛选过程中修改调用方持有的可变集合。 * - *

请求线程只校验并复制筛选条件;后台任务恢复当前租户和登录用户上下文后,按权限用户分页读取并 - * 分批写入 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(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), - 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 后台线程独占的完整筛选条件副本 + * @param source 原始用户预约筛选条件 + * @return 与原条件值等价且集合独立的查询副本 */ private HardCanteenUserApptQueryDto copyUserReservationQuery(HardCanteenUserApptQueryDto source) { HardCanteenUserApptQueryDto target = new HardCanteenUserApptQueryDto(); @@ -498,40 +211,53 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ } /** - * 复制可能为空的筛选列表,保留空值与空集合的原始查询语义。 + * 复制可选用户筛选列表,保留空值语义并避免远程请求对象之间共享可变列表。 * - * @param source 请求对象中的可变列表 + * @param source 原始列表,可为空 * @param 列表元素类型 - * @return 空值保持为空,非空列表返回独立副本 + * @return 独立列表副本或空值 */ private List copyList(List source) { return source == null ? null : new ArrayList<>(source); } /** - * 按文件服务约定编码中文原始文件名,避免 ISO-8859-1 到 UTF-8 的转换产生乱码。 + * 按当前用户预约查询条件生成每日和查询范围总计。 * - * @param fileName 用户看到的 UTF-8 文件名 - * @return 文件中心上传接口使用的原始文件名 + *

统计先复用用户预约列表的权限用户、有效订单和状态筛选口径,再读取订单购物车快照计算菜品份数, + * 保证抽屉中的人数、订单和菜品不会因为分页而少算或多算。

+ * + * @param query 用户、日期范围、食堂、餐段和状态筛选条件 + * @return 按日期升序排列并包含范围总计的统计结果 */ - private String encodeUploadOriginalFilename(String fileName) { - return new String(fileName.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1); - } + @Override + public HardCanteenUserApptStatisticsVo userReservationStatistics(HardCanteenUserApptQueryDto query) { + validateQuery(query); + List queryDates = getQueryDates(query); + List users = fetchAllUsers(query); + List userIds = users.stream().map(UserVO::getUserId).filter(Objects::nonNull).distinct().toList(); + List orders = fetchReservationOrders(userIds, query); + List rows = filterRows(buildRows(users, query, queryDates, orders), query); + Map orderMap = orders.stream().filter(order -> order.getId() != null) + .collect(Collectors.toMap(HardOrderVo::getId, Function.identity(), (left, right) -> left)); + Map> carMap = fetchReservationCars(orders); - /** - * 清理异步导出创建的最终临时文件;清理失败只记录告警,不覆盖原始任务结果。 - * - * @param outputPath 待删除的临时文件路径,允许为空 - */ - private void deleteTemporaryExportFile(Path outputPath) { - if (outputPath == null) { - return; - } - try { - Files.deleteIfExists(outputPath); - } catch (Exception exception) { - log.warn("|用户预约查询导出|清理临时文件失败,path:{}", outputPath, exception); + Map dailyBuckets = new LinkedHashMap<>(); + queryDates.forEach(date -> dailyBuckets.put(date, new StatisticsBucket())); + StatisticsBucket totalBucket = new StatisticsBucket(); + for (HardCanteenUserApptVo row : rows) { + StatisticsBucket dailyBucket = dailyBuckets.get(row.getApptUseDate()); + if (dailyBucket == null) { + continue; + } + dailyBucket.addRow(row, orderMap, carMap); + totalBucket.addRow(row, orderMap, carMap); } + + HardCanteenUserApptStatisticsVo result = new HardCanteenUserApptStatisticsVo(); + result.setTotal(totalBucket.toSummary()); + result.setDaily(queryDates.stream().map(date -> dailyBuckets.get(date).toDaily(date)).toList()); + return result; } /** @@ -642,20 +368,6 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ return filterRows(buildRows(users, query, queryDates), query); } - /** - * 获取完整用户导出资料并在关联预约订单后执行状态筛选。 - * - *

与页面列表的轻量用户扫描分离,只有导出需要角色、岗位和账号扩展字段时才调用 UPMS 完整导出分页。

- * - * @param query 查询条件 - * @param queryDates 查询范围内按升序排列的全部日期 - * @return 携带完整用户主资料和身份资料的预约导出行 - */ - private List listAllFilteredExportRows(HardCanteenUserApptQueryDto query, - List queryDates) { - List users = fetchAllExportUsers(query); - return filterRows(buildRows(users, query, queryDates), query); - } /** * 在没有关联状态筛选时,按“用户在前、日期在后”的稳定顺序查询当前结果页。 @@ -797,23 +509,6 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ return response.getData(); } - /** - * 调用 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 用户分页条件。 @@ -853,30 +548,6 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ return users; } - /** - * 分页遍历完整用户导出资料,供兼容同步导出使用。 - * - *

管理端默认走异步分批导出;该方法仅保持原同步接口与新二级表头字段一致。

- * - * @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; - } /** * 将用户列表与日期范围内的预约订单按用户、日期左关联,每个用户每天至少生成一行。 @@ -893,6 +564,23 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ } 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) @@ -955,6 +643,27 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ 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; + } + /** * 构造用户预约列表、候选用户预查询和导出明细共用的订单条件。 * @@ -1029,6 +738,226 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ return detail; } + /** + * 统计查询的内存聚合器,按用户日期、餐段和菜品三个层级累计指标。 + */ + private static final class StatisticsBucket { + + /** 用户-日期行总数。 */ + private long userCount; + + /** 已订餐用户-日期行数。 */ + private long reservedUserCount; + + /** 未订餐用户-日期行数。 */ + private long unreservedUserCount; + + /** 有效预约订单数。 */ + private long orderCount; + + /** 菜品总份数。 */ + private long dishCount; + + /** 预约总价,单位为分。 */ + private long totalPrice; + + /** 去重用户主键集合。 */ + private final Set uniqueUserIds = new HashSet<>(); + + /** 餐段编码到餐段聚合结果的有序映射。 */ + private final Map meals = new LinkedHashMap<>(); + + /** 餐段和菜品名称到菜品聚合结果的有序映射。 */ + private final Map dishes = new LinkedHashMap<>(); + + /** + * 累加一条用户-日期预约行及其订单商品快照。 + * + * @param row 用户-日期预约行 + * @param orderMap 订单主键到订单对象的映射 + * @param carMap 父购物车主键到商品快照的映射 + */ + private void addRow(HardCanteenUserApptVo row, Map orderMap, + Map> carMap) { + userCount++; + if (row.getUserId() != null) { + uniqueUserIds.add(row.getUserId()); + } + List rowOrders = row.getOrders() == null ? List.of() : row.getOrders(); + if (rowOrders.isEmpty()) { + unreservedUserCount++; + return; + } + reservedUserCount++; + Set rowOrderIds = new HashSet<>(); + for (HardCanteenUserApptOrderVo rowOrder : rowOrders) { + Long orderId = rowOrder.getId(); + HardOrderVo order = orderId == null ? null : orderMap.get(orderId); + if (orderId != null && rowOrderIds.add(orderId)) { + orderCount++; + totalPrice += number(rowOrder.getTotalPrice()); + } + String hourType = normalize(rowOrder.getHourType(), "未标记餐段"); + MealStatisticsAccumulator meal = meals.computeIfAbsent(hourType, + key -> new MealStatisticsAccumulator(key)); + if (orderId != null && meal.orderIds.add(orderId)) { + meal.reservedUserDateKeys.add(row.getUserId() + "_" + row.getApptUseDate()); + meal.orderCount++; + meal.totalPrice += number(rowOrder.getTotalPrice()); + } + List cars = order == null ? List.of() : carMap.getOrDefault(order.getParentCarId(), List.of()); + for (HardCarEntity car : cars) { + long count = number(car.getProductCount()); + dishCount += count; + meal.dishCount += count; + String productName = normalize(car.getProductName(), "未命名菜品"); + String dishKey = hourType + "\u0000" + productName; + DishStatisticsAccumulator dish = dishes.computeIfAbsent(dishKey, + key -> new DishStatisticsAccumulator(hourType, productName)); + dish.productCount += count; + dish.totalPrice += number(car.getTotalPrice()); + if (orderId != null) { + dish.orderIds.add(orderId); + } + } + } + } + + /** + * 转换为总计汇总响应。 + * + * @return 总计统计数据 + */ + private HardCanteenUserApptStatisticsSummaryVo toSummary() { + HardCanteenUserApptStatisticsSummaryVo summary = new HardCanteenUserApptStatisticsSummaryVo(); + summary.setUserCount(userCount); + summary.setUniqueUserCount((long) uniqueUserIds.size()); + summary.setReservedUserCount(reservedUserCount); + summary.setUnreservedUserCount(unreservedUserCount); + summary.setOrderCount(orderCount); + summary.setDishCount(dishCount); + summary.setTotalPrice(totalPrice); + summary.setMeals(meals.values().stream().map(MealStatisticsAccumulator::toVo).toList()); + summary.setDishes(dishes.values().stream().map(DishStatisticsAccumulator::toVo).toList()); + return summary; + } + + /** + * 转换为每日统计响应。 + * + * @param date 用餐日期 + * @return 每日统计数据 + */ + private HardCanteenUserApptDailyStatisticsVo toDaily(LocalDate date) { + HardCanteenUserApptDailyStatisticsVo daily = new HardCanteenUserApptDailyStatisticsVo(); + daily.setDate(date); + HardCanteenUserApptStatisticsSummaryVo summary = toSummary(); + daily.setUserCount(summary.getUserCount()); + daily.setUniqueUserCount(summary.getUniqueUserCount()); + daily.setReservedUserCount(summary.getReservedUserCount()); + daily.setUnreservedUserCount(summary.getUnreservedUserCount()); + daily.setOrderCount(summary.getOrderCount()); + daily.setDishCount(summary.getDishCount()); + daily.setTotalPrice(summary.getTotalPrice()); + daily.setMeals(summary.getMeals()); + daily.setDishes(summary.getDishes()); + return daily; + } + + /** 将可能为空的数值安全归一为零。 */ + private static long number(Number value) { + return value == null ? 0L : value.longValue(); + } + + /** 将可能为空的名称安全归一为统计展示文案。 */ + private static String normalize(String value, String fallback) { + return StrUtil.isBlank(value) ? fallback : value; + } + } + + /** 餐段统计的可变聚合状态。 */ + private static final class MealStatisticsAccumulator { + + /** 餐段编码或兼容展示文案。 */ + private final String hourType; + + /** 餐段内去重的用户-用餐日期键,范围总计按订餐人次累计并可与每日结果相加。 */ + private final Set reservedUserDateKeys = new HashSet<>(); + + /** 餐段内去重的订单主键。 */ + private final Set orderIds = new HashSet<>(); + + /** 餐段菜品份数。 */ + private long dishCount; + + /** 餐段预约订单数。 */ + private long orderCount; + + /** 餐段预约总价,单位为分。 */ + private long totalPrice; + + /** + * 创建指定餐段的聚合状态。 + * + * @param hourType 餐段协议编码或历史兼容文案 + */ + private MealStatisticsAccumulator(String hourType) { + this.hourType = hourType; + } + + /** 转换为餐段统计响应。 */ + private HardCanteenUserApptMealStatisticsVo toVo() { + HardCanteenUserApptMealStatisticsVo vo = new HardCanteenUserApptMealStatisticsVo(); + vo.setHourType(hourType); + vo.setReservedUserCount((long) reservedUserDateKeys.size()); + vo.setOrderCount(orderCount); + vo.setDishCount(dishCount); + vo.setTotalPrice(totalPrice); + return vo; + } + } + + /** 菜品统计的可变聚合状态。 */ + private static final class DishStatisticsAccumulator { + + /** 餐段编码或兼容展示文案。 */ + private final String hourType; + + /** 下单时固化的菜品名称。 */ + private final String productName; + + /** 菜品快照份数。 */ + private long productCount; + + /** 菜品快照总价,单位为分。 */ + private long totalPrice; + + /** 包含该菜品的去重订单主键。 */ + private final Set orderIds = new HashSet<>(); + + /** + * 创建指定餐段和菜品名称的聚合状态。 + * + * @param hourType 餐段协议编码或历史兼容文案 + * @param productName 下单时保存的菜品名称 + */ + private DishStatisticsAccumulator(String hourType, String productName) { + this.hourType = hourType; + this.productName = productName; + } + + /** 转换为菜品统计响应。 */ + private HardCanteenUserApptDishStatisticsVo toVo() { + HardCanteenUserApptDishStatisticsVo vo = new HardCanteenUserApptDishStatisticsVo(); + vo.setHourType(hourType); + vo.setProductName(productName); + vo.setProductCount(productCount); + vo.setOrderCount((long) orderIds.size()); + vo.setTotalPrice(totalPrice); + return vo; + } + } + /** * 汇总预约明细的商品总价,历史空金额按零分计算。 * @@ -1110,336 +1039,19 @@ public class HardCanteenApptTotalServiceImpl implements HardCanteenApptTotalServ 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)); - } - } } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java index e8b94040..0c0a55fb 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java @@ -172,7 +172,7 @@ public class HardOrderServiceImpl extends ServiceImpl contexts = loadDailyMenuReserveGroupContexts(payerUserId, groups); Long merchantId = contexts.get(0).parentCar().getMerchantId(); MchManageVo merchant = validateDailyMenuReserveMerchant(merchantId); - MchAutoRefundConfigVo autoRefundConfig = loadDailyMenuAutoRefundConfig(merchantId); validateMerchantReserveDinerPermissions(payerUserId, dinerUserIds, merchant); - validateDailyMenuCartProducts(merchantId, contexts); + Map inventorySnapshot = validateDailyMenuCartProducts(merchantId, contexts, dinerUserIds.size()); validateDailyMenuSingleProductLimit(merchant, contexts); List dinerUsers = Optional.ofNullable(userService.getInnerUserById(dinerUserIds)) @@ -1462,6 +1464,9 @@ public class HardOrderServiceImpl extends ServiceImpl validateSingleReservationOnlyPerMeal(merchant, dinerUserIds, context.reservation())); List createdOrders = new ArrayList<>(); + Map reservationByOrderId = new LinkedHashMap<>(); + String inventoryBizId = null; + boolean inventoryOperationAttempted = false; try { for (DailyMenuReserveGroupContext context : contexts) { String subject = buildDailyMenuReserveOrderSubject(context.carList()); @@ -1472,20 +1477,27 @@ public class HardOrderServiceImpl extends ServiceImpl createdOrders) { + private void registerDailyMenuRollbackCompensation(List createdOrders, + List contexts, + int dinerCount, + Map inventorySnapshot, + String inventoryBizId) { if (!TransactionSynchronizationManager.isSynchronizationActive()) { throw new RuntimeException("订餐事务状态异常,请稍后重试"); } List rollbackOrders = List.copyOf(createdOrders); + List rollbackContexts = List.copyOf(contexts); + Map rollbackInventory = Map.copyOf(inventorySnapshot); TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { /** - * 本地事务明确回滚时补偿远程钱包;状态未知时保留流水,避免数据库实际已提交却误退款。 + * 本地事务明确回滚时补偿远程库存和钱包;状态未知时不主动反向操作,避免数据库实际已提交却误恢复。 * * @param status Spring 事务完成状态 */ @Override public void afterCompletion(int status) { if (status == TransactionSynchronization.STATUS_ROLLED_BACK) { - compensateReserveOrderWallets(rollbackOrders); + compensateDailyMenuReserveBatch(rollbackOrders, rollbackContexts, dinerCount, + rollbackInventory, inventoryBizId); } } }); @@ -1741,18 +1764,27 @@ public class HardOrderServiceImpl extends ServiceImpl 168)) { + return expiredEnabled ? config : null; + } + if (expiredEnabled && (config.getExpiredReserveRefundDelayHours() == null + || config.getExpiredReserveRefundDelayHours() < 1 + || config.getExpiredReserveRefundDelayHours() > 168)) { return null; } return config; @@ -1773,18 +1805,31 @@ public class HardOrderServiceImpl extends ServiceImpl= 1 + && config.getExpiredReserveRefundDelayHours() <= 168 + && reservation.usageEndDateTime() != null + && config.getId() != null) { + order.setExpiredRefundConfigId(config.getId()); + order.setExpiredRefundUsageEndTime(reservation.usageEndDateTime()); + order.setExpiredRefundDeadline(reservation.usageEndDateTime().plusHours(config.getExpiredReserveRefundDelayHours())); + order.setExpiredRefundRestoreInventory(Objects.equals(config.getExpiredReserveRefundRestoreInventory(), YesNoEnum.YES.getCode()) + ? YesNoEnum.YES.getCode() : YesNoEnum.NO.getCode()); } } @@ -1853,12 +1898,17 @@ public class HardOrderServiceImpl extends ServiceImpl contexts) { + private Map validateDailyMenuCartProducts(Long merchantId, + List contexts, + int dinerCount) { List productIds = contexts.stream() .flatMap(context -> context.carList().stream()) .map(HardCarEntity::getProductId) @@ -1873,6 +1923,12 @@ public class HardOrderServiceImpl extends ServiceImpl product.getId() != null) .collect(Collectors.toMap(CanteenSpuEntity::getId, Function.identity(), (first, second) -> first)); + Map requiredStock = contexts.stream() + .flatMap(context -> context.carList().stream()) + .collect(Collectors.toMap(HardCarEntity::getProductId, + car -> Math.multiplyExact(car.getProductCount(), dinerCount), + Math::addExact, + LinkedHashMap::new)); for (DailyMenuReserveGroupContext context : contexts) { for (HardCarEntity car : context.carList()) { CanteenSpuEntity product = productMap.get(car.getProductId()); @@ -1883,6 +1939,10 @@ public class HardOrderServiceImpl extends ServiceImpl entry : requiredStock.entrySet()) { + CanteenSpuEntity product = productMap.get(entry.getKey()); + if (product.getStock() == null || product.getStock() < entry.getValue()) { + throw new RuntimeException(product.getName() + "库存不足"); + } + } + return requiredStock.keySet().stream().collect(Collectors.toMap(Function.identity(), + productId -> productMap.get(productId).getStock(), (first, second) -> first, LinkedHashMap::new)); } /** @@ -1927,7 +1995,7 @@ public class HardOrderServiceImpl extends ServiceImpl contexts, int dinerCount, + boolean reduce, Map inventorySnapshot, + String inventoryBizId) { + // ROLLBACK 必须原样携带 APPLY 的负增量,由食堂服务负责取反,不能在调用端提前改成正数。 + int factor = -dinerCount; + Map stockChanges = contexts.stream() + .flatMap(context -> context.carList().stream()) + .collect(Collectors.toMap(HardCarEntity::getProductId, + car -> Math.multiplyExact(car.getProductCount(), factor), + Math::addExact, + LinkedHashMap::new)); + CanteenSpuUpdateStockReqDTO stockDto = new CanteenSpuUpdateStockReqDTO(); + stockDto.setBizId(inventoryBizId); + stockDto.setOperation(reduce ? "APPLY" : "ROLLBACK"); + stockDto.setItems(stockChanges.entrySet().stream().map(entry -> { + CanteenSpuUpdateStockReqDTO.Item item = new CanteenSpuUpdateStockReqDTO.Item(); + item.setId(entry.getKey()); + item.setIncrCount(entry.getValue()); + item.setBeforeStock(inventorySnapshot.get(entry.getKey())); + return item; + }).toList()); + R result = remoteCanteenSpuService.updateSkuStockIdempotent(stockDto); + if (result == null || !result.isOk()) { + throw new RuntimeException(reduce ? "每日菜谱库存不足或扣减失败" : "每日菜谱库存恢复失败"); + } + } + + /** + * 使用本次批次首个订单 ID 构造每日菜谱库存业务号,避免同一购物车重试命中历史回滚状态。 + * + * @param createdOrders 已分配 ID 的本次每日菜谱订单 + * @return 本批次库存幂等业务号 + */ + private String buildDailyMenuReserveInventoryBizId(List createdOrders) { + Long firstOrderId = createdOrders.stream() + .map(HardOrderEntity::getId) + .filter(Objects::nonNull) + .findFirst() + .orElseThrow(() -> new RuntimeException("每日菜谱订单批次数据异常")); + return "HARD_DAILY_MENU_ORDER_BATCH:" + firstOrderId; + } + + /** + * 补偿每日菜谱批次已尝试的基础 SPU 库存和钱包操作;远程超时按同一业务号重复调用也不会重复恢复。 + * + * @param createdOrders 已创建并可能已产生钱包支付流水的订单 + * @param contexts 原始日期餐段购物车上下文 + * @param dinerCount 实际用餐人数 + * @param inventorySnapshot 扣库存前的基础 SPU 库存快照 + * @param inventoryBizId 已尝试扣库存时的业务号;未发起库存请求时为空 + */ + private void compensateDailyMenuReserveBatch(List createdOrders, + List contexts, + int dinerCount, + Map inventorySnapshot, + String inventoryBizId) { + if (StrUtil.isNotBlank(inventoryBizId)) { + try { + updateDailyMenuReserveInventory(contexts, dinerCount, false, inventorySnapshot, inventoryBizId); + } catch (Exception compensationException) { + log.error("|{}|每日菜谱库存补偿失败,inventoryBizId:|{}|", TAG, inventoryBizId, compensationException); + } + } + compensateReserveOrderWallets(createdOrders); } /** @@ -3709,15 +3866,160 @@ public class HardOrderServiceImpl extends ServiceImpl refundCarList, + YesNoEnum isReduce, Long afterSaleId) { + if (ObjectUtil.equals(isReduce, YesNoEnum.NO) && afterSaleId == null) { + throw new RuntimeException("每日菜谱退款库存缺少售后单 ID"); + } + List carList = hardCarService.getByParentCarId(order.getParentCarId()); + if (ObjectUtil.isEmpty(carList)) { + throw new RuntimeException("每日菜谱库存操作失败,购物车不存在"); + } + if (ObjectUtil.isNotEmpty(refundCarList)) { + carList = filterRefundInventoryCars(carList, refundCarList); + } + if (ObjectUtil.isEmpty(carList)) { + throw new RuntimeException("每日菜谱库存操作失败,退款商品不存在"); + } + + int factor = ObjectUtil.equals(isReduce, YesNoEnum.YES) ? -1 : 1; + Map stockChanges = carList.stream().collect(Collectors.toMap( + HardCarEntity::getProductId, + car -> Math.multiplyExact(car.getProductCount(), factor), + Math::addExact, + LinkedHashMap::new)); + R> productResult = remoteCanteenSpuService + .getSpuListByIds(new ArrayList<>(stockChanges.keySet())); + if (productResult == null || !productResult.isOk() || productResult.getData() == null) { + throw new RuntimeException(productResult == null ? "查询每日菜谱库存无响应" + : StrUtil.blankToDefault(productResult.getMsg(), "查询每日菜谱库存失败")); + } + Map productMap = productResult.getData().stream() + .filter(Objects::nonNull) + .filter(product -> product.getId() != null) + .collect(Collectors.toMap(CanteenSpuEntity::getId, Function.identity(), (first, second) -> first)); + Map inventorySnapshot = new LinkedHashMap<>(); + for (Map.Entry entry : stockChanges.entrySet()) { + CanteenSpuEntity product = productMap.get(entry.getKey()); + if (product == null || !Objects.equals(product.getMerchantId(), order.getMerchantId())) { + throw new RuntimeException("每日菜谱商品不存在或不属于当前商户"); + } + int productType = product.getSpuType() == null ? 1 : product.getSpuType(); + if (productType != 1 && productType != 2) { + throw new RuntimeException(product.getName() + "商品模式异常,请联系商户"); + } + if (product.getStock() == null || entry.getValue() < 0 && product.getStock() < Math.abs(entry.getValue())) { + throw new RuntimeException(product.getName() + "库存不足"); + } + inventorySnapshot.put(entry.getKey(), product.getStock()); + } + + // 售后单 ID 隔离多次部分退款;同一售后单的支付重试必须复用同一业务号,避免重复恢复库存。 + String inventoryBizId = afterSaleId == null + ? "HARD_DAILY_MENU_ORDER:" + order.getId() + : "HARD_DAILY_MENU_AFTER_SALE:" + afterSaleId; + registerDailyMenuInventoryRollback(stockChanges, inventorySnapshot, inventoryBizId); + applyDailyMenuInventoryChange(stockChanges, inventorySnapshot, inventoryBizId, "APPLY"); + } + + /** + * 当前 Hardware 本地事务回滚时反向撤销已经尝试的每日菜谱库存操作,覆盖远程成功但本地提交失败的窗口。 + * + * @param stockChanges 原 APPLY 使用的基础 SPU 库存增量 + * @param inventorySnapshot 操作前库存快照 + * @param inventoryBizId 本次订单或售后库存业务号 + */ + private void registerDailyMenuInventoryRollback(Map stockChanges, + Map inventorySnapshot, + String inventoryBizId) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + throw new RuntimeException("库存事务状态异常,请稍后重试"); + } + Map rollbackChanges = Map.copyOf(stockChanges); + Map rollbackSnapshot = Map.copyOf(inventorySnapshot); + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + /** + * 本地事务明确回滚时调用基础 SPU 幂等反向操作;重复回调由业务号状态兜底。 + * + * @param status Spring 事务完成状态 + */ + @Override + public void afterCompletion(int status) { + if (status != TransactionSynchronization.STATUS_ROLLED_BACK) { + return; + } + try { + applyDailyMenuInventoryChange(rollbackChanges, rollbackSnapshot, inventoryBizId, "ROLLBACK"); + } catch (Exception compensationException) { + log.error("|{}|每日菜谱订单库存回滚失败,inventoryBizId:|{}|", TAG, inventoryBizId, compensationException); + } + } + }); + } + + /** + * 将基础 SPU 增量转换为食堂库存协议并校验远程业务响应,APPLY 与 ROLLBACK 始终复用原增量和快照。 + * + * @param stockChanges SPU ID 与原始库存增量 + * @param inventorySnapshot 操作前库存快照 + * @param inventoryBizId 稳定库存业务号 + * @param operation APPLY 应用或 ROLLBACK 反向撤销 + */ + private void applyDailyMenuInventoryChange(Map stockChanges, + Map inventorySnapshot, + String inventoryBizId, + String operation) { + CanteenSpuUpdateStockReqDTO dto = new CanteenSpuUpdateStockReqDTO(); + dto.setBizId(inventoryBizId); + dto.setOperation(operation); + dto.setItems(stockChanges.entrySet().stream().map(entry -> { + CanteenSpuUpdateStockReqDTO.Item item = new CanteenSpuUpdateStockReqDTO.Item(); + item.setId(entry.getKey()); + item.setIncrCount(entry.getValue()); + item.setBeforeStock(inventorySnapshot.get(entry.getKey())); + return item; + }).toList()); + R result = remoteCanteenSpuService.updateSkuStockIdempotent(dto); + if (result == null || !result.isOk()) { + throw new RuntimeException(result == null ? "每日菜谱库存服务无响应" + : StrUtil.blankToDefault(result.getMsg(), "每日菜谱库存操作失败")); + } + } + @Override @Transactional(rollbackFor = Exception.class) public void fastenerInventory(HardOrderEntity order, YesNoEnum isReduce) { - getSelf().fastenerInventory(order, null, isReduce); + getSelf().fastenerInventory(order, null, isReduce, null); } @Override @Transactional(rollbackFor = Exception.class) public void fastenerInventory(HardOrderEntity order, List refundCarList, YesNoEnum isReduce) { + getSelf().fastenerInventory(order, refundCarList, isReduce, null); + } + + /** + * 按订单商品来源执行库存变更。每日菜谱使用基础 SPU 幂等接口,售后单 ID 用于隔离多次部分退款; + * 其他商品来源继续沿用原库存服务和协议,避免改变既有在线订餐、超市与商城行为。 + * + * @param order 原订单 + * @param refundCarList 本次退款商品;为空表示使用整单购物车 + * @param isReduce 是否扣减库存 + * @param afterSaleId 本次售后单 ID,非退款或历史调用可为空 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public void fastenerInventory(HardOrderEntity order, List refundCarList, YesNoEnum isReduce, + Long afterSaleId) { HardTypeEnum hardTypeEnum = HardTypeEnum.valueOfByType(order.getSysType()); HardOrderTypeEnum orderTypeEnum = HardOrderTypeEnum.valueOfByType(order.getOrderType()); if (!ObjectUtil.isAllNotEmpty(hardTypeEnum, orderTypeEnum)) return; @@ -3726,9 +4028,9 @@ public class HardOrderServiceImpl extends ServiceImpl + + + + + + diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarMapper.xml index 3a11b21c..6dfadd69 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarMapper.xml +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarMapper.xml @@ -336,6 +336,138 @@ order by DATE_FORMAT(o.appt_use_date, '%Y-%m-%d') desc, o.hour_type + + + + + + + + +