feat:预约统计导出

This commit is contained in:
dzc committed 2026-09-01 14:53:38 +08:00
1 parent 577f65173c
commit bb5a964ae1
4 files changed
+199 -19

No files matched your search

@@ -53,8 +53,8 @@ public class HardCanteenUserApptStatisticsExportMergeHandler implements RowWrite
{(byte) 0xED, 0x7D, 0x31}
};
/** 各列宽度,商品名称列加宽,其余统计字段保持紧凑。 */
private static final int[] COLUMN_WIDTHS = {12, 13, 13, 12, 12, 13, 13, 12, 32, 13, 12};
/** 各列宽度,商品名称列扩展到 80 个字符以完整展示中英文长套餐名称。 */
private static final int[] COLUMN_WIDTHS = {12, 13, 13, 12, 12, 13, 13, 12, 80, 13, 12};
/** 原样式索引到居中样式的缓存。 */
private final Map<Short, CellStyle> centeredStyles = new HashMap<>();
@@ -56,28 +56,35 @@ import org.springframework.validation.annotation.Validated;
import org.springframework.web.multipart.MultipartFile;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.text.Collator;
import java.time.LocalDate;
import java.time.LocalTime;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
* 用户食堂预约统计导出服务实现。
*
* <p>人员明细按权限用户分批写入;抽屉统计复用页面聚合结果,只输出日期、餐段和商品维度。</p>
* <p>人员明细按权限用户分批写入;抽屉统计复用页面聚合结果,
* 只输出日期、餐段和商品维度。</p>
*/
@Slf4j
@Validated
@@ -98,6 +105,9 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot
/** 预约统计抽屉导出任务名称。 */
private static final String USER_RESERVATION_STATISTICS_EXPORT_TASK_NAME = "用户预约统计导出";
/** 菜品名称自然排序使用的数字或非数字分段规则。 */
private static final Pattern STATISTICS_NATURAL_SORT_TOKEN_PATTERN = Pattern.compile("\\d+|\\D+");
/** 用户预约明细及统计导出任务创建时的占位总数。 */
private static final int USER_RESERVATION_EXPORT_INITIAL_TOTAL = 1;
@@ -245,7 +255,8 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot
/**
* 提交预约统计抽屉的日期、餐段和商品维度异步导出任务。
*
* <p>导出请求使用独立任务命名空间,并复制所有人员筛选条件;后台统计可以按人员范围过滤,
* <p>导出请求使用独立任务命名空间,并复制所有人员筛选条件;
* 后台统计可以按人员范围过滤,
* 但 Excel 只输出聚合后的日期、餐段和商品数据。</p>
*
* @param query 用户、身份、日期范围、食堂、餐段和状态筛选条件
@@ -349,7 +360,8 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot
}
if (rows.size() > MAX_USER_RESERVATION_STATISTICS_EXPORT_ROWS) {
throw new IllegalStateException("预约统计导出超过单个 Excel 工作表支持的 "
+ MAX_USER_RESERVATION_STATISTICS_EXPORT_ROWS + " 行,请缩小统计日期或筛选范围后重试");
+ MAX_USER_RESERVATION_STATISTICS_EXPORT_ROWS
+ " 行,请缩小统计日期或筛选范围后重试");
}
reporter.advance("统计数据汇总完成,共 " + rows.size() + " 行,正在生成 Excel 文件");
@@ -378,8 +390,9 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot
/**
* 将抽屉每日统计展开为日期汇总行、餐段统计行和商品统计行。
*
* <p>人员筛选只用于确定统计范围,导出行不会读取菜品统计中的人员列表;没有有效预约订单、
* 商品数量或总价的纯人员日期占位不会写入文件。每个层级只填写所属横向区块,配合合并处理器
* <p>人员筛选只用于确定统计范围,导出行不会读取菜品统计中的人员列表;
* 没有有效预约订单、商品数量或总价的纯人员日期占位不会写入文件。
* 每个层级只填写所属横向区块,配合合并处理器
* 形成从左到右、从汇总到商品的树形报表。</p>
*
* @param statistics 与抽屉展示一致的每日和范围统计结果
@@ -392,6 +405,7 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot
return List.of();
}
List<HardCanteenUserApptStatisticsExcelVo> rows = new ArrayList<>();
Comparator<String> displayNameComparator = createStatisticsDisplayNameComparator();
for (HardCanteenUserApptDailyStatisticsVo daily : statistics.getDaily()) {
if (!hasExportableStatistics(daily)) {
continue;
@@ -403,13 +417,13 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot
Map<String, List<HardCanteenUserApptDishStatisticsVo>> dishMap = safeList(daily.getDishes()).stream()
.collect(Collectors.groupingBy(dish -> normalizeStatisticsHourType(dish.getHourType()),
LinkedHashMap::new, Collectors.toList()));
Set<String> hourTypes = new LinkedHashSet<>(mealMap.keySet());
hourTypes.addAll(dishMap.keySet());
List<String> hourTypes = resolveOrderedStatisticsHourTypes(mealMap, dishMap,
mealSegmentLabelMap, displayNameComparator);
for (String hourType : hourTypes) {
HardCanteenUserApptMealStatisticsVo meal = mealMap.get(hourType);
List<HardCanteenUserApptDishStatisticsVo> dishes = dishMap.getOrDefault(hourType, List.of());
rows.add(toMealStatisticsExcelRow(hourType, meal, mealSegmentLabelMap));
rows.addAll(toProductStatisticsExcelRows(dishes));
rows.addAll(toProductStatisticsExcelRows(dishes, displayNameComparator));
}
}
return rows;
@@ -467,16 +481,20 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot
}
/**
* 按业务可见商品名称合并并组装商品统计区块,忽略商品主键、订单主键和点餐人员明细。
* 按业务可见商品名称合并并组装商品统计区块,
* 忽略商品主键、订单主键和点餐人员明细。
*
* @param dishes 当前餐段的商品汇总
* @return 按首次出现顺序排列的商品名称、商品数量和总价导出行
* @return 按 drawer 名称顺序排列的商品名称、商品数量和总价导出行
*/
private List<HardCanteenUserApptStatisticsExcelVo> toProductStatisticsExcelRows(
List<HardCanteenUserApptDishStatisticsVo> dishes) {
List<HardCanteenUserApptDishStatisticsVo> dishes, Comparator<String> displayNameComparator) {
Map<String, HardCanteenUserApptStatisticsExcelVo> statisticsMap = new LinkedHashMap<>();
for (HardCanteenUserApptDishStatisticsVo dish : dishes) {
String displayName = StrUtil.blankToDefault(dish.getProductName(), "未命名菜品").trim();
List<HardCanteenUserApptDishStatisticsVo> orderedDishes = dishes.stream()
.sorted(Comparator.comparing(this::resolveStatisticsProductDisplayName, displayNameComparator))
.toList();
for (HardCanteenUserApptDishStatisticsVo dish : orderedDishes) {
String displayName = resolveStatisticsProductDisplayName(dish);
String statisticsKey = normalizeStatisticsProductName(displayName);
HardCanteenUserApptStatisticsExcelVo row = statisticsMap.computeIfAbsent(statisticsKey, key -> {
HardCanteenUserApptStatisticsExcelVo productRow = new HardCanteenUserApptStatisticsExcelVo();
@@ -492,7 +510,97 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot
}
/**
* 归一商品名称中的连续空白,避免内部商品主键不同但显示名称相同的商品重复占行。
* 按 drawer 的展示规则排列餐段:字典内餐段使用字典顺序,历史餐段按展示名称排序。
*
* @param mealMap 实际餐段统计
* @param dishMap 实际商品按餐段分组结果
* @param mealSegmentLabelMap 餐段字典有序映射
* @param displayNameComparator 中文和数字自然排序比较器
* @return 只包含实际统计餐段的有序编码列表
*/
private List<String> resolveOrderedStatisticsHourTypes(
Map<String, HardCanteenUserApptMealStatisticsVo> mealMap,
Map<String, List<HardCanteenUserApptDishStatisticsVo>> dishMap,
Map<String, String> mealSegmentLabelMap,
Comparator<String> displayNameComparator) {
Set<String> remainingHourTypes = new LinkedHashSet<>(mealMap.keySet());
remainingHourTypes.addAll(dishMap.keySet());
List<String> orderedHourTypes = new ArrayList<>();
for (String configuredHourType : mealSegmentLabelMap.keySet()) {
if (remainingHourTypes.remove(configuredHourType)) {
orderedHourTypes.add(configuredHourType);
}
}
List<String> historicalHourTypes = new ArrayList<>(remainingHourTypes);
historicalHourTypes.sort(Comparator.comparing(
hourType -> resolveMealSegmentLabel(hourType, mealSegmentLabelMap), displayNameComparator));
orderedHourTypes.addAll(historicalHourTypes);
return orderedHourTypes;
}
/**
* 将商品统计项转换为与 drawer 一致的可见名称。
*
* @param dish 商品统计项
* @return 去除首尾空白后的商品名称;空名称返回兼容文案
*/
private String resolveStatisticsProductDisplayName(HardCanteenUserApptDishStatisticsVo dish) {
return StrUtil.blankToDefault(dish.getProductName(), "未命名菜品").trim();
}
/**
* 创建与浏览器 {@code zh-CN localeCompare} 的数字自然排序、基础字符比较规则对齐的比较器。
*
* @return 中文拼音和名称数字自然排序比较器
*/
private Comparator<String> createStatisticsDisplayNameComparator() {
Collator collator = Collator.getInstance(Locale.SIMPLIFIED_CHINESE);
collator.setStrength(Collator.PRIMARY);
return (left, right) -> compareStatisticsDisplayNames(left, right, collator);
}
/**
* 按数字和非数字片段自然比较两个展示名称,保证“套餐2”排在“套餐10”之前。
*
* @param left 左侧展示名称
* @param right 右侧展示名称
* @param collator 中文本地化比较器
* @return 负数、零或正数,分别表示左侧在前、相等或右侧在前
*/
private int compareStatisticsDisplayNames(String left, String right, Collator collator) {
String safeLeft = StrUtil.nullToEmpty(left);
String safeRight = StrUtil.nullToEmpty(right);
Matcher leftMatcher = STATISTICS_NATURAL_SORT_TOKEN_PATTERN.matcher(safeLeft);
Matcher rightMatcher = STATISTICS_NATURAL_SORT_TOKEN_PATTERN.matcher(safeRight);
boolean hasLeftToken = leftMatcher.find();
boolean hasRightToken = rightMatcher.find();
while (hasLeftToken && hasRightToken) {
String leftToken = leftMatcher.group();
String rightToken = rightMatcher.group();
int comparison;
if (Character.isDigit(leftToken.charAt(0)) && Character.isDigit(rightToken.charAt(0))) {
comparison = new BigInteger(leftToken).compareTo(new BigInteger(rightToken));
} else {
comparison = collator.compare(leftToken, rightToken);
}
if (comparison != 0) {
return comparison;
}
hasLeftToken = leftMatcher.find();
hasRightToken = rightMatcher.find();
}
if (hasLeftToken) {
return 1;
}
if (hasRightToken) {
return -1;
}
return collator.compare(safeLeft, safeRight);
}
/**
* 归一商品名称中的连续空白,避免内部商品主键不同但显示名称相同的商品
* 重复占行。
*
* @param productName 面向业务人员展示的商品名称
* @return 可用于同名商品聚合的稳定名称
@@ -502,7 +610,8 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot
}
/**
* 将餐段编码归一为稳定分组键,历史空编码使用空字符串并在展示阶段转换为兼容文案。
* 将餐段编码归一为稳定分组键,历史空编码使用空字符串并在展示阶段
* 转换为兼容文案。
*
* @param hourType 餐段编码
* @return 原餐段编码;空值统一返回空字符串
@@ -32,6 +32,8 @@ class HardCanteenUserApptStatisticsExportMergeHandlerTest {
void shouldMergeDateAndMealBlocksAcrossProductStatistics() throws Exception {
Path output = Files.createTempFile("canteen-user-appt-statistics-merge-test-", ".xlsx");
try {
String longProductName =
"套餐 D周四午-铁板英式霹雳腿Iron Plate British-style Thunderbolt Leg";
List<HardCanteenUserApptStatisticsExcelVo> rows = List.of(
dateRow(LocalDate.of(2026, 9, 1)),
mealRow("早餐"),
@@ -41,7 +43,7 @@ class HardCanteenUserApptStatisticsExportMergeHandlerTest {
productRow("青菜"),
dateRow(LocalDate.of(2026, 9, 2)),
mealRow("晚餐"),
productRow("米饭"));
productRow(longProductName));
EasyExcel.write(output.toFile(), HardCanteenUserApptStatisticsExcelVo.class)
.registerWriteHandler(new HardCanteenUserApptStatisticsExportMergeHandler())
@@ -58,7 +60,8 @@ class HardCanteenUserApptStatisticsExportMergeHandlerTest {
assertTrue(mergedRegions.contains("A9:A11"));
assertTrue(mergedRegions.contains("E10:E11"));
assertEquals(12 * 256, workbook.getSheetAt(0).getColumnWidth(0));
assertEquals(32 * 256, workbook.getSheetAt(0).getColumnWidth(8));
assertEquals(80 * 256, workbook.getSheetAt(0).getColumnWidth(8));
assertEquals(longProductName, workbook.getSheetAt(0).getRow(10).getCell(8).getStringCellValue());
}
} finally {
Files.deleteIfExists(output);
@@ -21,8 +21,10 @@ import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
@@ -120,6 +122,72 @@ class HardCanteenApptTotalExportServiceImplTest {
|| name.endsWith("id") || name.endsWith("ids")));
}
/**
* 验证餐段使用 drawer 的字典顺序,商品名称使用带数字的中文自然排序。
*/
@Test
void shouldFollowDrawerMealAndProductOrder() {
HardCanteenUserApptMealStatisticsVo dinner = mealStatistics("3");
HardCanteenUserApptMealStatisticsVo breakfast = mealStatistics("1");
HardCanteenUserApptDishStatisticsVo dinnerDish = dishStatistics("3", "晚餐菜品");
HardCanteenUserApptDishStatisticsVo product10 = dishStatistics("1", "套餐10");
HardCanteenUserApptDishStatisticsVo product2 = dishStatistics("1", "套餐2");
HardCanteenUserApptDailyStatisticsVo daily = new HardCanteenUserApptDailyStatisticsVo();
daily.setDate(LocalDate.of(2026, 9, 2));
daily.setOrderCount(3L);
daily.setDishCount(3L);
daily.setTotalPrice(300L);
daily.setMeals(List.of(dinner, breakfast));
daily.setDishes(List.of(dinnerDish, product10, product2));
HardCanteenUserApptStatisticsVo statistics = new HardCanteenUserApptStatisticsVo();
statistics.setDaily(List.of(daily));
Map<String, String> mealLabels = new LinkedHashMap<>();
mealLabels.put("1", "早餐");
mealLabels.put("2", "午餐");
mealLabels.put("3", "晚餐");
List<String> hierarchy = createService().buildUserReservationStatisticsExportRows(statistics, mealLabels)
.stream().map(row -> row.getMealName() != null ? "餐段:" + row.getMealName()
: row.getProductName() == null ? null : "商品:" + row.getProductName())
.filter(Objects::nonNull).toList();
assertEquals(List.of(
"餐段:早餐", "商品:套餐2", "商品:套餐10", "餐段:晚餐", "商品:晚餐菜品"),
hierarchy);
}
/**
* 创建排序测试使用的餐段统计项。
*
* @param hourType 餐段编码
* @return 包含一笔预约的餐段统计
*/
private HardCanteenUserApptMealStatisticsVo mealStatistics(String hourType) {
HardCanteenUserApptMealStatisticsVo meal = new HardCanteenUserApptMealStatisticsVo();
meal.setHourType(hourType);
meal.setOrderCount(1L);
meal.setDishCount(1L);
meal.setTotalPrice(100L);
return meal;
}
/**
* 创建排序测试使用的商品统计项。
*
* @param hourType 餐段编码
* @param productName 商品名称
* @return 包含一份商品的统计项
*/
private HardCanteenUserApptDishStatisticsVo dishStatistics(String hourType, String productName) {
HardCanteenUserApptDishStatisticsVo dish = new HardCanteenUserApptDishStatisticsVo();
dish.setHourType(hourType);
dish.setProductName(productName);
dish.setProductCount(1L);
dish.setTotalPrice(100L);
return dish;
}
/**
* 使用 Mock 依赖构造导出服务,当前测试仅执行内存中的统计行转换。
*