feat:预约统计导出
This commit is contained in:
1 parent
577f65173c
commit
bb5a964ae1
4 files changed
+199
-19
No files matched your search
+2
-2
@@ -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<>();
|
||||
|
||||
+124
-15
@@ -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 原餐段编码;空值统一返回空字符串
|
||||
|
||||
+5
-2
@@ -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);
|
||||
|
||||
+68
@@ -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 依赖构造导出服务,当前测试仅执行内存中的统计行转换。
|
||||
*
|
||||
|
||||
Reference in new issue
Block a user