feat:商品预约导出
feat:个人菜谱预约统计 feat:预定扣库存 feat:过期订单自动退款
This commit is contained in:
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 '稳定钱包退款业务号,过期任务按订单生成';
|
||||
+11
@@ -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查询菜品
|
||||
|
||||
+3
-2
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
+14
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询
|
||||
*
|
||||
|
||||
+33
-4
@@ -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);
|
||||
}
|
||||
}
|
||||
+9
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
+155
-16
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
+3
@@ -33,6 +33,9 @@ public enum HardAfterSaleOperateTypeEnum {
|
||||
/** 系统根据订单快照和商户配置免审完成退款。 */
|
||||
SYSTEM_AUTO_REFUND("31", "系统自动退款"),
|
||||
|
||||
/** 系统按每日菜谱预约过期快照创建整单退款。 */
|
||||
SYSTEM_EXPIRED_RESERVE_REFUND("32", "系统过期预约退款"),
|
||||
|
||||
/** 会员取消尚未审核的退款申请。 */
|
||||
MEMBER_CANCEL("40", "会员取消退款"),
|
||||
;
|
||||
|
||||
+6
-1
@@ -21,7 +21,12 @@ public enum HardAutoRefundTriggerTypeEnum {
|
||||
/**
|
||||
* 管理端技术人员对耗尽任务执行的一次额外尝试。
|
||||
*/
|
||||
MANUAL("30", "人工技术重试", false);
|
||||
MANUAL("30", "人工技术重试", false),
|
||||
|
||||
/**
|
||||
* 管理端业务人员对已到期候选订单确认后执行的逐单退款,不复用技术重试权限。
|
||||
*/
|
||||
EXPIRED_MANUAL("40", "后台逐单过期退款", false);
|
||||
|
||||
/**
|
||||
* 数据库存储的稳定触发编码。
|
||||
|
||||
+4
@@ -43,6 +43,10 @@ public class HardAutoRefundTaskPageDto {
|
||||
@Schema(description = "任务状态")
|
||||
private String status;
|
||||
|
||||
/** 任务场景编码;候选列表固定筛选过期预约任务。 */
|
||||
@Schema(description = "任务场景")
|
||||
private String taskType;
|
||||
|
||||
/**
|
||||
* 任务创建时间闭区间,数组长度应为 2。
|
||||
*/
|
||||
|
||||
+6
@@ -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;
|
||||
|
||||
/** 预计使用日期范围。 */
|
||||
|
||||
+15
-2
@@ -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。
|
||||
*/
|
||||
|
||||
+16
@@ -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;
|
||||
|
||||
/**
|
||||
* 订单设备类型
|
||||
*/
|
||||
|
||||
+8
@@ -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;
|
||||
}
|
||||
+29
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询指定任务全部只读尝试日志。
|
||||
*
|
||||
|
||||
+92
-2
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
+16
@@ -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);
|
||||
}
|
||||
+27
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
+27
@@ -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);
|
||||
|
||||
/**
|
||||
* 同意售后
|
||||
*/
|
||||
|
||||
+2
-1
@@ -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) {
|
||||
}
|
||||
}
|
||||
+19
@@ -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);
|
||||
|
||||
/**
|
||||
* 按售后单查询自动退款任务。
|
||||
*
|
||||
|
||||
+5
-13
@@ -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);
|
||||
}
|
||||
+10
@@ -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);
|
||||
|
||||
/**
|
||||
* 核销硬件二维码
|
||||
*/
|
||||
|
||||
+30
-2
@@ -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,
|
||||
|
||||
+1
-2
@@ -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);
|
||||
|
||||
+409
-58
@@ -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;
|
||||
}
|
||||
|
||||
+229
-5
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
+1634
File diff suppressed because it is too large.
Load diff
+304
-692
File diff suppressed because it is too large.
Load diff
+335
-33
@@ -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;
|
||||
}
|
||||
// 查询购物车
|
||||
|
||||
+71
@@ -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 <= #{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 <> 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>
|
||||
+182
@@ -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=",">
|
||||
|
||||
+4
@@ -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,
|
||||
|
||||
+71
@@ -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);
|
||||
|
||||
+53
-4
@@ -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();
|
||||
}
|
||||
}
|
||||
+30
@@ -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());
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造旧消费机通用核销接口请求。
|
||||
*
|
||||
|
||||
+12
@@ -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;
|
||||
}
|
||||
+11
@@ -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;
|
||||
}
|
||||
+12
@@ -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;
|
||||
|
||||
/**
|
||||
* 创建人账号,由公共字段填充器在新增时写入。
|
||||
*/
|
||||
|
||||
+9
@@ -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);
|
||||
}
|
||||
+63
-4
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+6
-1
@@ -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())
|
||||
|
||||
+35
-1
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user