feat:商品预约导出

feat:个人菜谱预约统计
feat:预定扣库存
feat:过期订单自动退款
This commit is contained in:
dzc committed 2026-08-23 11:00:02 +08:00
1 parent e1cf5d5b90
commit e4d8b4f354
11 files changed
+637

No files matched your search

@@ -0,0 +1,129 @@
package com.bsd.pjcloud.canteen.service.impl;
import com.bsd.pjcloud.canteen.dto.CanteenSpuUpdateStockReqDTO;
import com.bsd.pjcloud.canteen.entity.CanteenSpuEntity;
import com.bsd.pjcloud.canteen.mapper.CanteenSpuMapper;
import com.bsd.pjcloud.canteen.service.CanteenSpuService;
import com.bsd.pjcloud.canteen.util.CanteenMerchantScopeHelper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.time.Duration;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 每日菜谱基础 SPU 库存幂等状态机测试,重点覆盖退款任务本地回滚后的再次执行。
*/
class CanteenSpuServiceImplTest {
/** 被测库存服务。 */
private CanteenSpuService service;
/** 基础 SPU Mapper,用于验证库存增量是否真实执行。 */
private CanteenSpuMapper spuMapper;
/** Redis 字符串操作,用于模拟和验证库存业务状态迁移。 */
private ValueOperations<String, String> valueOperations;
/**
* 为每个用例建立独立的事务同步上下文,模拟 Hardware 远程调用进入食堂本地事务后的状态写入。
*/
@BeforeEach
void setUp() {
spuMapper = mock(CanteenSpuMapper.class);
StringRedisTemplate redisTemplate = mock(StringRedisTemplate.class);
valueOperations = mock(ValueOperations.class);
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
CanteenSpuServiceImpl target = new CanteenSpuServiceImpl(redisTemplate, null, null, null, null, null,
mock(CanteenMerchantScopeHelper.class));
ReflectionTestUtils.setField(target, "baseMapper", spuMapper);
service = target;
TransactionSynchronizationManager.initSynchronization();
}
/** 清理线程级事务同步状态,避免测试间相互污染。 */
@AfterEach
void tearDown() {
TransactionSynchronizationManager.clearSynchronization();
}
/**
* 已确认应用后完成补偿的售后库存允许再次 APPLY;否则钱包成功后的任务重试会丢失库存恢复。
*/
@Test
void compensatedRefundInventoryShouldAllowRetry() {
CanteenSpuUpdateStockReqDTO dto = stockRequest("APPLY", 2, 10);
when(valueOperations.get("canteen:spu:order-stock:HARD_DAILY_MENU_AFTER_SALE:9"))
.thenReturn("COMPENSATED");
when(spuMapper.selectById(1L)).thenReturn(spu(10));
when(spuMapper.updateStockIfMatch(1L, 2, 10)).thenReturn(1);
assertTrue(service.updateSkuStockIdempotent(dto).isOk());
verify(spuMapper).updateStockIfMatch(1L, 2, 10);
verify(valueOperations).set("canteen:spu:order-stock:HARD_DAILY_MENU_AFTER_SALE:9", "APPLIED",
Duration.ofDays(7));
}
/**
* 已执行 APPLY 的回滚必须标记为可重试补偿态,区别于阻断网络延迟 APPLY 的未确认回滚态。
*/
@Test
void confirmedApplyRollbackShouldBecomeCompensated() {
CanteenSpuUpdateStockReqDTO dto = stockRequest("ROLLBACK", 2, 10);
when(valueOperations.get("canteen:spu:order-stock:HARD_DAILY_MENU_AFTER_SALE:9"))
.thenReturn("APPLIED");
when(spuMapper.selectById(1L)).thenReturn(spu(12));
when(spuMapper.updateStock(1L, -2)).thenReturn(1);
assertTrue(service.updateSkuStockIdempotent(dto).isOk());
verify(spuMapper).updateStock(1L, -2);
verify(valueOperations).set("canteen:spu:order-stock:HARD_DAILY_MENU_AFTER_SALE:9", "COMPENSATED",
Duration.ofDays(7));
}
/**
* 构造单个基础 SPU 的幂等库存请求。
*
* @param operation APPLY 或 ROLLBACK
* @param increment 原 APPLY 使用的库存增量
* @param beforeStock 当前业务尝试的操作前库存快照
* @return 可直接调用库存服务的请求
*/
private CanteenSpuUpdateStockReqDTO stockRequest(String operation, int increment, int beforeStock) {
CanteenSpuUpdateStockReqDTO.Item item = new CanteenSpuUpdateStockReqDTO.Item();
item.setId(1L);
item.setIncrCount(increment);
item.setBeforeStock(beforeStock);
CanteenSpuUpdateStockReqDTO dto = new CanteenSpuUpdateStockReqDTO();
dto.setBizId("HARD_DAILY_MENU_AFTER_SALE:9");
dto.setOperation(operation);
dto.setItems(List.of(item));
return dto;
}
/**
* 构造当前库存快照。
*
* @param stock 当前基础 SPU 库存
* @return 仅包含本用例所需字段的实体
*/
private CanteenSpuEntity spu(int stock) {
CanteenSpuEntity entity = new CanteenSpuEntity();
entity.setId(1L);
entity.setStock(stock);
entity.setName("测试菜品");
return entity;
}
}
@@ -0,0 +1,33 @@
package com.bsd.pjcloud.hard.api.constant.refund;
import lombok.Getter;
/**
* 自动退款任务场景;编码持久化到任务表并用于区分不同的配置和售后创建规则。
*/
@Getter
public enum HardAutoRefundTaskTypeEnum {
/** 用户申请免审退款后创建的技术自动退款任务。 */
AFTER_SALE("10", "售后申请自动退款"),
/** 每日菜谱预约餐段结束后创建的系统过期退款任务。 */
EXPIRED_RESERVE("20", "过期预约自动退款");
/** 数据库存储的稳定任务场景编码。 */
private final String type;
/** 管理端展示名称。 */
private final String description;
/**
* 构造任务场景协议项。
*
* @param type 数据库存储编码
* @param description 展示名称
*/
HardAutoRefundTaskTypeEnum(String type, String description) {
this.type = type;
this.description = description;
}
}
@@ -0,0 +1,42 @@
package com.bsd.pjcloud.hard.api.vo.order;
import lombok.Data;
/**
* 食堂预约菜品导出聚合记录。
*
* <p>同一模型承载商户部门聚合和商品聚合两种查询结果,商品聚合时额外填充商品字段;金额统一使用分。</p>
*/
@Data
public class HardCanteenProductApptExportRecord {
/** 预约使用日期,格式为 yyyy-MM-dd。 */
private String apptDate;
/** 用餐时间段协议编码。 */
private String hourType;
/** 食堂商户主键。 */
private Long merchantId;
/** 预约用户所属部门主键。 */
private Long userDeptId;
/** 商品主键;商户部门聚合记录为空。 */
private Long productId;
/** 订单快照中的商品名称;商户部门聚合记录为空。 */
private String productName;
/** 去重预约人数。 */
private Long payUserCount;
/** 去重预约次数。 */
private Long timeCount;
/** 预约商品总数量。 */
private Long payCount;
/** 预约商品总价,单位为分。 */
private Long totalPrice;
}
@@ -0,0 +1,20 @@
package com.bsd.pjcloud.hard.api.vo.order;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.time.LocalDate;
/**
* 指定用餐日期的用户预约统计。
*/
@Data
@EqualsAndHashCode(callSuper = true)
@Schema(description = "食堂用户每日预约统计")
public class HardCanteenUserApptDailyStatisticsVo extends HardCanteenUserApptStatisticsSummaryVo {
/** 统计对应的用餐日期,格式由接口序列化器输出为 yyyy-MM-dd。 */
@Schema(description = "用餐日期")
private LocalDate date;
}
@@ -0,0 +1,32 @@
package com.bsd.pjcloud.hard.api.vo.order;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 用户预约按餐段和菜品快照聚合的统计数据。
*/
@Data
@Schema(description = "食堂用户预约菜品统计")
public class HardCanteenUserApptDishStatisticsVo {
/** 餐段字典编码,空值表示历史订单未记录餐段。 */
@Schema(description = "餐段编码")
private String hourType;
/** 下单时固化的菜品名称;历史空值由服务层归一为未命名菜品。 */
@Schema(description = "菜品名称")
private String productName;
/** 菜品快照的总份数。 */
@Schema(description = "菜品份数")
private Long productCount = 0L;
/** 包含该菜品的去重预约订单数。 */
@Schema(description = "涉及订单数")
private Long orderCount = 0L;
/** 该菜品快照总价,单位为分。 */
@Schema(description = "菜品总价,单位为分")
private Long totalPrice = 0L;
}
@@ -0,0 +1,32 @@
package com.bsd.pjcloud.hard.api.vo.order;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 用户预约按餐段聚合的统计数据。
*/
@Data
@Schema(description = "食堂用户预约餐段统计")
public class HardCanteenUserApptMealStatisticsVo {
/** 餐段字典编码,属于 PC、App 与后端共用协议。 */
@Schema(description = "餐段编码")
private String hourType;
/** 该餐段去重后的用户-用餐日期数;每日表示人数,范围总计表示可与每日相加的订餐人次。 */
@Schema(description = "预约人数或人次")
private Long reservedUserCount = 0L;
/** 该餐段的有效预约订单数。 */
@Schema(description = "预约订单数")
private Long orderCount = 0L;
/** 该餐段购物车快照中的菜品总份数。 */
@Schema(description = "菜品份数")
private Long dishCount = 0L;
/** 该餐段有效预约商品总价,单位为分。 */
@Schema(description = "预约总价,单位为分")
private Long totalPrice = 0L;
}
@@ -0,0 +1,53 @@
package com.bsd.pjcloud.hard.api.vo.order;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 用户预约统计的通用汇总数据。
*
* <p>金额字段统一使用分;用户数量按“用户-用餐日期”记录累计,避免多日查询把同一用户重复隐藏。</p>
*/
@Data
@Schema(description = "食堂用户预约统计汇总")
public class HardCanteenUserApptStatisticsSummaryVo {
/** 当前统计范围内的用户-用餐日期记录数。 */
@Schema(description = "用户日期记录数")
private Long userCount = 0L;
/** 当前统计范围内去重后的用户数。 */
@Schema(description = "去重用户数")
private Long uniqueUserCount = 0L;
/** 有效预约记录的用户-用餐日期数。 */
@Schema(description = "已订餐记录数")
private Long reservedUserCount = 0L;
/** 没有有效预约记录的用户-用餐日期数。 */
@Schema(description = "未订餐记录数")
private Long unreservedUserCount = 0L;
/** 有效预约订单数。 */
@Schema(description = "预约订单数")
private Long orderCount = 0L;
/** 购物车快照中的菜品总份数。 */
@Schema(description = "菜品总份数")
private Long dishCount = 0L;
/** 有效预约订单商品总价,单位为分。 */
@Schema(description = "预约总价,单位为分")
private Long totalPrice = 0L;
/** 按餐段聚合的预约人数、订单数和菜品份数。 */
@Schema(description = "餐段统计")
private List<HardCanteenUserApptMealStatisticsVo> meals = new ArrayList<>();
/** 按餐段和菜品名称聚合的菜品份数。 */
@Schema(description = "菜品统计")
private List<HardCanteenUserApptDishStatisticsVo> dishes = new ArrayList<>();
}
@@ -0,0 +1,23 @@
package com.bsd.pjcloud.hard.api.vo.order;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 用户预约统计响应,包含查询范围总计和逐日统计。
*/
@Data
@Schema(description = "食堂用户预约统计")
public class HardCanteenUserApptStatisticsVo {
/** 当前查询条件下所有用餐日期的汇总结果。 */
@Schema(description = "查询范围总计")
private HardCanteenUserApptStatisticsSummaryVo total = new HardCanteenUserApptStatisticsSummaryVo();
/** 当前查询条件下按用餐日期升序排列的每日结果。 */
@Schema(description = "每日统计")
private List<HardCanteenUserApptDailyStatisticsVo> daily = new ArrayList<>();
}
@@ -0,0 +1,93 @@
package com.bsd.pjcloud.hard.api.vo.order.excel;
import com.alibaba.excel.annotation.ExcelIgnore;
import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.annotation.format.NumberFormat;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
/**
* 食堂预约菜品统计自定义导出行。
*
* <p>一组日期和餐段按汇总行、商户部门行、商品明细行组织;不同层级只填写所属列,配合自定义合并处理器形成树形报表。</p>
*/
@Data
public class HardCanteenProductApptExcelVo {
/** 日期餐段汇总中的预约日期。 */
@ExcelProperty({"日期餐段汇总", "预约时间"})
private LocalDate summaryDate;
/** 日期餐段汇总中的餐段编码或字典名称。 */
@ExcelProperty({"日期餐段汇总", "预约时间段"})
private String summaryHourType;
/** 日期餐段汇总中的去重预约人数。 */
@ExcelProperty({"日期餐段汇总", "预约人数"})
private Long summaryPayUserCount;
/** 日期餐段汇总中的预约次数。 */
@ExcelProperty({"日期餐段汇总", "预约次数"})
private Long summaryTimeCount;
/** 日期餐段汇总中的商品总数量。 */
@ExcelProperty({"日期餐段汇总", "预约商品数量"})
private Long summaryPayCount;
/** 日期餐段汇总中的总价,单位为元。 */
@ExcelProperty({"日期餐段汇总", "总价"})
@NumberFormat("#,##0.00")
private BigDecimal summaryTotalPrice;
/** 商户部门统计中的食堂名称。 */
@ExcelProperty({"商户部门统计", "商户"})
private String merchantName;
/** 商户部门统计中的用户部门名称。 */
@ExcelProperty({"商户部门统计", "部门"})
private String departmentName;
/** 商户部门统计中的预约日期。 */
@ExcelProperty({"商户部门统计", "预约时间"})
private LocalDate merchantDate;
/** 商户部门统计中的餐段名称。 */
@ExcelProperty({"商户部门统计", "预约时间段"})
private String merchantHourType;
/** 商户部门统计中的去重预约人数。 */
@ExcelProperty({"商户部门统计", "预约人数"})
private Long merchantPayUserCount;
/** 商户部门统计中的预约次数。 */
@ExcelProperty({"商户部门统计", "预约次数"})
private Long merchantTimeCount;
/** 商户部门统计中的商品总数量。 */
@ExcelProperty({"商户部门统计", "预约商品数量"})
private Long merchantPayCount;
/** 商户部门统计中的总价,单位为元。 */
@ExcelProperty({"商户部门统计", "总价"})
@NumberFormat("#,##0.00")
private BigDecimal merchantTotalPrice;
/** 商品明细中的商品名称。 */
@ExcelProperty({"商品明细", "商品"})
private String productName;
/** 商品明细中的商品数量。 */
@ExcelProperty({"商品明细", "数量"})
private Long productCount;
/** 商品明细中的商品总价,单位为元。 */
@ExcelProperty({"商品明细", "总价"})
@NumberFormat("#,##0.00")
private BigDecimal productTotalPrice;
/** 导出行层级,仅供合并处理器识别,不写入 Excel。 */
@ExcelIgnore
private String rowLevel;
}
@@ -0,0 +1,130 @@
package com.bsd.pjcloud.hard.excel;
import com.alibaba.excel.write.handler.WorkbookWriteHandler;
import com.alibaba.excel.write.metadata.holder.WriteWorkbookHolder;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.HorizontalAlignment;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.VerticalAlignment;
import org.apache.poi.ss.util.CellRangeAddress;
import java.util.HashMap;
import java.util.Map;
/**
* 食堂预约菜品统计导出合并处理器。
*
* <p>数据行通过各层级专属列识别:日期餐段行填写第 0 列,商户部门行填写第 6 列,商品行填写第 14 列;
* 处理器在工作簿关闭前合并父层级字段,避免分批写入时依赖 Easy Excel 的行缓存。</p>
*/
public class HardCanteenProductApptExportMergeHandler implements WorkbookWriteHandler {
/** 日期餐段汇总列。 */
private static final int[] SUMMARY_COLUMNS = {0, 1, 2, 3, 4, 5};
/** 商户部门统计列。 */
private static final int[] MERCHANT_COLUMNS = {6, 7, 8, 9, 10, 11, 12, 13};
/** Easy Excel 双层表头占用两行,数据从第三行开始。 */
private static final int DATA_START_ROW = 2;
/**
* 工作簿关闭前统一处理数据行样式、层级合并和列宽。
*
* <p>Easy Excel 已为金额列设置数字格式,居中时必须从原样式复制再覆盖对齐属性,
* 不能直接替换成全新的样式,否则金额会失去两位小数格式。</p>
*
* @param holder Easy Excel 当前工作簿持有者
*/
@Override
public void afterWorkbookDispose(WriteWorkbookHolder holder) {
Sheet sheet = holder.getWorkbook().getNumberOfSheets() == 0 ? null : holder.getWorkbook().getSheetAt(0);
if (sheet == null || sheet.getLastRowNum() < DATA_START_ROW) {
return;
}
Map<Short, CellStyle> centeredStyles = new HashMap<>();
int lastRow = sheet.getLastRowNum();
for (int rowIndex = DATA_START_ROW; rowIndex <= lastRow; rowIndex++) {
Row row = sheet.getRow(rowIndex);
if (row == null) {
continue;
}
for (int column = 0; column < 17; column++) {
Cell cell = row.getCell(column);
if (cell != null) {
CellStyle originalStyle = cell.getCellStyle();
short styleIndex = originalStyle.getIndex();
CellStyle centeredStyle = centeredStyles.computeIfAbsent(styleIndex, key -> {
CellStyle copiedStyle = holder.getWorkbook().createCellStyle();
copiedStyle.cloneStyleFrom(originalStyle);
copiedStyle.setAlignment(HorizontalAlignment.CENTER);
copiedStyle.setVerticalAlignment(VerticalAlignment.CENTER);
return copiedStyle;
});
cell.setCellStyle(centeredStyle);
}
}
}
mergeSummaryGroups(sheet, lastRow);
mergeMerchantGroups(sheet, lastRow);
int[] widths = {13, 12, 11, 11, 13, 14, 18, 18, 13, 12, 11, 11, 13, 14, 24, 11, 14};
for (int column = 0; column < widths.length; column++) {
sheet.setColumnWidth(column, widths[column] * 256);
}
}
/** 合并每个日期餐段组的汇总列。 */
private void mergeSummaryGroups(Sheet sheet, int lastRow) {
int groupStart = -1;
for (int rowIndex = DATA_START_ROW; rowIndex <= lastRow + 1; rowIndex++) {
boolean boundary = rowIndex > lastRow || hasValue(sheet, rowIndex, 0);
if (!boundary) {
continue;
}
if (groupStart >= 0) {
mergeColumns(sheet, groupStart, rowIndex - 1, SUMMARY_COLUMNS);
}
groupStart = rowIndex > lastRow ? -1 : rowIndex;
}
}
/** 合并每个商户部门组的统计列,商品明细行共享商户统计。 */
private void mergeMerchantGroups(Sheet sheet, int lastRow) {
int groupStart = -1;
for (int rowIndex = DATA_START_ROW; rowIndex <= lastRow + 1; rowIndex++) {
boolean summaryRow = rowIndex <= lastRow && hasValue(sheet, rowIndex, 0);
boolean merchantRow = rowIndex <= lastRow && hasValue(sheet, rowIndex, 6);
// 日期汇总行也是商户分组的边界,不能让上一日期最后一个商户跨过汇总行继续合并。
boolean boundary = rowIndex > lastRow || summaryRow || (merchantRow && groupStart >= 0);
if (boundary && groupStart >= 0) {
mergeColumns(sheet, groupStart, rowIndex - 1, MERCHANT_COLUMNS);
groupStart = -1;
}
if (merchantRow) {
groupStart = rowIndex;
}
}
}
/** 按列范围添加合并单元格。 */
private void mergeColumns(Sheet sheet, int firstRow, int lastRow, int[] columns) {
if (firstRow >= lastRow) {
return;
}
for (int column : columns) {
sheet.addMergedRegion(new CellRangeAddress(firstRow, lastRow, column, column));
}
}
/** 判断指定单元格是否包含非空文本。 */
private boolean hasValue(Sheet sheet, int rowIndex, int columnIndex) {
Row row = sheet.getRow(rowIndex);
if (row == null) {
return false;
}
Cell cell = row.getCell(columnIndex);
return cell != null && !cell.toString().trim().isEmpty();
}
}
@@ -0,0 +1,50 @@
package com.bsd.pjcloud.hard.service;
import com.bsd.pjcloud.common.asynctask.vo.AsyncTaskProgressVO;
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.excel.HardCanteenProductApptExcelVo;
import com.bsd.pjcloud.hard.api.vo.order.excel.HardCanteenUserApptExcelVo;
import java.util.List;
/**
* 食堂预约统计导出服务。
*
* <p>导出属于独立的长耗时任务边界,负责异步任务、分批读取、Easy Excel 写入和文件中心上传;
* 普通统计服务只负责页面查询和统计,不持有导出任务状态。</p>
*/
public interface HardCanteenApptTotalExportService {
/**
* 提交用户预约异步导出任务。
*
* @param query 用户预约日期、部门、食堂、餐段和状态筛选条件
* @return 可供前端轮询的初始任务进度
*/
AsyncTaskProgressVO submitUserReservationExport(HardCanteenUserApptQueryDto query);
/**
* 兼容旧同步用户预约导出接口。
*
* @param query 用户预约筛选条件
* @return 用户预约导出行
*/
List<HardCanteenUserApptExcelVo> userReservationExport(HardCanteenUserApptQueryDto query);
/**
* 提交菜品预约异步导出任务。
*
* @param query 菜品预约统计筛选条件
* @return 可供前端轮询的初始任务进度
*/
AsyncTaskProgressVO submitProductReservationExport(HardOrderDto query);
/**
* 查询菜品预约自定义 Excel 行,供同步下载和异步写入复用。
*
* @param query 菜品预约统计筛选条件
* @return 按日期餐段、商户部门、商品三级结构排列的导出行
*/
List<HardCanteenProductApptExcelVo> productReservationExport(HardOrderDto query);
}