feat:商品预约导出

feat:个人菜谱预约统计
feat:预定扣库存
feat:过期订单自动退款
This commit is contained in:
dzc committed 2026-08-23 10:59:54 +08:00
1 parent a859b0ab19
commit e1cf5d5b90
43 files changed
+4060 -845

No files matched your search

@@ -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 '稳定钱包退款业务号,过期任务按订单生成';
@@ -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查询菜品
@@ -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;
/**
@@ -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);
}
/**
* 分页查询
*
@@ -13,11 +13,11 @@ public interface CanteenSpuMapper extends PjcloudBaseMapper<CanteenSpuEntity> {
* 更新商品 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<CanteenSpuEntity> {
// 执行更新
LambdaUpdateWrapper<CanteenSpuEntity> updateWrapper = new LambdaUpdateWrapper<CanteenSpuEntity>()
.setSql(sql)
.eq(CanteenSpuEntity::getId, id);
.eq(CanteenSpuEntity::getId, id)
// 扣减条件必须落到同一条 UPDATE,避免并发请求都通过前置查询后把库存扣成负数。
.ge(incrCount < 0, CanteenSpuEntity::getStock, Math.abs(incrCount));
return update(null, updateWrapper);
}
}
/**
* 仅在数据库当前库存仍等于业务快照时更新库存和销量,避免不同业务号并发操作同一 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<CanteenSpuEntity> updateWrapper = new LambdaUpdateWrapper<CanteenSpuEntity>()
.setSql(sql)
.eq(CanteenSpuEntity::getId, id)
.eq(CanteenSpuEntity::getStock, expectedStock)
.ge(incrCount < 0, CanteenSpuEntity::getStock, Math.abs(incrCount));
return update(null, updateWrapper);
}
}
@@ -32,6 +32,14 @@ public interface CanteenSpuService extends IService<CanteenSpuEntity> {
*/
R updateSkuStock(CanteenSpuUpdateStockReqDTO updateStockReqDTO);
/**
* 按业务号原子应用或回滚基础菜品库存,供每日菜谱下单和退款跨服务调用。
*
* @param updateStockReqDTO 商品库存增量、操作前快照、业务号和操作类型
* @return 成功、幂等命中或库存校验失败的统一响应
*/
R updateSkuStockIdempotent(CanteenSpuUpdateStockReqDTO updateStockReqDTO);
/**
* 根据菜品分类id获取菜品信息
@@ -106,4 +114,4 @@ public interface CanteenSpuService extends IService<CanteenSpuEntity> {
* 批量加载套餐组成(App/每日菜谱展示)
*/
Map<Long, List<OnlineSpuComboItemVO>> loadComboItemVos(List<Long> comboSpuIds);
}
}
@@ -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<CanteenSpuMapper, CanteenSpuEntity> 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<CanteenSpuMapper, Canteen
}
@Override
@Transactional
@Transactional(rollbackFor = Exception.class)
public R updateSkuStock(CanteenSpuUpdateStockReqDTO updateStockReqDTO) {
// 更新 SKU 库存
updateStockReqDTO.getItems().forEach(item -> {
// 查询当前库存
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<CanteenSpuEntity> canteenSpuEntities = baseMapper.selectListByScope(new LambdaQueryWrapper<CanteenSpuEntity>()
@@ -481,4 +620,4 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
return list;
}
}
}
@@ -33,6 +33,9 @@ public enum HardAfterSaleOperateTypeEnum {
/** 系统根据订单快照和商户配置免审完成退款。 */
SYSTEM_AUTO_REFUND("31", "系统自动退款"),
/** 系统按每日菜谱预约过期快照创建整单退款。 */
SYSTEM_EXPIRED_RESERVE_REFUND("32", "系统过期预约退款"),
/** 会员取消尚未审核的退款申请。 */
MEMBER_CANCEL("40", "会员取消退款"),
;
@@ -21,7 +21,12 @@ public enum HardAutoRefundTriggerTypeEnum {
/**
* 管理端技术人员对耗尽任务执行的一次额外尝试。
*/
MANUAL("30", "人工技术重试", false);
MANUAL("30", "人工技术重试", false),
/**
* 管理端业务人员对已到期候选订单确认后执行的逐单退款,不复用技术重试权限。
*/
EXPIRED_MANUAL("40", "后台逐单过期退款", false);
/**
* 数据库存储的稳定触发编码。
@@ -43,6 +43,10 @@ public class HardAutoRefundTaskPageDto {
@Schema(description = "任务状态")
private String status;
/** 任务场景编码;候选列表固定筛选过期预约任务。 */
@Schema(description = "任务场景")
private String taskType;
/**
* 任务创建时间闭区间,数组长度应为 2。
*/
@@ -26,6 +26,12 @@ public class HardCarDto extends HardCarEntity implements BaseIsIncludeTreeInterf
private Long merchantId;
/** 订单类型协议编码;详情查询传预约编码时仅统计预约订单,其他场景为空表示不限定。 */
private String orderType;
/** 是否只保留关联预约规则或预约记录的订单;为空或 false 时不增加预约关联限制。 */
private Boolean apptIdOrApptRuleIdNotNull;
private List<Long> mchIds;
/** 预计使用日期范围。 */
@@ -18,7 +18,8 @@ import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* Hardware 自动退款持久化任务。一个售后单最多创建一条任务,失败后由该记录持续驱动恢复。
* Hardware 自动退款持久化任务。售后免审任务创建时绑定售后单,过期预约任务在实际执行前允许暂不绑定,
* 失败后由该记录持续驱动恢复。
*/
@Data
@Builder
@@ -38,11 +39,15 @@ public class HardAutoRefundTaskEntity extends Model<HardAutoRefundTaskEntity> {
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<HardAutoRefundTaskEntity> {
@Schema(description = "免审资格截止时间")
private LocalDateTime eligibilityDeadline;
/** 过期预约任务的订单快照触发时间;旧售后任务为空。 */
@Schema(description = "过期退款触发时间")
private LocalDateTime triggerTime;
/** 任务创建时快照的退库存开关,1恢复、0不恢复。 */
@Schema(description = "退库存快照")
private String restoreInventory;
/**
* 当前任务状态编码,对应 HardAutoRefundTaskStatusEnum。
*/
@@ -159,6 +159,22 @@ public class HardOrderEntity extends Model<HardOrderEntity> {
@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;
/**
* 订单设备类型
*/
@@ -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;
}
@@ -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<Page<HardAutoRefundTaskVo>> expiredCandidates(@ParameterObject Page<HardAutoRefundTaskEntity> page,
@ParameterObject HardAutoRefundTaskPageDto dto) {
List<Long> scopedMchIds = listScopedMerchantIds();
if (scopedMchIds.isEmpty()) {
Page<HardAutoRefundTaskVo> 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<Boolean> expiredRefund(@PathVariable Long id) {
validateTaskScope(id);
taskService.manualExpiredRefund(id, SecurityUtils.getUser().getId());
return R.ok(true);
}
/**
* 查询指定任务全部只读尝试日志。
*
@@ -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<HardCanteenUserApptStatisticsVo> 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<AsyncTaskProgressVO> 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<HardCanteenUserApptExcelVo> 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<AsyncTaskProgressVO> 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<HardCanteenProductApptExcelVo> productReservationExport(@RequestBody HardOrderDto query) {
applyCanteenMerchantScope(query);
return canteenApptTotalExportService.productReservationExport(query);
}
/**
* 为后台菜品预约导出补充当前操作者可见的食堂范围。
*
* <p>分页查询接口已有相同约束;导出任务在异步线程执行前必须冻结这份范围,避免未选择食堂时
* 直接按空 {@code mchIds} 查询到当前权限之外的商户数据。</p>
*
* @param query 菜品预约导出的请求条件;为空时保留给导出服务做参数校验
*/
private void applyCanteenMerchantScope(HardOrderDto query) {
if (query == null) {
return;
}
MchManageDto mchManageDto = new MchManageDto();
mchManageDto.setTypesList(List.of(MerchantTypeEnum.CANTEEN.getType()));
R<List<MchManageVo>> scopeResponse = remoteMchService.scopeList(mchManageDto);
List<MchManageVo> mchList = scopeResponse == null ? null : scopeResponse.getData();
List<Long> 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<Long> allowedMerchantIds = query.getMchIds().stream().filter(visibleMerchantIds::contains).toList();
query.setMchIds(ObjectUtil.isEmpty(allowedMerchantIds)
? List.of(NO_VISIBLE_CANTEEN_ID) : allowedMerchantIds);
}
}
@@ -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<HardAutoRefu
.eq(dto.getOrderId() != null, HardAutoRefundTaskEntity::getOrderId, dto.getOrderId())
.eq(dto.getMchId() != null, HardAutoRefundTaskEntity::getMchId, dto.getMchId())
.eq(dto.getStatus() != null && !dto.getStatus().isBlank(), HardAutoRefundTaskEntity::getStatus, dto.getStatus())
.eq(dto.getTaskType() != null && !dto.getTaskType().isBlank(), HardAutoRefundTaskEntity::getTaskType, dto.getTaskType())
.in(dto.getMchIds() != null && !dto.getMchIds().isEmpty(), HardAutoRefundTaskEntity::getMchId, dto.getMchIds())
.between(dto.getCreateTimeList() != null && dto.getCreateTimeList().length == 2,
HardAutoRefundTaskEntity::getCreateTime,
@@ -48,4 +52,16 @@ public interface HardAutoRefundTaskMapper extends PjcloudBaseMapper<HardAutoRefu
.orderByDesc(HardAutoRefundTaskEntity::getId);
return selectPage(page, wrapper);
}
/**
* 按订单、父购物车和售后状态联合过滤可整单退款的过期预约候选,分页总数由数据库直接计算。
*
* @param page 分页参数
* @param dto 管理端筛选及服务端注入的商户范围
* @param now 当前服务端时间,候选必须已到期
* @return 仅包含未尝试、未关联售后且仍满足业务资格的任务
*/
Page<HardAutoRefundTaskEntity> pageExpiredCandidates(Page<HardAutoRefundTaskEntity> page,
@Param("dto") HardAutoRefundTaskPageDto dto,
@Param("now") LocalDateTime now);
}
@@ -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<HardCarEntity> {
Page<HardCanteenProductTotalVo> productTotalByApptUseDate(Page page, @Param("dto") HardOrderDto dto, DataScope dataScope);
/** 按预约日期和餐段查询导出所需的日期餐段总计。 */
List<HardCanteenProductTotalVo> listApptExportSummary(@Param("dto") HardOrderDto dto);
/** 按预约日期、餐段、商户和部门查询导出商户部门统计。 */
List<HardCanteenProductApptExportRecord> listApptExportMerchant(@Param("dto") HardOrderDto dto);
/** 按预约日期、餐段、商户、部门和商品查询导出商品明细。 */
List<HardCanteenProductApptExportRecord> listApptExportProduct(@Param("dto") HardOrderDto dto);
Page<HardCarVo> proudctQuery(Page page, @Param("dto") HardCarDto dto);
/**
@@ -48,6 +58,22 @@ public interface HardCarMapper extends PjcloudBaseMapper<HardCarEntity> {
List<HardCanteenProductTotalVo> listByHourType(@Param("dto") HardOrderDto dto);
/**
* 按预约订单父购物车主键批量读取商品快照,供预约统计计算真实菜品份数。
*
* @param parentCarIds 预约订单关联的父购物车主键集合
* @return 未删除的购物车商品快照;传入空集合时返回空列表
*/
default List<HardCarEntity> listByParentCarIds(List<Long> parentCarIds) {
if (parentCarIds == null || parentCarIds.isEmpty()) {
return List.of();
}
return selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<HardCarEntity>()
.in(HardCarEntity::getParentCarId, parentCarIds)
.orderByAsc(HardCarEntity::getParentCarId)
.orderByAsc(HardCarEntity::getId));
}
/**
* 菜品销量按照分类查询
*/
@@ -62,4 +88,4 @@ public interface HardCarMapper extends PjcloudBaseMapper<HardCarEntity> {
* 个人菜品喜好排行
*/
List<HardAppProductLikeTopVo> productLikeTop(@Param("dto") HardAppProductLikeTopDto dto);
}
}
@@ -72,6 +72,33 @@ public interface HardAfterSaleService extends IService<HardAfterSaleEntity> {
*/
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);
/**
* 同意售后
*/
@@ -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) {
}
}
@@ -27,6 +27,14 @@ public interface HardAutoRefundTaskService extends IService<HardAutoRefundTaskEn
*/
HardAutoRefundTaskEntity tryCreateTask(HardOrderEntity order, HardAfterSaleEntity afterSale);
/**
* 在每日菜谱订单支付成功事务内按订单过期快照创建任务;同一订单和场景保持幂等。
*
* @param order 已更新为支付成功且带过期退款快照的订单
* @return 已存在或新建的任务;订单不具备快照时返回空
*/
HardAutoRefundTaskEntity createExpiredReserveTask(HardOrderEntity order);
/**
* 注册事务提交后的同步首次执行;回调内部吞掉资金异常并持久化重试状态。
*
@@ -52,6 +60,9 @@ public interface HardAutoRefundTaskService extends IService<HardAutoRefundTaskEn
*/
Page<HardAutoRefundTaskVo> pageTasks(Page<HardAutoRefundTaskEntity> page, HardAutoRefundTaskPageDto dto);
/** 查询已到期且仍可整单退款的每日菜谱预约候选任务。 */
Page<HardAutoRefundTaskVo> pageExpiredCandidates(Page<HardAutoRefundTaskEntity> page, HardAutoRefundTaskPageDto dto);
/**
* 查询任务的只读尝试日志,按尝试序号升序返回。
*
@@ -68,6 +79,14 @@ public interface HardAutoRefundTaskService extends IService<HardAutoRefundTaskEn
*/
void manualRetry(Long taskId, Long operatorUserId);
/**
* 管理端有业务退款权限时逐单执行过期预约整单退款,不依赖技术重试权限或当前自动开关。
*
* @param taskId 过期预约任务 ID
* @param operatorUserId 当前后台操作用户 ID
*/
void manualExpiredRefund(Long taskId, Long operatorUserId);
/**
* 按售后单查询自动退款任务。
*
@@ -1,14 +1,13 @@
package com.bsd.pjcloud.hard.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
import com.bsd.pjcloud.common.data.datascope.DataScope;
import com.bsd.pjcloud.hard.api.dto.order.HardCanteenUserApptQueryDto;
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.excel.HardCanteenUserApptExcelVo;
import com.bsd.pjcloud.hard.api.vo.order.HardCanteenUserApptStatisticsVo;
import java.time.LocalDate;
import java.util.List;
@@ -47,18 +46,11 @@ public interface HardCanteenApptTotalService {
List<HardCanteenUserApptOrderVo> 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<HardCanteenUserApptExcelVo> userReservationExport(HardCanteenUserApptQueryDto query);
}
@@ -268,6 +268,16 @@ public interface HardOrderService extends IService<HardOrderEntity> {
void fastenerInventory(HardOrderEntity order, List<HardCarEntity> refundCarList, YesNoEnum isReduce);
/**
* 按售后单隔离库存幂等业务号执行扣减或恢复;每日菜谱退款必须传售后单 ID,避免多次部分退款互相覆盖。
*
* @param order 原订单
* @param refundCarList 本次退款商品;为空表示整单商品
* @param isReduce {@link YesNoEnum#YES} 扣库存,{@link YesNoEnum#NO} 恢复库存
* @param afterSaleId 本次售后单 ID,非退款调用可为空
*/
void fastenerInventory(HardOrderEntity order, List<HardCarEntity> refundCarList, YesNoEnum isReduce, Long afterSaleId);
/**
* 核销硬件二维码
*/
@@ -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<HardApptEntity> candidates = hardApptService.list(new LambdaQueryWrapper<HardApptEntity>()
.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,
@@ -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);
@@ -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<HardAfterSaleMapper, H
* @return 已锁定并完成渠道退款的原订单
*/
private HardOrderEntity refundInCurrentTransaction(Long afterSaleId, String prefixStr, Long autoRefundConfigId) {
return refundInCurrentTransaction(afterSaleId, prefixStr, autoRefundConfigId, null);
}
/**
* 在当前事务中调用原支付渠道退款,并携带自动退款任务场景供 Wallet 终审独立开关。
*
* @param afterSaleId 待执行的售后单 ID
* @param prefixStr 退款业务号前缀
* @param autoRefundConfigId 自动退款配置 ID;人工退款为空
* @param autoRefundTaskType 自动退款任务场景;过期预约为 20
* @return 已锁定并完成渠道退款的原订单
*/
private HardOrderEntity refundInCurrentTransaction(Long afterSaleId, String prefixStr, Long autoRefundConfigId,
String autoRefundTaskType) {
HardAfterSaleEntity afterSale = baseMapper.selectById(afterSaleId);
if (ObjectUtil.isEmpty(afterSale)) throw new RuntimeException("售后订单不存在");
HardOrderEntity order = hardOrderMapper.selectByIdForUpdate(afterSale.getOrderId());
@@ -528,7 +543,9 @@ public class HardAfterSaleServiceImpl extends ServiceImpl<HardAfterSaleMapper, H
// 不能用它判断渠道退款是否执行过;重复调用由售后状态、订单锁和售后单 ID 下游幂等共同兜底。
// 生成退款单号
if (ObjectUtil.isEmpty(prefixStr)) prefixStr = "hard";
String refundCode = prefixStr + "_" + order.getId() + "_" + afterSale.getId();
String refundCode = "hard_expired".equals(prefixStr)
? "hard_expired_" + order.getId()
: prefixStr + "_" + order.getId() + "_" + afterSale.getId();
// 查询总的退款金额
Integer refundPrice = afterSale.getRefundPrice();
if (ObjectUtil.isEmpty(refundPrice)) {
@@ -563,7 +580,7 @@ public class HardAfterSaleServiceImpl extends ServiceImpl<HardAfterSaleMapper, H
if (isWalletPay) {
WalletRechargeRefundDto hardRefundDto = WalletRechargeRefundDto.builder().walletId(order.getWalletId())
.orderId(order.getId()).afterSaleId(afterSale.getId()).refundCode(refundCode).reason("订单退款")
.price(refundPrice).autoRefundConfigId(autoRefundConfigId).build();
.price(refundPrice).autoRefundConfigId(autoRefundConfigId).autoRefundTaskType(autoRefundTaskType).build();
R<WalletRechargeVo> 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<HardAfterSaleMapper, H
throw new RuntimeException("订单自动退款快照不匹配");
}
// 每日菜谱库存跨服务变更具备幂等反向补偿能力,必须先恢复库存;其他历史订单保持原有退款顺序。
if (isDailyMenuOrder(order)) {
restoreRefundInventory(order, afterSaleId);
}
refundInCurrentTransaction(afterSaleId, "hard", autoRefundConfigId);
if (!isDailyMenuOrder(order)) {
restoreOrderInventory(order, afterSaleId);
}
LocalDateTime completedTime = LocalDateTime.now();
HardAfterSaleEntity completed = HardAfterSaleEntity.builder().id(afterSaleId)
.status(HardSaleStatusEnum.COMPLETE.getStatus())
@@ -653,15 +677,134 @@ public class HardAfterSaleServiceImpl extends ServiceImpl<HardAfterSaleMapper, H
insertAfterSaleLog(afterSaleId, afterSale.getUserId(), HardAfterSaleOperateTypeEnum.SYSTEM_AUTO_REFUND.getType(),
HardSaleStatusEnum.APPLY.getStatus(), HardSaleStatusEnum.COMPLETE.getStatus(),
HardAfterSaleOperateTypeEnum.SYSTEM_AUTO_REFUND.getContent());
List<HardAfterSaleCarEntity> 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<HardAfterSaleEntity> 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<HardAfterSaleEntity> 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<HardAfterSaleMapper, H
baseMapper.updateById(updateRefundObj);
insertAfterSaleLog(afterSale.getId(), userId, HardAfterSaleOperateTypeEnum.ADMIN_AGREE_APPLY.getType(),
order.getOrderStatus(), order.getOrderStatus(), HardSaleStatusEnum.COMPLETE.getContent());
// 执行退款
// 每日菜谱库存恢复失败时禁止发起退款;渠道退款失败由库存幂等回调撤销本次恢复。
if (isDailyMenuOrder(order)) {
restoreRefundInventory(order, id);
}
getSelf().refund(updateRefundObj.getId());
List<HardAfterSaleCarEntity> 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<HardAfterSaleMapper, H
if (ObjectUtil.isEmpty(orderCarList)) throw new RuntimeException("订单购物车不存在");
// 查询已退款商品
List<HardAfterSaleCarEntity> refundSuccessCarList = hardAfterSaleCarService.getOccupiedByCarParentId(order.getParentCarId());
Map<Long, Integer> 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<Long, Integer> 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<Long, Integer> 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<HardCarEntity> 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<HardAfterSaleMapper, H
hardAfterSaleLogMapper.insert(saveLogObj);
if (!simplePay) {
hardAfterSaleCarService.create(remainingCars, lockedOrder, saveObj);
hardOrderService.fastenerInventory(order, toInventoryCars(remainingCars), YesNoEnum.NO);
} else if (lockedOrder.getParentCarId() != null) {
// 简单支付通常没有购物车;历史异常数据若带有父购物车,仍沿用原整单库存恢复能力。
hardOrderService.fastenerInventory(order, YesNoEnum.NO);
}
// 执行退款
boolean dailyMenuOrder = isDailyMenuOrder(lockedOrder);
if (dailyMenuOrder) {
// 每日菜谱库存接口具备本地事务回滚和业务号幂等补偿,必须先恢复库存再调用支付渠道。
restoreRefundInventory(lockedOrder, saveObj.getId());
}
// 普通商品库存接口不具备同等跨服务补偿能力,保持历史“先退款、后恢复库存”顺序。
getSelf().refund(saveObj.getId());
if (!dailyMenuOrder) {
if (!simplePay) {
hardOrderService.fastenerInventory(order, toInventoryCars(remainingCars), YesNoEnum.NO);
} else if (lockedOrder.getParentCarId() != null) {
// 简单支付通常没有购物车;历史异常数据若带有父购物车,仍沿用原整单库存恢复能力。
hardOrderService.fastenerInventory(order, null, YesNoEnum.NO, saveObj.getId());
}
}
recomputeOrderRefundState(order.getId());
return true;
}
@@ -1539,7 +1748,9 @@ public class HardAfterSaleServiceImpl extends ServiceImpl<HardAfterSaleMapper, H
baseMapper.insert(saveObj);
// 添加退款购物车
if (!isSimplePayList) {
hardAfterSaleCarService.createAdmin(dto.getCarList(), order, saveObj);
List<HardAfterSaleCarEntity> 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<HardAfterSaleMapper, H
.content(HardSaleStatusEnum.COMPLETE.getContent())
.build();
hardAfterSaleLogMapper.insert(saveLogObj);
// 退库存
if (!isSimplePayList) {
boolean dailyMenuOrder = isDailyMenuOrder(order);
// 每日菜谱先恢复库存;库存接口失败时不发起渠道退款。
if (dailyMenuOrder) {
restoreRefundInventory(order, saveObj.getId());
}
// 普通商品库存恢复沿用原接口,放在渠道退款成功之后。
if (!dailyMenuOrder && !isSimplePayList) {
List<HardCarEntity> carList = dto.getCarList().stream().map((item) ->
HardCarEntity.builder()
.productId(item.getProductId())
@@ -1561,10 +1777,12 @@ public class HardAfterSaleServiceImpl extends ServiceImpl<HardAfterSaleMapper, H
.productNature(item.getProductNature())
.build()
).toList();
// 延后到渠道退款成功后执行,避免普通库存服务缺少补偿导致库存虚增。
getSelf().refund(saveObj.getId());
hardOrderService.fastenerInventory(order, carList, YesNoEnum.NO);
} else {
getSelf().refund(saveObj.getId());
}
// 执行退款
getSelf().refund(saveObj.getId());
recomputeOrderRefundState(order.getId());
return true;
}
@@ -1586,6 +1804,43 @@ public class HardAfterSaleServiceImpl extends ServiceImpl<HardAfterSaleMapper, H
&& ObjectUtil.equal(parentCar.getTakeOutSettingJson().getCartScene(), DAILY_MENU_CART_SCENE);
}
/**
* 恢复每日菜谱本次售后占用的基础 SPU 库存。
*
* <p>售后商品快照是服务端核定的可信数量:存在快照时仅恢复本次部分退款商品,
* 快照为空时兼容整单退款并恢复父购物车全部商品。库存服务使用售后单 ID 做幂等隔离,
* 使重复回调不会重复增加库存。</p>
*
* @param order 原每日菜谱预约订单
* @param afterSaleId 本次售后单 ID,不能为空
*/
private void restoreRefundInventory(HardOrderEntity order, Long afterSaleId) {
if (afterSaleId == null) {
throw new RuntimeException("每日菜谱退款库存缺少售后单 ID");
}
List<HardAfterSaleCarEntity> 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<HardAfterSaleCarEntity> 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<HardAfterSaleMapper, H
if (ObjectUtil.isEmpty(orderCarList)) {
throw new RuntimeException("订单购物车不存在");
}
int refundPrice = 0;
Map<Long, Integer> 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<Long, Integer> entry : refundCountByProductId.entrySet()) {
int remainingCount = entry.getValue();
List<HardCarEntity> 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<HardAfterSaleCarEntity> buildDailyMenuAdminRefundCars(HardOrderEntity order,
List<HardAfterSaleCarEntity> refundCarList) {
List<HardCarEntity> orderCarList = hardCarService.getByParentCarId(order.getParentCarId());
if (ObjectUtil.isEmpty(orderCarList)) {
throw new RuntimeException("订单购物车不存在");
}
Map<Long, HardCarEntity> orderCarMap = orderCarList.stream()
.filter(item -> item.getProductId() != null)
.collect(Collectors.toMap(HardCarEntity::getProductId, item -> item, (left, right) -> left,
LinkedHashMap::new));
Map<Long, Integer> 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<HardAfterSaleCarEntity> result = new ArrayList<>(refundCountByProductId.size());
for (Map.Entry<Long, Integer> 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<HardAfterSaleMapper, H
}
insertAfterSaleLog(afterSale.getId(), order.getUserId(), HardAfterSaleOperateTypeEnum.ADMIN_REFUND.getType(),
order.getOrderStatus(), HardSaleStatusEnum.COMPLETE.getStatus(), HardSaleStatusEnum.COMPLETE.getContent());
if (!simplePay) {
hardOrderService.fastenerInventory(order, toInventoryCars(remainingCars), YesNoEnum.NO);
boolean dailyMenuOrder = isDailyMenuOrder(order);
if (dailyMenuOrder) {
// 每日菜谱先恢复基础 SPU 库存,库存失败时不发起渠道退款。
restoreRefundInventory(order, afterSale.getId());
}
getSelf().refund(afterSale.getId(), "hard|ad");
if (!dailyMenuOrder && !simplePay) {
// 普通商品保持先渠道退款后恢复库存,避免旧库存接口失败时出现库存虚增。
hardOrderService.fastenerInventory(order, toInventoryCars(remainingCars), YesNoEnum.NO);
}
recomputeOrderRefundState(order.getId());
return true;
}
@@ -13,9 +13,11 @@ import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.common.core.util.BeanUtils;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundAttemptResultEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTaskStatusEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTaskTypeEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTriggerTypeEnum;
import com.bsd.pjcloud.hard.api.dto.order.HardAutoRefundTaskPageDto;
import com.bsd.pjcloud.hard.api.entity.order.HardAfterSaleEntity;
@@ -116,6 +118,9 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
*/
private final HardAfterSaleMapper afterSaleMapper;
/** 订单 Mapper,用于过期候选列表补充预约日期和餐段快照。 */
private final com.bsd.pjcloud.hard.mapper.order.HardOrderMapper hardOrderMapper;
/**
* 售后服务延迟提供器,用于显式打破售后申请创建任务与任务完成售后的循环依赖。
*/
@@ -173,6 +178,7 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
}
HardAutoRefundTaskEntity task = HardAutoRefundTaskEntity.builder()
.afterSaleId(afterSale.getId())
.taskType(HardAutoRefundTaskTypeEnum.AFTER_SALE.getType())
.orderId(order.getId())
.mchId(order.getMerchantId())
.configId(order.getAutoRefundConfigId())
@@ -189,6 +195,44 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
return task;
}
/**
* 支付成功事务内创建每日菜谱过期任务。任务的触发时间、配置 ID 和退库存值均来自订单快照,
* 配置关闭只会在执行阶段暂停,不会删除或改写已支付订单的时间口径。
*
* @param order 已支付的每日菜谱预约订单
* @return 新建或已存在的过期任务;快照不完整时返回空
*/
@Override
public HardAutoRefundTaskEntity createExpiredReserveTask(HardOrderEntity order) {
if (order == null || order.getId() == null || order.getExpiredRefundConfigId() == null
|| order.getExpiredRefundDeadline() == null
|| !Objects.equals(order.getOrderType(), HardOrderTypeEnum.RESERVE.getType())
|| !Objects.equals(order.getOrderStatus(), HardOrderStatusEnum.PAY.getType())
|| !Objects.equals(order.getPayType(), HardPayTypeEnum.WALLET_DEFAULT.getType())) {
return null;
}
HardAutoRefundTaskEntity existing = baseMapper.selectOne(new LambdaQueryWrapper<HardAutoRefundTaskEntity>()
.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<HardAutoRefundTas
}
try {
try {
if (Objects.equals(current.getTaskType(), HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())
&& current.getAfterSaleId() == null) {
if (!getSelf().canPrepareExpiredReserveTask(taskId, triggerType, operatorUserId)) {
return;
}
getSelf().prepareExpiredReserveTask(taskId);
current = getById(taskId);
if (current == null) {
return;
}
}
getSelf().executeAttemptInTransaction(taskId, triggerType, operatorUserId);
} catch (CurrentConfigLookupException exception) {
if (!tryRecoverPersistedWalletRefund(taskId, triggerType, operatorUserId)) {
@@ -253,6 +308,57 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
}
}
/**
* 在创建系统售后前复核到期时间、任务状态和当前配置,避免开关已关闭时提前占用订单售后。
*
* @param taskId 过期预约任务主键
* @param triggerType 触发来源,人工业务退款不受当前开关控制
* @param operatorUserId 人工操作用户 ID,自动触发为空
* @return 当前允许准备系统售后时返回 {@code true}
*/
@Transactional(rollbackFor = Exception.class)
public boolean canPrepareExpiredReserveTask(Long taskId, HardAutoRefundTriggerTypeEnum triggerType,
Long operatorUserId) {
HardAutoRefundTaskEntity task = baseMapper.selectByIdForUpdate(taskId);
if (!canExecute(task, triggerType)
|| !Objects.equals(task.getTaskType(), HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())) {
return false;
}
if (triggerType == HardAutoRefundTriggerTypeEnum.EXPIRED_MANUAL) {
return true;
}
MchAutoRefundConfigVo current = loadCurrentConfig(task.getMchId(), SUPPORTED_ORDER_TYPE);
if (!matchesExpiredSnapshot(task, current)) {
pauseConfigUnavailable(task, "自动退款配置与订单快照不一致");
return false;
}
if (!Objects.equals(current.getExpiredReserveRefundEnabled(), YesNoEnum.YES.getCode())) {
pauseForDisabledSwitch(task, triggerType, operatorUserId);
return false;
}
return true;
}
/**
* 在资金退款尝试前独立提交过期预约售后单,并把售后主键写回任务;该方法不调用钱包。
*
* @param taskId 过期预约任务主键
*/
@Transactional(rollbackFor = Exception.class)
public void prepareExpiredReserveTask(Long taskId) {
HardAutoRefundTaskEntity task = baseMapper.selectByIdForUpdate(taskId);
if (task == null || !Objects.equals(task.getTaskType(), HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())
|| task.getAfterSaleId() != null
|| Objects.equals(task.getStatus(), HardAutoRefundTaskStatusEnum.SUCCESS.getStatus())) {
return;
}
Long afterSaleId = hardAfterSaleServiceProvider.getObject().prepareExpiredReserveRefund(task);
task.setAfterSaleId(afterSaleId);
if (baseMapper.updateById(task) != 1) {
throw new RuntimeException("关联过期预约退款售后单失败");
}
}
/**
* 在单个 Hardware 事务中锁任务、复核配置、调用 Wallet 并原子完成售后和任务状态。
* 资金调用异常会使本事务全部回滚,外层随后用独立事务写失败日志。
@@ -267,7 +373,24 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
if (!canExecute(task, triggerType)) {
return;
}
if (Objects.equals(task.getTaskType(), HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())
&& triggerType == HardAutoRefundTriggerTypeEnum.EXPIRED_MANUAL) {
completeAttempt(task, triggerType, operatorUserId, false);
return;
}
MchAutoRefundConfigVo current = loadCurrentConfig(task.getMchId(), SUPPORTED_ORDER_TYPE);
if (Objects.equals(task.getTaskType(), HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())) {
if (!matchesExpiredSnapshot(task, current)) {
pauseConfigUnavailable(task, "自动退款配置与订单快照不一致");
return;
}
if (!Objects.equals(current.getExpiredReserveRefundEnabled(), YesNoEnum.YES.getCode())) {
pauseForDisabledSwitch(task, triggerType, operatorUserId);
return;
}
completeAttempt(task, triggerType, operatorUserId, true);
return;
}
if (!matchesSnapshot(task.getConfigId(), task.getRuleType(), current)) {
if (!recoverPersistedWalletRefund(task, triggerType, operatorUserId)) {
pauseConfigUnavailable(task, "自动退款配置与订单快照不一致");
@@ -328,7 +451,14 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
throw new RuntimeException("更新自动退款尝试次数失败");
}
hardAfterSaleServiceProvider.getObject().completeAutoRefund(task.getAfterSaleId(), task.getConfigId());
if (Objects.equals(task.getTaskType(), HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())) {
boolean enforceAutoSwitch = triggerType != HardAutoRefundTriggerTypeEnum.EXPIRED_MANUAL;
Long afterSaleId = hardAfterSaleServiceProvider.getObject()
.completeExpiredReserveRefund(task, enforceAutoSwitch);
task.setAfterSaleId(afterSaleId);
} else {
hardAfterSaleServiceProvider.getObject().completeAutoRefund(task.getAfterSaleId(), task.getConfigId());
}
LocalDateTime finishedTime = LocalDateTime.now();
attempt.setResultStatus(HardAutoRefundAttemptResultEnum.SUCCESS.getStatus());
@@ -365,11 +495,14 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
}
try {
MchAutoRefundConfigVo current = loadCurrentConfig(task.getMchId(), SUPPORTED_ORDER_TYPE);
if (!matchesSnapshot(task.getConfigId(), task.getRuleType(), current)) {
boolean expiredTask = Objects.equals(task.getTaskType(), HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType());
if ((!expiredTask && !matchesSnapshot(task.getConfigId(), task.getRuleType(), current))
|| (expiredTask && !matchesExpiredSnapshot(task, current))) {
getSelf().pauseConfigUnavailableInTransaction(taskId, "自动退款配置与订单快照不一致");
return;
}
if (!Objects.equals(current.getEnabled(), YesNoEnum.YES.getCode())) {
if ((!expiredTask && !Objects.equals(current.getEnabled(), YesNoEnum.YES.getCode()))
|| (expiredTask && !Objects.equals(current.getExpiredReserveRefundEnabled(), YesNoEnum.YES.getCode()))) {
getSelf().pauseSwitchAfterFailureInTransaction(taskId, triggerType, operatorUserId);
return;
}
@@ -494,7 +627,8 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
task.setAutoAttemptCount(autoAttempts);
task.setLastAttemptTime(now);
task.setLastError(errorMessage);
if (triggerType == HardAutoRefundTriggerTypeEnum.MANUAL || autoAttempts >= 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<HardAutoRefundTas
if (task == null || Objects.equals(task.getStatus(), HardAutoRefundTaskStatusEnum.SUCCESS.getStatus())) {
return false;
}
if (triggerType == HardAutoRefundTriggerTypeEnum.EXPIRED_MANUAL) {
return Objects.equals(task.getTaskType(), HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())
&& task.getTriggerTime() != null && !task.getTriggerTime().isAfter(LocalDateTime.now())
&& List.of(HardAutoRefundTaskStatusEnum.PENDING.getStatus(),
HardAutoRefundTaskStatusEnum.SWITCH_DISABLED.getStatus()).contains(task.getStatus());
}
if (triggerType == HardAutoRefundTriggerTypeEnum.MANUAL) {
return Objects.equals(task.getStatus(), HardAutoRefundTaskStatusEnum.RETRY_EXHAUSTED.getStatus());
}
if (Objects.equals(task.getStatus(), HardAutoRefundTaskStatusEnum.RETRY_EXHAUSTED.getStatus())) {
return false;
}
if (Objects.equals(task.getTaskType(), HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())
&& (task.getTriggerTime() == null || task.getTriggerTime().isAfter(LocalDateTime.now()))) {
return false;
}
if (Objects.equals(task.getStatus(), HardAutoRefundTaskStatusEnum.WAITING_RETRY.getStatus())
&& task.getNextRetryTime() != null && task.getNextRetryTime().isAfter(LocalDateTime.now())) {
return false;
@@ -644,6 +788,12 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
&& Objects.equals(current.getOrderType(), SUPPORTED_ORDER_TYPE);
}
/** 校验过期任务只匹配创建时配置身份和过期退款开关,不读取或覆盖订单延迟快照。 */
private boolean matchesExpiredSnapshot(HardAutoRefundTaskEntity task, MchAutoRefundConfigVo current) {
return current != null && Objects.equals(current.getId(), task.getConfigId())
&& Objects.equals(current.getOrderType(), SUPPORTED_ORDER_TYPE);
}
/**
* 校验订单是否具备每日菜谱自动退款的完整快照和固定支付协议。
*
@@ -787,6 +937,67 @@ public class HardAutoRefundTaskServiceImpl extends ServiceImpl<HardAutoRefundTas
execute(taskId, HardAutoRefundTriggerTypeEnum.MANUAL, operatorUserId);
}
/**
* 管理端逐单执行过期预约业务退款,候选任务不受技术重试状态机权限限制。
* 状态机为保留失败日志会吸收资金异常,因此执行后必须复查最终状态,避免管理端把失败提示成成功。
*
* @param taskId 过期预约任务主键
* @param operatorUserId 当前业务操作用户 ID
*/
@Override
public void manualExpiredRefund(Long taskId, Long operatorUserId) {
HardAutoRefundTaskEntity task = getById(taskId);
if (task == null || !Objects.equals(task.getTaskType(), HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())) {
throw new RuntimeException("过期预约退款任务不存在");
}
if (Objects.equals(task.getStatus(), HardAutoRefundTaskStatusEnum.SUCCESS.getStatus())) {
throw new RuntimeException("订单已退款");
}
if (task.getTriggerTime() == null || task.getTriggerTime().isAfter(LocalDateTime.now())
|| !List.of(HardAutoRefundTaskStatusEnum.PENDING.getStatus(),
HardAutoRefundTaskStatusEnum.SWITCH_DISABLED.getStatus()).contains(task.getStatus())) {
throw new RuntimeException("当前任务不属于待过期退款候选订单");
}
execute(taskId, HardAutoRefundTriggerTypeEnum.EXPIRED_MANUAL, operatorUserId);
HardAutoRefundTaskEntity completed = getById(taskId);
if (completed == null || !Objects.equals(completed.getStatus(), HardAutoRefundTaskStatusEnum.SUCCESS.getStatus())) {
throw new RuntimeException("订单退款未完成,请刷新列表或前往自动退款任务页处理");
}
}
/** 查询已到期、未失败耗尽且仍可整单退款的任务,失败任务留在技术任务页处理。 */
@Override
public Page<HardAutoRefundTaskVo> pageExpiredCandidates(Page<HardAutoRefundTaskEntity> page,
HardAutoRefundTaskPageDto dto) {
LocalDateTime now = LocalDateTime.now();
Page<HardAutoRefundTaskEntity> entityPage = baseMapper.pageExpiredCandidates(page, dto, now);
List<HardAutoRefundTaskVo> records = BeanUtils.toBean(entityPage.getRecords(), HardAutoRefundTaskVo.class);
fillAfterSaleData(records);
fillOrderData(records);
fillUserData(records);
fillMerchantData(records);
Page<HardAutoRefundTaskVo> result = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal());
result.setRecords(records);
return result;
}
/** 批量补充过期候选订单日期和餐段,避免把配置变更后的规则重新解析为退款时间。 */
private void fillOrderData(List<HardAutoRefundTaskVo> records) {
List<Long> orderIds = records.stream().map(HardAutoRefundTaskVo::getOrderId).filter(Objects::nonNull).distinct().toList();
if (orderIds.isEmpty()) return;
Map<Long, HardOrderEntity> 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<HardAutoRefundTas
new LambdaQueryWrapper<HardAutoRefundTaskEntity>()
.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<HardAutoRefundTas
* @return hard_{orderId}_{afterSaleId}
*/
private String buildRefundCode(HardAutoRefundTaskEntity task) {
if (Objects.equals(task.getTaskType(), HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())) {
return "hard_expired_" + task.getOrderId();
}
return "hard_" + task.getOrderId() + "_" + task.getAfterSaleId();
}
@@ -172,7 +172,7 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
static private final String TAG = "订单";
private static final long RESERVATION_SLOT_LOCK_EXPIRE_MILLIS = 60_000L;
private static final long RESERVATION_SLOT_LOCK_WAIT_MILLIS = 3_000L;
/** 每日菜谱父购物车在扩展 JSON 中持久化的场景值,作为忽略库存和商品来源分流依据。 */
/** 每日菜谱父购物车在扩展 JSON 中持久化的场景值,作为基础菜品库存和商品来源分流依据。 */
private static final String DAILY_MENU_CART_SCENE = "DAILY_MENU";
/** 聚合订单码跨服务协议类型,禁止与自有钱包码或平台原生码混用。 */
@@ -198,7 +198,10 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
private final HardUserMapAddressService hardUserMapAddressService;
private final HardTakeOutApptService hardTakeOutApptService;
/** 每日菜谱商品远程服务,批量下单时按 {@code canteen_spu.id} 复核购物车价格快照。 */
/** 过期预约任务服务;支付成功事务内写入订单快照后创建独立任务。 */
private final HardAutoRefundTaskService hardAutoRefundTaskService;
/** 每日菜谱商品远程服务,按 {@code canteen_spu.id} 复核商品并幂等变更基础菜品库存。 */
private final RemoteCanteenSpuService remoteCanteenSpuService;
private final RemoteOnlineCanteenSpuService remoteOnlineCanteenSpuService;
private final RemoteMchService remoteMchService;
@@ -1405,8 +1408,8 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
/**
* 每日菜谱批量下单在一个本地事务中处理全部日期餐段。购物车来自 {@code canteen_spu},
* 因此不复用会查询 {@code canteen_online_spu} 的旧预约接口;该场景按业务规则忽略库存,
* 下单和退款均不修改在线菜品库存,远程钱包扣款失败时按订单业务号幂等补偿。
* 单品和套餐都以购物车 SPU 为库存单位;跨服务库存和钱包操作使用稳定业务号幂等执行,
* 方法体失败或本地事务提交失败时同时发起反向补偿,避免产生部分日期、部分人员或单边扣减。
*/
@Override
@Transactional(rollbackFor = Exception.class)
@@ -1424,9 +1427,8 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
List<DailyMenuReserveGroupContext> 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<Long, Integer> inventorySnapshot = validateDailyMenuCartProducts(merchantId, contexts, dinerUserIds.size());
validateDailyMenuSingleProductLimit(merchant, contexts);
List<SysUser> dinerUsers = Optional.ofNullable(userService.getInnerUserById(dinerUserIds))
@@ -1462,6 +1464,9 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
.forEach(context -> validateSingleReservationOnlyPerMeal(merchant, dinerUserIds, context.reservation()));
List<HardOrderEntity> createdOrders = new ArrayList<>();
Map<Long, HardApptReservationService.ReservationValidation> 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<HardOrderMapper, HardOrder
: cloneReserveCart(context.parentCar(), context.carList(), payerUserId);
HardOrderEntity order = buildReserveOrder(dinerUserMap.get(dinerUserId), parentCarId, merchant,
dinerWalletMap.get(dinerUserId), context.reservation(), context.singlePrice(), subject);
applyDailyMenuAutoRefundSnapshot(order, context.reservation(), autoRefundConfig);
if (baseMapper.insert(order) != 1) {
throw new RuntimeException("创建订餐订单失败");
}
createdOrders.add(order);
reservationByOrderId.put(order.getId(), context.reservation());
}
}
registerDailyMenuWalletRollbackCompensation(createdOrders);
inventoryBizId = buildDailyMenuReserveInventoryBizId(createdOrders);
inventoryOperationAttempted = true;
updateDailyMenuReserveInventory(contexts, dinerUserIds.size(), true, inventorySnapshot, inventoryBizId);
registerDailyMenuRollbackCompensation(createdOrders, contexts, dinerUserIds.size(), inventorySnapshot, inventoryBizId);
reduceReserveOrderBalances(createdOrders);
// 配置快照必须以钱包扣款成功时刻为准,避免支付等待期间的配置变更影响订单资格。
MchAutoRefundConfigVo autoRefundConfig = loadDailyMenuAutoRefundConfig(merchantId);
for (HardOrderEntity order : createdOrders) {
applyDailyMenuAutoRefundSnapshot(order, reservationByOrderId.get(order.getId()), autoRefundConfig);
markReserveOrderPaid(order, autoRefundConfig);
}
} catch (Exception exception) {
compensateReserveOrderWallets(createdOrders);
compensateDailyMenuReserveBatch(createdOrders, contexts, dinerUserIds.size(), inventorySnapshot,
inventoryOperationAttempted ? inventoryBizId : null);
throw exception;
}
@@ -1498,26 +1510,37 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
}
/**
* 将每日菜谱钱包补偿绑定到本地事务回滚,覆盖方法返回后发生的数据库提交失败。
* 钱包补偿接口按订单业务号幂等,方法体异常路径即使已经补偿,再次调用也不会重复入账。
* 将每日菜谱库存和钱包补偿绑定到本地事务回滚,覆盖方法返回后发生的数据库提交失败。
* 两侧补偿接口都按稳定业务号幂等,方法体异常已经补偿时回调再次执行不会重复恢复。
*
* @param createdOrders 已分配订单 ID、即将进行远程钱包扣款的订单;未产生 PAYMENT 流水时补偿接口会安全忽略
* @param createdOrders 已分配订单 ID、即将执行跨服务操作的订单
* @param contexts 本批次全部日期餐段及其原始购物车商品
* @param dinerCount 实际用餐人数
* @param inventorySnapshot 扣减前的基础菜品库存快照
* @param inventoryBizId 本批次库存扣减与回滚共用的稳定业务号
*/
private void registerDailyMenuWalletRollbackCompensation(List<HardOrderEntity> createdOrders) {
private void registerDailyMenuRollbackCompensation(List<HardOrderEntity> createdOrders,
List<DailyMenuReserveGroupContext> contexts,
int dinerCount,
Map<Long, Integer> inventorySnapshot,
String inventoryBizId) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
throw new RuntimeException("订餐事务状态异常,请稍后重试");
}
List<HardOrderEntity> rollbackOrders = List.copyOf(createdOrders);
List<DailyMenuReserveGroupContext> rollbackContexts = List.copyOf(contexts);
Map<Long, Integer> 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<HardOrderMapper, HardOrder
return null;
}
MchAutoRefundConfigVo config = response.getData();
if (!Objects.equals(config.getEnabled(), YesNoEnum.YES.getCode())
|| !Objects.equals(config.getMchId(), merchantId)
if (!Objects.equals(config.getMchId(), merchantId)
|| !Objects.equals(config.getOrderType(), HardOrderTypeEnum.RESERVE.getType())) {
return null;
}
MchAutoRefundRuleTypeEnum rule = MchAutoRefundRuleTypeEnum.valueOfName(config.getRuleType());
if (rule == null) {
boolean legacyEnabled = Objects.equals(config.getEnabled(), YesNoEnum.YES.getCode());
boolean expiredEnabled = Objects.equals(config.getExpiredReserveRefundEnabled(), YesNoEnum.YES.getCode());
if (!legacyEnabled && !expiredEnabled) {
return null;
}
if (rule == MchAutoRefundRuleTypeEnum.PAY_TIME_LIMIT
if (legacyEnabled && rule == null) {
return expiredEnabled ? config : null;
}
if (legacyEnabled && rule == MchAutoRefundRuleTypeEnum.PAY_TIME_LIMIT
&& (config.getPayTimeLimitHours() == null || config.getPayTimeLimitHours() < 1
|| config.getPayTimeLimitHours() > 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<HardOrderMapper, HardOrder
private void applyDailyMenuAutoRefundSnapshot(HardOrderEntity order,
HardApptReservationService.ReservationValidation reservation,
MchAutoRefundConfigVo config) {
if (config == null) {
if (config == null || reservation == null) {
return;
}
MchAutoRefundRuleTypeEnum rule = MchAutoRefundRuleTypeEnum.valueOfName(config.getRuleType());
if (rule == MchAutoRefundRuleTypeEnum.APPT_CYCLE_CLOSE
&& (reservation.apptRuleId() == null || reservation.eligibilityDeadline() == null)) {
return;
boolean legacyEnabled = Objects.equals(config.getEnabled(), YesNoEnum.YES.getCode());
// 用户申请免审退款和过期预约退款使用独立开关;旧规则异常只能放弃旧快照,不能阻断过期快照。
if (legacyEnabled && rule != null) {
order.setAutoRefundConfigId(config.getId());
order.setAutoRefundRuleType(config.getRuleType());
if (rule == MchAutoRefundRuleTypeEnum.APPT_CYCLE_CLOSE
&& reservation.apptRuleId() != null && reservation.eligibilityDeadline() != null) {
order.setAutoRefundDeadline(reservation.eligibilityDeadline());
}
}
order.setAutoRefundConfigId(config.getId());
order.setAutoRefundRuleType(config.getRuleType());
if (rule == MchAutoRefundRuleTypeEnum.APPT_CYCLE_CLOSE) {
order.setAutoRefundDeadline(reservation.eligibilityDeadline());
if (Objects.equals(config.getExpiredReserveRefundEnabled(), YesNoEnum.YES.getCode())
&& config.getExpiredReserveRefundDelayHours() != null
&& config.getExpiredReserveRefundDelayHours() >= 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<HardOrderMapper, HardOrder
}
/**
* 使用 {@code canteen_spu} 当前数据复核购物车商品归属、可见状态和价格快照,价格变化时要求重新结算。
* 使用 {@code canteen_spu} 当前数据复核购物车商品归属、单品/套餐模式、价格和整批库存。
* 套餐组成只用于展示和核销,库存按套餐 SPU 本身扣减,与单品使用同一聚合规则。
*
* @param merchantId 每日菜谱所属商户 ID
* @param contexts 已加载的日期餐段购物车上下文
* @param dinerCount 实际用餐人数,每个日期餐段商品数量都需要乘以该人数
* @return 当前批次涉及的基础 SPU 操作前库存快照
*/
private void validateDailyMenuCartProducts(Long merchantId, List<DailyMenuReserveGroupContext> contexts) {
private Map<Long, Integer> validateDailyMenuCartProducts(Long merchantId,
List<DailyMenuReserveGroupContext> contexts,
int dinerCount) {
List<Long> productIds = contexts.stream()
.flatMap(context -> context.carList().stream())
.map(HardCarEntity::getProductId)
@@ -1873,6 +1923,12 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
.filter(Objects::nonNull)
.filter(product -> product.getId() != null)
.collect(Collectors.toMap(CanteenSpuEntity::getId, Function.identity(), (first, second) -> first));
Map<Long, Integer> 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<HardOrderMapper, HardOrder
if (product.getPrice() == null || product.getPrice() < 0) {
throw new RuntimeException(product.getName() + "价格异常,请联系商户");
}
int productType = product.getSpuType() == null ? 1 : product.getSpuType();
if (productType != 1 && productType != 2) {
throw new RuntimeException(product.getName() + "商品模式异常,请联系商户");
}
long currentTotalPrice = Math.multiplyExact((long) product.getPrice(), car.getProductCount().longValue());
if (!Objects.equals(product.getPrice(), car.getProductPrice())
|| !Objects.equals(currentTotalPrice, car.getTotalPrice())) {
@@ -1890,6 +1950,14 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
}
}
}
for (Map.Entry<Long, Integer> 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<HardOrderMapper, HardOrder
* 从父购物车现有扩展 JSON 读取服务端写入的每日菜谱场景标记,格式异常时按非每日菜谱处理。
*
* @param parentCar 待判断的父购物车
* @return {@code true} 表示商品来自 {@code canteen_spu} 每日菜谱且不参与在线库存变更
* @return {@code true} 表示商品来自 {@code canteen_spu} 每日菜谱,库存必须分流到基础菜品服务
*/
private boolean isDailyMenuParentCar(HardParentCarEntity parentCar) {
return parentCar != null
@@ -2294,7 +2362,13 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
.payTime(payTime)
.orderStatus(HardOrderStatusEnum.PAY.getType())
.payPrice(order.getTotalPrice() + order.getExtraPrice())
.autoRefundConfigId(order.getAutoRefundConfigId())
.autoRefundRuleType(order.getAutoRefundRuleType())
.autoRefundDeadline(autoRefundDeadline)
.expiredRefundConfigId(order.getExpiredRefundConfigId())
.expiredRefundUsageEndTime(order.getExpiredRefundUsageEndTime())
.expiredRefundDeadline(order.getExpiredRefundDeadline())
.expiredRefundRestoreInventory(order.getExpiredRefundRestoreInventory())
.build();
if (baseMapper.updateById(orderUpdate) != 1) {
throw new RuntimeException("更新订餐订单支付状态失败");
@@ -2309,6 +2383,89 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
order.setPayTime(payTime);
order.setOrderStatus(HardOrderStatusEnum.PAY.getType());
order.setAutoRefundDeadline(autoRefundDeadline);
order.setExpiredRefundConfigId(orderUpdate.getExpiredRefundConfigId());
order.setExpiredRefundUsageEndTime(orderUpdate.getExpiredRefundUsageEndTime());
order.setExpiredRefundDeadline(orderUpdate.getExpiredRefundDeadline());
order.setExpiredRefundRestoreInventory(orderUpdate.getExpiredRefundRestoreInventory());
if (order.getExpiredRefundDeadline() != null && order.getExpiredRefundConfigId() != null) {
hardAutoRefundTaskService.createExpiredReserveTask(order);
}
}
/**
* 按每日菜谱批次聚合基础 SPU 库存增量,并调用食堂服务的幂等接口执行扣减或回滚。
* APPLY 的负数增量表示下单扣库存,ROLLBACK 使用同一业务号把该批次反向恢复;单品和套餐不拆分组成明细。
*
* @param contexts 每日菜谱日期餐段上下文
* @param dinerCount 实际用餐人数
* @param reduce {@code true} 表示扣减,{@code false} 表示回滚
* @param inventorySnapshot 本批次每个 SPU 的操作前库存快照
* @param inventoryBizId 扣减与回滚共用的库存业务号
*/
private void updateDailyMenuReserveInventory(List<DailyMenuReserveGroupContext> contexts, int dinerCount,
boolean reduce, Map<Long, Integer> inventorySnapshot,
String inventoryBizId) {
// ROLLBACK 必须原样携带 APPLY 的负增量,由食堂服务负责取反,不能在调用端提前改成正数。
int factor = -dinerCount;
Map<Long, Integer> 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<HardOrderEntity> 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<HardOrderEntity> createdOrders,
List<DailyMenuReserveGroupContext> contexts,
int dinerCount,
Map<Long, Integer> 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<HardOrderMapper, HardOrder
}
}
/**
* 对单笔每日菜谱订单应用基础 SPU 库存变更。单品与套餐均按订单购物车中的 SPU 数量处理,
* 部分退款先收敛到本次售后商品;本地事务回滚时使用同一业务号反向补偿远程库存。
*
* @param order 每日菜谱预约订单
* @param refundCarList 本次退款商品;为空表示整单商品
* @param isReduce 是否扣减库存
* @param afterSaleId 本次售后单 ID,用于隔离同一订单的多次部分退款
*/
private void updateDailyMenuOrderInventory(HardOrderEntity order, List<HardCarEntity> refundCarList,
YesNoEnum isReduce, Long afterSaleId) {
if (ObjectUtil.equals(isReduce, YesNoEnum.NO) && afterSaleId == null) {
throw new RuntimeException("每日菜谱退款库存缺少售后单 ID");
}
List<HardCarEntity> 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<Long, Integer> stockChanges = carList.stream().collect(Collectors.toMap(
HardCarEntity::getProductId,
car -> Math.multiplyExact(car.getProductCount(), factor),
Math::addExact,
LinkedHashMap::new));
R<List<CanteenSpuEntity>> 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<Long, CanteenSpuEntity> productMap = productResult.getData().stream()
.filter(Objects::nonNull)
.filter(product -> product.getId() != null)
.collect(Collectors.toMap(CanteenSpuEntity::getId, Function.identity(), (first, second) -> first));
Map<Long, Integer> inventorySnapshot = new LinkedHashMap<>();
for (Map.Entry<Long, Integer> 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<Long, Integer> stockChanges,
Map<Long, Integer> inventorySnapshot,
String inventoryBizId) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
throw new RuntimeException("库存事务状态异常,请稍后重试");
}
Map<Long, Integer> rollbackChanges = Map.copyOf(stockChanges);
Map<Long, Integer> 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<Long, Integer> stockChanges,
Map<Long, Integer> 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<HardCarEntity> 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<HardCarEntity> 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<HardOrderMapper, HardOrder
// 食堂扣减库存
if (ObjectUtil.equals(hardTypeEnum, HardTypeEnum.CANTEEN) && ObjectUtil.equals(orderTypeEnum, HardOrderTypeEnum.RESERVE)) {
HardParentCarEntity parentCar = hardCarService.getParentCarById(order.getParentCarId());
// 每日菜谱页面明确忽略库存;退款或取消时也必须保持对称,不能误恢复同 ID 的在线菜品库存。
// 每日菜谱购物车的 productId 属于 canteen_spu,必须在在线菜品库存分支之前单独处理。
if (isDailyMenuParentCar(parentCar)) {
log.info("|{}|每日菜谱订单忽略库存变更,orderId:|{}|,isReduce:|{}|", TAG, order.getId(), isReduce);
updateDailyMenuOrderInventory(order, refundCarList, isReduce, afterSaleId);
return;
}
// 查询购物车
@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.bsd.pjcloud.hard.mapper.order.HardAutoRefundTaskMapper">
<!--
候选列表只返回尚未发起任何自动资金尝试的任务。
已核销、已退款、非每日菜谱订单和失败重试任务分别留在任务页或异常处理流程。
开关关闭竞态中若已经创建本任务自己的系统售后,仍允许业务人员从候选列表完成它;其他售后继续排除。
-->
<select id="pageExpiredCandidates"
resultType="com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity">
select t.*
from hard_auto_refund_task t
inner join hard_order o
on o.id = t.order_id
and o.del_flag = '0'
and o.order_type = '2'
and o.order_status = '5'
and o.pay_type = '1'
inner join hard_parent_car p
on p.id = o.parent_car_id
and p.del_flag = '0'
where t.del_flag = '0'
and t.task_type = '20'
and t.status in ('10', '30')
and t.trigger_time &lt;= #{now}
and coalesce(t.auto_attempt_count, 0) = 0
and (o.refund_price is null or o.refund_price = 0)
and case
when json_valid(p.take_out_setting_json) = 1
then json_unquote(json_extract(p.take_out_setting_json, '$.cartScene'))
else null
end = 'DAILY_MENU'
and not exists (
select 1
from hard_after_sale s
where s.order_id = o.id
and s.del_flag = '0'
and s.status in ('10', '50')
and (t.after_sale_id is null or s.id &lt;&gt; t.after_sale_id)
)
and (t.after_sale_id is null or exists (
select 1
from hard_after_sale own_s
where own_s.id = t.after_sale_id
and own_s.order_id = o.id
and own_s.del_flag = '0'
and own_s.status = '10'
and own_s.no = concat('hard_expired_', o.id)
))
<if test="dto.id != null">
and t.id = #{dto.id}
</if>
<if test="dto.orderId != null">
and t.order_id = #{dto.orderId}
</if>
<if test="dto.mchId != null">
and t.mch_id = #{dto.mchId}
</if>
<if test="dto.mchIds != null and dto.mchIds.size() != 0">
and t.mch_id in
<foreach collection="dto.mchIds" item="mchId" open="(" close=")" separator=",">
#{mchId}
</foreach>
</if>
<if test="dto.createTimeList != null and dto.createTimeList.length == 2">
and t.create_time between #{dto.createTimeList[0]} and #{dto.createTimeList[1]}
</if>
order by t.trigger_time asc, t.id asc
</select>
</mapper>
@@ -336,6 +336,138 @@
order by DATE_FORMAT(o.appt_use_date, '%Y-%m-%d') desc, o.hour_type
</select>
<!-- 菜品预约导出按单个用餐日期和餐段返回日期餐段汇总。 -->
<select id="listApptExportSummary" resultType="com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductTotalVo">
select DATE_FORMAT(o.appt_use_date, '%Y-%m-%d') as createTime,
o.hour_type as hourType,
SUM(a.product_count) as payCount,
SUM(a.total_price) as totalPrice,
count(distinct o.id) as timeCount,
count(distinct o.user_id) as payUserCount
from hard_car a
inner join hard_parent_car p on a.parent_car_id = p.id and p.del_flag = '0'
inner join hard_order o on o.parent_car_id = p.id and o.del_flag = '0'
and o.hour_type is not null and o.hour_type not in ('-1')
<where>
<include refid="whereA"/>
and o.order_type = #{dto.orderType}
and (o.appt_id is not null or o.appt_rule_id is not null)
<if test="dto.orderStatusCodes != null and dto.orderStatusCodes.size() != 0">
and o.order_status in
<foreach collection="dto.orderStatusCodes" item="v" open="(" close=")" separator=",">#{v}</foreach>
</if>
<if test="dto.hourType != null and dto.hourType != ''">and o.hour_type = #{dto.hourType}</if>
<if test="dto.merchantId != null">and o.merchant_id = #{dto.merchantId}</if>
<if test="dto.userDeptIds != null and dto.userDeptIds.size() != 0">
<choose>
<when test="dto.isInclude != null and dto.isInclude == false">and o.user_dept_id not in</when>
<otherwise>and o.user_dept_id in</otherwise>
</choose>
<foreach collection="dto.userDeptIds" item="v" open="(" close=")" separator=",">#{v}</foreach>
</if>
<if test="dto.merchantDeptIds != null and dto.merchantDeptIds.size() != 0">
and o.merchant_dept_id in
<foreach collection="dto.merchantDeptIds" item="v" open="(" close=")" separator=",">#{v}</foreach>
</if>
<if test="dto.mchIds != null and dto.mchIds.size() != 0">
and o.merchant_id in
<foreach collection="dto.mchIds" item="v" open="(" close=")" separator=",">#{v}</foreach>
</if>
</where>
group by DATE_FORMAT(o.appt_use_date, '%Y-%m-%d'), o.hour_type
order by DATE_FORMAT(o.appt_use_date, '%Y-%m-%d'), o.hour_type
</select>
<!-- 菜品预约导出按商户和用户部门返回第二层统计。 -->
<select id="listApptExportMerchant" resultType="com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductApptExportRecord">
select DATE_FORMAT(o.appt_use_date, '%Y-%m-%d') as apptDate,
o.hour_type as hourType,
o.merchant_id as merchantId,
o.user_dept_id as userDeptId,
count(distinct o.user_id) as payUserCount,
count(distinct o.id) as timeCount,
SUM(a.product_count) as payCount,
SUM(a.total_price) as totalPrice
from hard_car a
inner join hard_parent_car p on a.parent_car_id = p.id and p.del_flag = '0'
inner join hard_order o on o.parent_car_id = p.id and o.del_flag = '0'
and o.hour_type is not null and o.hour_type not in ('-1')
<where>
<include refid="whereA"/>
and o.order_type = #{dto.orderType}
and (o.appt_id is not null or o.appt_rule_id is not null)
<if test="dto.orderStatusCodes != null and dto.orderStatusCodes.size() != 0">
and o.order_status in
<foreach collection="dto.orderStatusCodes" item="v" open="(" close=")" separator=",">#{v}</foreach>
</if>
<if test="dto.hourType != null and dto.hourType != ''">and o.hour_type = #{dto.hourType}</if>
<if test="dto.merchantId != null">and o.merchant_id = #{dto.merchantId}</if>
<if test="dto.userDeptIds != null and dto.userDeptIds.size() != 0">
<choose>
<when test="dto.isInclude != null and dto.isInclude == false">and o.user_dept_id not in</when>
<otherwise>and o.user_dept_id in</otherwise>
</choose>
<foreach collection="dto.userDeptIds" item="v" open="(" close=")" separator=",">#{v}</foreach>
</if>
<if test="dto.merchantDeptIds != null and dto.merchantDeptIds.size() != 0">
and o.merchant_dept_id in
<foreach collection="dto.merchantDeptIds" item="v" open="(" close=")" separator=",">#{v}</foreach>
</if>
<if test="dto.mchIds != null and dto.mchIds.size() != 0">
and o.merchant_id in
<foreach collection="dto.mchIds" item="v" open="(" close=")" separator=",">#{v}</foreach>
</if>
</where>
group by DATE_FORMAT(o.appt_use_date, '%Y-%m-%d'), o.hour_type, o.merchant_id, o.user_dept_id
order by DATE_FORMAT(o.appt_use_date, '%Y-%m-%d'), o.hour_type, o.merchant_id, o.user_dept_id
</select>
<!-- 菜品预约导出按商户、部门和商品返回第三层商品明细。 -->
<select id="listApptExportProduct" resultType="com.bsd.pjcloud.hard.api.vo.order.HardCanteenProductApptExportRecord">
select DATE_FORMAT(o.appt_use_date, '%Y-%m-%d') as apptDate,
o.hour_type as hourType,
o.merchant_id as merchantId,
o.user_dept_id as userDeptId,
a.product_id as productId,
a.product_name as productName,
SUM(a.product_count) as payCount,
SUM(a.total_price) as totalPrice
from hard_car a
inner join hard_parent_car p on a.parent_car_id = p.id and p.del_flag = '0'
inner join hard_order o on o.parent_car_id = p.id and o.del_flag = '0'
and o.hour_type is not null and o.hour_type not in ('-1')
<where>
<include refid="whereA"/>
and o.order_type = #{dto.orderType}
and (o.appt_id is not null or o.appt_rule_id is not null)
<if test="dto.orderStatusCodes != null and dto.orderStatusCodes.size() != 0">
and o.order_status in
<foreach collection="dto.orderStatusCodes" item="v" open="(" close=")" separator=",">#{v}</foreach>
</if>
<if test="dto.hourType != null and dto.hourType != ''">and o.hour_type = #{dto.hourType}</if>
<if test="dto.merchantId != null">and o.merchant_id = #{dto.merchantId}</if>
<if test="dto.userDeptIds != null and dto.userDeptIds.size() != 0">
<choose>
<when test="dto.isInclude != null and dto.isInclude == false">and o.user_dept_id not in</when>
<otherwise>and o.user_dept_id in</otherwise>
</choose>
<foreach collection="dto.userDeptIds" item="v" open="(" close=")" separator=",">#{v}</foreach>
</if>
<if test="dto.merchantDeptIds != null and dto.merchantDeptIds.size() != 0">
and o.merchant_dept_id in
<foreach collection="dto.merchantDeptIds" item="v" open="(" close=")" separator=",">#{v}</foreach>
</if>
<if test="dto.mchIds != null and dto.mchIds.size() != 0">
and o.merchant_id in
<foreach collection="dto.mchIds" item="v" open="(" close=")" separator=",">#{v}</foreach>
</if>
</where>
group by DATE_FORMAT(o.appt_use_date, '%Y-%m-%d'), o.hour_type, o.merchant_id, o.user_dept_id,
a.product_id, a.product_name
order by DATE_FORMAT(o.appt_use_date, '%Y-%m-%d'), o.hour_type, o.merchant_id, o.user_dept_id,
a.product_name
</select>
<select id="proudctQuery" resultType="com.bsd.pjcloud.hard.api.vo.order.HardCarVo">
select a.product_id,
a.product_name,
@@ -361,6 +493,12 @@
<if test="dto.merchantId != null">
and o.merchant_id = #{dto.merchantId}
</if>
<if test="dto.orderType != null and dto.orderType != ''">
and o.order_type = #{dto.orderType}
</if>
<if test="dto.apptIdOrApptRuleIdNotNull != null and dto.apptIdOrApptRuleIdNotNull == true">
and (o.appt_id is not null or o.appt_rule_id is not null)
</if>
<if test="dto.productName != null and dto.productName != '' ">
and a.product_name LIKE concat('%',#{dto.productName},'%')
</if>
@@ -396,6 +534,12 @@
<if test="dto.merchantId != null">
and o.merchant_id = #{dto.merchantId}
</if>
<if test="dto.orderType != null and dto.orderType != ''">
and o.order_type = #{dto.orderType}
</if>
<if test="dto.apptIdOrApptRuleIdNotNull != null and dto.apptIdOrApptRuleIdNotNull == true">
and (o.appt_id is not null or o.appt_rule_id is not null)
</if>
<if test="dto.productName != null and dto.productName != '' ">
and a.product_name LIKE concat('%',#{dto.productName},'%')
</if>
@@ -469,6 +613,22 @@
<if test="dto.merchantId != null">
and o.merchant_id = #{dto.merchantId}
</if>
<if test="dto.userDeptIds != null and dto.userDeptIds.size() != 0">
<choose>
<when test="dto.isInclude != null and dto.isInclude == true">
and o.user_dept_id in
</when>
<when test="dto.isInclude != null and dto.isInclude == false">
and o.user_dept_id not in
</when>
<otherwise>
and o.user_dept_id in
</otherwise>
</choose>
<foreach collection="dto.userDeptIds" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
<if test="dto.payTimeList != null and dto.payTimeList.length == 2">
and o.pay_time between #{dto.payTimeList[0]} and #{dto.payTimeList[1]}
</if>
@@ -497,6 +657,28 @@
<if test="dto.merchantId != null">
and o.merchant_id = #{dto.merchantId}
</if>
<if test="dto.orderType != null and dto.orderType != '' ">
and o.order_type = #{dto.orderType}
</if>
<if test="dto.apptIdOrApptRuleIdNotNull != null and dto.apptIdOrApptRuleIdNotNull == true ">
and (o.appt_id is not null or o.appt_rule_id is not null)
</if>
<if test="dto.userDeptIds != null and dto.userDeptIds.size() != 0">
<choose>
<when test="dto.isInclude != null and dto.isInclude == true">
and o.user_dept_id in
</when>
<when test="dto.isInclude != null and dto.isInclude == false">
and o.user_dept_id not in
</when>
<otherwise>
and o.user_dept_id in
</otherwise>
</choose>
<foreach collection="dto.userDeptIds" item="v" open="(" close=")" separator=",">
#{v}
</foreach>
</if>
<if test="dto.orderStatusCodes != null and dto.orderStatusCodes.size() != 0">
and o.order_status in
<foreach collection="dto.orderStatusCodes" item="v" open="(" close=")" separator=",">
@@ -36,6 +36,10 @@
a.auto_refund_config_id,
a.auto_refund_rule_type,
a.auto_refund_deadline,
a.expired_refund_config_id,
a.expired_refund_usage_end_time,
a.expired_refund_deadline,
a.expired_refund_restore_inventory,
a.sys_type,
a.total_price,
a.extra_price,
@@ -27,6 +27,7 @@ import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
@@ -57,6 +58,60 @@ class HardApptReservationServiceImplTest {
appt, APPT_DATE, LocalDateTime.of(2026, 7, 21, 10, 0, 0, 1)).available());
}
/** 动态预约直接快照已校验餐段的实际结束时间。 */
@Test
void dynamicReservationUsesValidatedSegmentEndTime() {
HardApptEntity dynamic = timeWindowAppt();
dynamic.setId(1L);
dynamic.setMchId(10L);
dynamic.setHourType("1");
dynamic.setStatus(YesNoEnum.YES.getCode());
var validation = reservationService(dynamic, 1, List.of())
.validateReservation(10L, 1L, null, "1", APPT_DATE, null,
LocalDateTime.of(2026, 7, 20, 18, 0));
assertEquals(LocalDateTime.of(2026, 7, 21, 10, 0), validation.usageEndDateTime());
}
/** 周期预约按有效 hard_appt 餐段解析结束时间,并支持结束时间落到用餐日次日。 */
@Test
void weeklyReservationResolvesCrossDaySegmentEndTime() {
HardApptEntity weekly = weeklyAppt(1L, 10L);
weekly.setHourType("1");
weekly.setEndTimeType(HardApptTimeTypeEnum.TOMORROW.getType());
weekly.setEndTime(LocalTime.of(2, 0));
HardApptCycleRuleVo rule = cycleRule(100L, 10L, 5, LocalTime.of(17, 0),
7, LocalTime.of(22, 0), 2, 7, "1");
var validation = reservationService(weekly, 0, List.of(rule))
.validateReservation(10L, null, 100L, "1", LocalDate.of(2026, 7, 28), null,
LocalDateTime.of(2026, 7, 24, 18, 0));
assertEquals(LocalDateTime.of(2026, 7, 29, 2, 0), validation.usageEndDateTime());
}
/** 周期餐段无法唯一匹配时不生成结束时间,避免按错误规则自动退款。 */
@Test
void weeklyReservationOmitsUsageEndTimeWhenSegmentIsAmbiguous() {
HardApptEntity weekly = weeklyAppt(1L, 10L);
weekly.setHourType("1");
weekly.setEndTimeType(HardApptTimeTypeEnum.TODAY.getType());
weekly.setEndTime(LocalTime.of(13, 0));
HardApptEntity duplicate = weeklyAppt(2L, 10L);
duplicate.setHourType("1");
duplicate.setEndTimeType(HardApptTimeTypeEnum.TODAY.getType());
duplicate.setEndTime(LocalTime.of(14, 0));
HardApptCycleRuleVo rule = cycleRule(100L, 10L, 5, LocalTime.of(17, 0),
7, LocalTime.of(22, 0), 2, 7, "1");
var validation = reservationService(weekly, 0, List.of(rule), List.of(weekly, duplicate))
.validateReservation(10L, null, 100L, "1", LocalDate.of(2026, 7, 28), null,
LocalDateTime.of(2026, 7, 24, 18, 0));
assertNull(validation.usageEndDateTime());
}
@Test
void configurableCycleIncludesBothBoundaryInstants() {
HardApptCycleRuleVo rule = cycleRule(100L, 10L, 5, LocalTime.of(17, 0),
@@ -347,12 +402,28 @@ class HardApptReservationServiceImplTest {
private HardApptReservationServiceImpl reservationService(HardApptEntity appt, long mappingCount,
List<HardApptCycleRuleVo> rules) {
return reservationService(appt, mappingCount, rules, List.of(appt));
}
/**
* 构造可控制实际餐段查询结果的预约服务。
*
* @param appt 动态校验按主键读取的餐段,同时决定商户预约模式
* @param mappingCount 动态日期与餐段映射数量
* @param rules 当前商户有效周期规则
* @param enabledAppts 周期模式按商户和餐段查询到的有效 hard_appt 列表
* @return 注入测试替身的预约校验服务
*/
private HardApptReservationServiceImpl reservationService(HardApptEntity appt, long mappingCount,
List<HardApptCycleRuleVo> rules,
List<HardApptEntity> enabledAppts) {
HardApptService apptService = mock(HardApptService.class);
HardApptTimeService timeService = mock(HardApptTimeService.class);
HardApptCycleRuleService cycleRuleService = mock(HardApptCycleRuleService.class);
HardApptMerchantModeService merchantModeService = merchantModeService(
HardApptReserveModeEnum.valueOfByType(appt.getReserveMode()));
when(apptService.getById(appt.getId())).thenReturn(appt);
when(apptService.list(org.mockito.ArgumentMatchers.<Wrapper<HardApptEntity>>any())).thenReturn(enabledAppts);
when(timeService.count(any())).thenReturn(mappingCount);
when(cycleRuleService.listEnabledConfigurations(appt.getMchId())).thenReturn(rules);
return new HardApptReservationServiceImpl(apptService, timeService, cycleRuleService, merchantModeService);
@@ -5,12 +5,14 @@ import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum;
import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTaskStatusEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTaskTypeEnum;
import com.bsd.pjcloud.hard.api.constant.refund.HardAutoRefundTriggerTypeEnum;
import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundAttemptLogEntity;
import com.bsd.pjcloud.hard.api.entity.order.HardAutoRefundTaskEntity;
import com.bsd.pjcloud.hard.mapper.order.HardAfterSaleMapper;
import com.bsd.pjcloud.hard.mapper.order.HardAutoRefundAttemptLogMapper;
import com.bsd.pjcloud.hard.mapper.order.HardAutoRefundTaskMapper;
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
import com.bsd.pjcloud.hard.service.HardAfterSaleService;
import com.bsd.pjcloud.wallet.api.entity.recharge.WalletRechargeEntity;
import com.bsd.pjcloud.wallet.api.feign.RemoteMchAutoRefundConfigService;
@@ -30,8 +32,13 @@ import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -68,6 +75,9 @@ class HardAutoRefundTaskServiceImplTest {
/** 自动退款任务 Mapper Mock。 */
@Mock
private HardAutoRefundTaskMapper taskMapper;
/** 订单 Mapper Mock,用于候选订单和到期退款流程的订单锁查询。 */
@Mock
private HardOrderMapper orderMapper;
/** 被测自动退款任务服务。 */
private HardAutoRefundTaskServiceImpl service;
@@ -77,11 +87,11 @@ class HardAutoRefundTaskServiceImplTest {
*/
@BeforeEach
void setUp() {
service = new HardAutoRefundTaskServiceImpl(attemptLogMapper, afterSaleMapper, afterSaleServiceProvider,
configService, walletRechargeService, mchService, userService, lockTemplate);
service = new HardAutoRefundTaskServiceImpl(attemptLogMapper, afterSaleMapper, orderMapper,
afterSaleServiceProvider, configService, walletRechargeService, mchService, userService, lockTemplate);
ReflectionTestUtils.setField(service, "baseMapper", taskMapper);
when(attemptLogMapper.insert(any(HardAutoRefundAttemptLogEntity.class))).thenReturn(1);
when(taskMapper.updateById(any(HardAutoRefundTaskEntity.class))).thenReturn(1);
lenient().when(attemptLogMapper.insert(any(HardAutoRefundAttemptLogEntity.class))).thenReturn(1);
lenient().when(taskMapper.updateById(any(HardAutoRefundTaskEntity.class))).thenReturn(1);
}
/**
@@ -165,6 +175,32 @@ class HardAutoRefundTaskServiceImplTest {
assertEquals(HardAutoRefundTaskStatusEnum.SUCCESS.getStatus(), task.getStatus());
}
/** 管理端逐单退款只有在任务最终成功时才允许接口返回成功。 */
@Test
void expiredManualRefundShouldReturnOnlyAfterTaskSuccess() {
HardAutoRefundTaskEntity pending = expiredTask(HardAutoRefundTaskStatusEnum.PENDING.getStatus());
HardAutoRefundTaskEntity completed = expiredTask(HardAutoRefundTaskStatusEnum.SUCCESS.getStatus());
HardAutoRefundTaskServiceImpl spyService = spy(service);
when(taskMapper.selectById(1L)).thenReturn(pending, completed);
doNothing().when(spyService).execute(eq(1L), eq(HardAutoRefundTriggerTypeEnum.EXPIRED_MANUAL), eq(99L));
spyService.manualExpiredRefund(1L, 99L);
verify(spyService).execute(1L, HardAutoRefundTriggerTypeEnum.EXPIRED_MANUAL, 99L);
}
/** 锁竞争或资金失败未把任务推进为成功时,逐单退款接口必须返回失败而不是误报成功。 */
@Test
void expiredManualRefundShouldFailWhenTaskDidNotComplete() {
HardAutoRefundTaskEntity pending = expiredTask(HardAutoRefundTaskStatusEnum.PENDING.getStatus());
HardAutoRefundTaskEntity failed = expiredTask(HardAutoRefundTaskStatusEnum.RETRY_EXHAUSTED.getStatus());
HardAutoRefundTaskServiceImpl spyService = spy(service);
when(taskMapper.selectById(1L)).thenReturn(pending, failed);
doNothing().when(spyService).execute(eq(1L), eq(HardAutoRefundTriggerTypeEnum.EXPIRED_MANUAL), eq(99L));
assertThrows(RuntimeException.class, () -> spyService.manualExpiredRefund(1L, 99L));
}
/**
* 构造固定订单和售后单的自动退款任务。
*
@@ -177,4 +213,17 @@ class HardAutoRefundTaskServiceImplTest {
.configId(5L).ruleType("PAY_TIME_LIMIT").status(HardAutoRefundTaskStatusEnum.PENDING.getStatus())
.autoAttemptCount(autoAttempts).totalAttemptCount(totalAttempts).build();
}
/**
* 构造已到期的每日菜谱过期退款任务。
*
* @param status 当前任务状态
* @return 可供管理端逐单退款状态复查使用的任务
*/
private HardAutoRefundTaskEntity expiredTask(String status) {
return HardAutoRefundTaskEntity.builder().id(1L).orderId(3L).mchId(4L).configId(5L)
.taskType(HardAutoRefundTaskTypeEnum.EXPIRED_RESERVE.getType())
.triggerTime(LocalDateTime.now().minusMinutes(1)).status(status)
.autoAttemptCount(0).totalAttemptCount(0).build();
}
}
@@ -8,14 +8,19 @@ import com.bsd.pjcloud.hard.api.dto.hard.HandCancelCodeDto;
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
import com.bsd.pjcloud.hard.api.vo.order.HardCarVo;
import com.bsd.pjcloud.hard.mapper.order.HardOrderMapper;
import com.bsd.pjcloud.hard.service.HardApptReservationService;
import com.bsd.pjcloud.hard.service.HardCarService;
import com.bsd.pjcloud.wallet.api.vo.mch.MchAutoRefundConfigVo;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Answers.CALLS_REAL_METHODS;
import static org.mockito.ArgumentMatchers.anyLong;
@@ -78,6 +83,31 @@ class HardOrderServiceImplVerificationTest {
assertEquals("该预约订单商品已全部退款,不能核销", exception.getMessage());
}
/**
* 旧免审退款规则损坏时只能放弃旧快照,不能阻断独立的过期预约退款快照。
*/
@Test
void expiredRefundSnapshotDoesNotDependOnLegacyRuleValidity() {
HardOrderEntity order = new HardOrderEntity();
MchAutoRefundConfigVo config = new MchAutoRefundConfigVo();
config.setId(7L);
config.setEnabled("1");
config.setRuleType("BROKEN_RULE");
config.setExpiredReserveRefundEnabled("1");
config.setExpiredReserveRefundDelayHours(2);
config.setExpiredReserveRefundRestoreInventory("1");
HardApptReservationService.ReservationValidation validation = new HardApptReservationService.ReservationValidation(
null, "1", LocalDate.of(2026, 8, 23), 9L,
LocalDateTime.of(2026, 8, 22, 22, 0), LocalDateTime.of(2026, 8, 23, 13, 0));
ReflectionTestUtils.invokeMethod(service, "applyDailyMenuAutoRefundSnapshot", order, validation, config);
assertNull(order.getAutoRefundConfigId());
assertEquals(7L, order.getExpiredRefundConfigId());
assertEquals(LocalDateTime.of(2026, 8, 23, 15, 0), order.getExpiredRefundDeadline());
assertEquals("1", order.getExpiredRefundRestoreInventory());
}
/**
* 构造旧消费机通用核销接口请求。
*
@@ -51,4 +51,16 @@ public class MchAutoRefundConfigDto {
*/
@Schema(description = "支付后可自动退款小时数")
private Integer payTimeLimitHours;
/** 过期预约订单自动退款开关,1开启、0关闭。 */
@Schema(description = "过期预约自动退款开关")
private String expiredReserveRefundEnabled;
/** 餐段结束后延迟退款小时数,单位小时,允许1至168。 */
@Schema(description = "过期预约退款延迟小时数")
private Integer expiredReserveRefundDelayHours;
/** 过期预约退款是否恢复库存,1恢复、0不恢复。 */
@Schema(description = "过期预约退款是否退库存")
private String expiredReserveRefundRestoreInventory;
}
@@ -18,6 +18,12 @@ import org.hibernate.validator.constraints.Length;
@Data
public class WalletRechargeRefundDto {
/** 售后申请免审自动退款任务场景编码,持久化协议值为 10。 */
public static final String AUTO_REFUND_TASK_TYPE_AFTER_SALE = "10";
/** 每日菜谱过期预约自动退款任务场景编码,持久化协议值为 20。 */
public static final String AUTO_REFUND_TASK_TYPE_EXPIRED_RESERVE = "20";
/**
* 退款到账的钱包 ID,重复请求必须与首笔退款记录保持一致。
*/
@@ -60,4 +66,9 @@ public class WalletRechargeRefundDto {
* 自动退款配置主键;人工审核退款为空,非空时 Wallet 必须锁定配置并确认开关仍开启。
*/
private Long autoRefundConfigId;
/**
* 自动退款任务场景编码;过期预约任务为 20,空值兼容历史售后免审调用,人工退款不得填写。
*/
private String autoRefundTaskType;
}
@@ -67,6 +67,18 @@ public class MchAutoRefundConfigEntity extends Model<MchAutoRefundConfigEntity>
@Schema(description = "支付后可自动退款小时数")
private Integer payTimeLimitHours;
/** 过期预约订单自动退款开关,持久化值为1开启、0关闭;与用户免审退款开关独立。 */
@Schema(description = "过期预约自动退款开关")
private String expiredReserveRefundEnabled;
/** 预约餐段结束后的自动退款延迟小时数,单位小时,允许1至168。 */
@Schema(description = "过期预约退款延迟小时数")
private Integer expiredReserveRefundDelayHours;
/** 过期预约退款是否恢复每日菜谱库存,配置保存时快照到订单和任务。 */
@Schema(description = "过期预约退款是否退库存")
private String expiredReserveRefundRestoreInventory;
/**
* 创建人账号,由公共字段填充器在新增时写入。
*/
@@ -62,4 +62,13 @@ public interface MchAutoRefundConfigService extends IService<MchAutoRefundConfig
* @return 已锁定且可用的配置
*/
MchAutoRefundConfigEntity requireEnabledForUpdate(Long configId);
/**
* 按自动退款任务场景锁定配置并校验对应开关;过期预约只检查独立的过期开关。
*
* @param configId 订单快照和任务引用的配置主键
* @param autoRefundTaskType 自动退款任务场景编码,过期预约为 20
* @return 已锁定且可用的配置
*/
MchAutoRefundConfigEntity requireEnabledForUpdate(Long configId, String autoRefundTaskType);
}
@@ -9,6 +9,7 @@ import com.bsd.pjcloud.wallet.api.constant.mch.MchAutoRefundRuleTypeEnum;
import com.bsd.pjcloud.wallet.api.dto.MchManageDto;
import com.bsd.pjcloud.wallet.api.dto.mch.MchAutoRefundConfigBatchQueryDto;
import com.bsd.pjcloud.wallet.api.dto.mch.MchAutoRefundConfigDto;
import com.bsd.pjcloud.wallet.api.dto.recharge.WalletRechargeRefundDto;
import com.bsd.pjcloud.wallet.api.entity.mch.MchAutoRefundConfigEntity;
import com.bsd.pjcloud.wallet.api.vo.MchManageVo;
import com.bsd.pjcloud.wallet.api.vo.mch.MchAutoRefundConfigVo;
@@ -52,6 +53,12 @@ public class MchAutoRefundConfigServiceImpl extends ServiceImpl<MchAutoRefundCon
*/
private static final int MAX_PAY_TIME_LIMIT_HOURS = 168;
/** 过期预约退款默认延迟,单位小时。 */
private static final int DEFAULT_EXPIRED_RESERVE_DELAY_HOURS = 2;
/** 过期预约退款默认恢复库存。 */
private static final String DEFAULT_EXPIRED_RESERVE_RESTORE_INVENTORY = "1";
/**
* 商户服务,用于管理接口校验目标商户属于当前用户的数据范围。
*/
@@ -81,7 +88,9 @@ public class MchAutoRefundConfigServiceImpl extends ServiceImpl<MchAutoRefundCon
@Transactional(rollbackFor = Exception.class)
public MchAutoRefundConfigVo saveOrUpdateConfig(MchAutoRefundConfigDto dto) {
validateMerchantScope(dto.getMchId());
validateConfig(dto.getOrderType(), dto.getEnabled(), dto.getRuleType(), dto.getPayTimeLimitHours());
validateConfig(dto.getOrderType(), dto.getEnabled(), dto.getRuleType(), dto.getPayTimeLimitHours(),
dto.getExpiredReserveRefundEnabled(), dto.getExpiredReserveRefundDelayHours(),
dto.getExpiredReserveRefundRestoreInventory());
MchAutoRefundConfigEntity exact = baseMapper.selectExactForUpdate(dto.getMchId(), dto.getOrderType());
MchAutoRefundConfigEntity target;
@@ -106,6 +115,10 @@ public class MchAutoRefundConfigServiceImpl extends ServiceImpl<MchAutoRefundCon
target.setEnabled(dto.getEnabled());
target.setRuleType(dto.getRuleType());
target.setPayTimeLimitHours(resolvePayTimeLimitHours(dto.getRuleType(), dto.getPayTimeLimitHours()));
target.setExpiredReserveRefundEnabled(resolveYesNo(dto.getExpiredReserveRefundEnabled(), "0"));
target.setExpiredReserveRefundDelayHours(resolveExpiredDelay(dto.getExpiredReserveRefundDelayHours()));
target.setExpiredReserveRefundRestoreInventory(resolveYesNo(dto.getExpiredReserveRefundRestoreInventory(),
DEFAULT_EXPIRED_RESERVE_RESTORE_INVENTORY));
if (target.getId() == null) {
if (baseMapper.insert(target) != 1) {
throw new RuntimeException("新增自动退款配置失败");
@@ -178,11 +191,34 @@ public class MchAutoRefundConfigServiceImpl extends ServiceImpl<MchAutoRefundCon
*/
@Override
public MchAutoRefundConfigEntity requireEnabledForUpdate(Long configId) {
return requireEnabledForUpdate(configId, null);
}
/**
* 自动退款资金入账前按任务场景执行最终开关校验,配置行锁覆盖配置修改和本次幂等检查。
*
* @param configId 订单快照和任务引用的配置主键
* @param autoRefundTaskType 自动退款任务场景编码,过期预约为 20
* @return 已锁定且可用的配置
*/
@Override
public MchAutoRefundConfigEntity requireEnabledForUpdate(Long configId, String autoRefundTaskType) {
MchAutoRefundConfigEntity config = lockForUpdate(configId);
if (!Objects.equals(config.getEnabled(), YesNoEnum.YES.getCode())) {
if (autoRefundTaskType != null
&& !Objects.equals(autoRefundTaskType, WalletRechargeRefundDto.AUTO_REFUND_TASK_TYPE_AFTER_SALE)
&& !Objects.equals(autoRefundTaskType, WalletRechargeRefundDto.AUTO_REFUND_TASK_TYPE_EXPIRED_RESERVE)) {
throw new RuntimeException("自动退款任务场景无效");
}
if (Objects.equals(autoRefundTaskType, WalletRechargeRefundDto.AUTO_REFUND_TASK_TYPE_EXPIRED_RESERVE)) {
if (!Objects.equals(config.getExpiredReserveRefundEnabled(), YesNoEnum.YES.getCode())) {
throw new RuntimeException("过期预约自动退款开关已关闭");
}
} else if (!Objects.equals(config.getEnabled(), YesNoEnum.YES.getCode())) {
throw new RuntimeException("自动退款开关已关闭");
}
validateConfig(config.getOrderType(), config.getEnabled(), config.getRuleType(), config.getPayTimeLimitHours());
validateConfig(config.getOrderType(), config.getEnabled(), config.getRuleType(), config.getPayTimeLimitHours(),
config.getExpiredReserveRefundEnabled(), config.getExpiredReserveRefundDelayHours(),
config.getExpiredReserveRefundRestoreInventory());
return config;
}
@@ -221,7 +257,8 @@ public class MchAutoRefundConfigServiceImpl extends ServiceImpl<MchAutoRefundCon
* @param ruleType 截止规则名称
* @param payTimeLimitHours 支付后限时小时数
*/
private void validateConfig(String orderType, String enabled, String ruleType, Integer payTimeLimitHours) {
private void validateConfig(String orderType, String enabled, String ruleType, Integer payTimeLimitHours,
String expiredEnabled, Integer expiredDelayHours, String expiredRestoreInventory) {
validateOrderType(orderType);
if (!Objects.equals(enabled, YesNoEnum.YES.getCode()) && !Objects.equals(enabled, YesNoEnum.NO.getCode())) {
throw new RuntimeException("自动退款开关值无效");
@@ -236,6 +273,28 @@ public class MchAutoRefundConfigServiceImpl extends ServiceImpl<MchAutoRefundCon
throw new RuntimeException("支付后自动退款时限必须在1至168小时之间");
}
}
if (expiredEnabled != null && !Objects.equals(expiredEnabled, YesNoEnum.YES.getCode())
&& !Objects.equals(expiredEnabled, YesNoEnum.NO.getCode())) {
throw new RuntimeException("过期预约自动退款开关值无效");
}
int expiredDelay = resolveExpiredDelay(expiredDelayHours);
if (expiredDelay < MIN_PAY_TIME_LIMIT_HOURS || expiredDelay > MAX_PAY_TIME_LIMIT_HOURS) {
throw new RuntimeException("过期预约退款延迟必须在1至168小时之间");
}
if (expiredRestoreInventory != null && !Objects.equals(expiredRestoreInventory, YesNoEnum.YES.getCode())
&& !Objects.equals(expiredRestoreInventory, YesNoEnum.NO.getCode())) {
throw new RuntimeException("过期预约退库存开关值无效");
}
}
/** 解析过期退款延迟,兼容历史配置行新增字段为空的场景。 */
private int resolveExpiredDelay(Integer value) {
return value == null ? DEFAULT_EXPIRED_RESERVE_DELAY_HOURS : value;
}
/** 解析开关默认值,保证历史配置读取后具备稳定协议值。 */
private String resolveYesNo(String value, String defaultValue) {
return value == null ? defaultValue : value;
}
/**
@@ -290,7 +290,12 @@ public class WalletRechargeServiceImpl extends ServiceImpl<WalletRechargeMapper,
}
if (dto.getAutoRefundConfigId() != null) {
// 配置行仍由当前事务持有,二次读取不会改变锁顺序;关闭接口返回后不会有新资金请求通过这里。
mchAutoRefundConfigService.requireEnabledForUpdate(dto.getAutoRefundConfigId());
if (dto.getAutoRefundTaskType() == null) {
// 未携带场景的历史调用继续使用原有用户免审退款校验契约。
mchAutoRefundConfigService.requireEnabledForUpdate(dto.getAutoRefundConfigId());
} else {
mchAutoRefundConfigService.requireEnabledForUpdate(dto.getAutoRefundConfigId(), dto.getAutoRefundTaskType());
}
}
WalletRechargeEntity recharge = WalletRechargeEntity.builder()
.walletId(wallet.getId())
@@ -123,6 +123,27 @@ class WalletRechargeServiceImplTest {
locks.verify(configService).requireEnabledForUpdate(30L);
}
/**
* 过期预约任务必须使用独立开关终审,不能因为用户免审退款开关关闭而误拒绝或放行。
*/
@Test
void expiredReserveRefundShouldUseIndependentSwitch() {
MchAutoRefundConfigEntity config = new MchAutoRefundConfigEntity();
config.setId(30L);
config.setEnabled(YesNoEnum.NO.getCode());
config.setExpiredReserveRefundEnabled(YesNoEnum.YES.getCode());
when(configService.lockForUpdate(30L)).thenReturn(config);
when(configService.requireEnabledForUpdate(30L,
WalletRechargeRefundDto.AUTO_REFUND_TASK_TYPE_EXPIRED_RESERVE)).thenReturn(config);
when(rechargeMapper.selectOne(any())).thenReturn(null);
when(rechargeMapper.insert(any(WalletRechargeEntity.class))).thenReturn(1);
service.refund(refundRequest(100, 30L, WalletRechargeRefundDto.AUTO_REFUND_TASK_TYPE_EXPIRED_RESERVE));
verify(configService).requireEnabledForUpdate(30L,
WalletRechargeRefundDto.AUTO_REFUND_TASK_TYPE_EXPIRED_RESERVE);
}
/**
* 开关关闭后,已按同一售后单到账的请求仍应返回原记录,不再次终审开关或增加余额。
*/
@@ -198,8 +219,21 @@ class WalletRechargeServiceImplTest {
* @return 钱包退款请求
*/
private WalletRechargeRefundDto refundRequest(Integer price, Long configId) {
return refundRequest(price, configId, null);
}
/**
* 构造带自动任务场景的退款请求。
*
* @param price 退款金额,单位为分
* @param configId 自动退款配置 ID;人工流程为空
* @param taskType 自动退款场景编码
* @return 钱包退款请求
*/
private WalletRechargeRefundDto refundRequest(Integer price, Long configId, String taskType) {
return WalletRechargeRefundDto.builder().walletId(20L).orderId(40L).afterSaleId(50L)
.refundCode("hard_40_50").reason("订单退款").price(price).autoRefundConfigId(configId).build();
.refundCode("hard_40_50").reason("订单退款").price(price).autoRefundConfigId(configId)
.autoRefundTaskType(taskType).build();
}
/**