diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/excel/HardCanteenUserApptStatisticsExportMergeHandler.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/excel/HardCanteenUserApptStatisticsExportMergeHandler.java index 0c08465c2..a832f1d4f 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/excel/HardCanteenUserApptStatisticsExportMergeHandler.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/excel/HardCanteenUserApptStatisticsExportMergeHandler.java @@ -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 centeredStyles = new HashMap<>(); diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalExportServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalExportServiceImpl.java index e711f9521..07c591758 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalExportServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalExportServiceImpl.java @@ -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; /** * 用户食堂预约统计导出服务实现。 * - *

人员明细按权限用户分批写入;抽屉统计复用页面聚合结果,只输出日期、餐段和商品维度。

+ *

人员明细按权限用户分批写入;抽屉统计复用页面聚合结果, + * 只输出日期、餐段和商品维度。

*/ @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 /** * 提交预约统计抽屉的日期、餐段和商品维度异步导出任务。 * - *

导出请求使用独立任务命名空间,并复制所有人员筛选条件;后台统计可以按人员范围过滤, + *

导出请求使用独立任务命名空间,并复制所有人员筛选条件; + * 后台统计可以按人员范围过滤, * 但 Excel 只输出聚合后的日期、餐段和商品数据。

* * @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 /** * 将抽屉每日统计展开为日期汇总行、餐段统计行和商品统计行。 * - *

人员筛选只用于确定统计范围,导出行不会读取菜品统计中的人员列表;没有有效预约订单、 - * 商品数量或总价的纯人员日期占位不会写入文件。每个层级只填写所属横向区块,配合合并处理器 + *

人员筛选只用于确定统计范围,导出行不会读取菜品统计中的人员列表; + * 没有有效预约订单、商品数量或总价的纯人员日期占位不会写入文件。 + * 每个层级只填写所属横向区块,配合合并处理器 * 形成从左到右、从汇总到商品的树形报表。

* * @param statistics 与抽屉展示一致的每日和范围统计结果 @@ -392,6 +405,7 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot return List.of(); } List rows = new ArrayList<>(); + Comparator displayNameComparator = createStatisticsDisplayNameComparator(); for (HardCanteenUserApptDailyStatisticsVo daily : statistics.getDaily()) { if (!hasExportableStatistics(daily)) { continue; @@ -403,13 +417,13 @@ public class HardCanteenApptTotalExportServiceImpl implements HardCanteenApptTot Map> dishMap = safeList(daily.getDishes()).stream() .collect(Collectors.groupingBy(dish -> normalizeStatisticsHourType(dish.getHourType()), LinkedHashMap::new, Collectors.toList())); - Set hourTypes = new LinkedHashSet<>(mealMap.keySet()); - hourTypes.addAll(dishMap.keySet()); + List hourTypes = resolveOrderedStatisticsHourTypes(mealMap, dishMap, + mealSegmentLabelMap, displayNameComparator); for (String hourType : hourTypes) { HardCanteenUserApptMealStatisticsVo meal = mealMap.get(hourType); List 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 toProductStatisticsExcelRows( - List dishes) { + List dishes, Comparator displayNameComparator) { Map statisticsMap = new LinkedHashMap<>(); - for (HardCanteenUserApptDishStatisticsVo dish : dishes) { - String displayName = StrUtil.blankToDefault(dish.getProductName(), "未命名菜品").trim(); + List 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 resolveOrderedStatisticsHourTypes( + Map mealMap, + Map> dishMap, + Map mealSegmentLabelMap, + Comparator displayNameComparator) { + Set remainingHourTypes = new LinkedHashSet<>(mealMap.keySet()); + remainingHourTypes.addAll(dishMap.keySet()); + List orderedHourTypes = new ArrayList<>(); + for (String configuredHourType : mealSegmentLabelMap.keySet()) { + if (remainingHourTypes.remove(configuredHourType)) { + orderedHourTypes.add(configuredHourType); + } + } + List 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 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 原餐段编码;空值统一返回空字符串 diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/excel/HardCanteenUserApptStatisticsExportMergeHandlerTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/excel/HardCanteenUserApptStatisticsExportMergeHandlerTest.java index 4f9421fb0..2f2ef78f1 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/excel/HardCanteenUserApptStatisticsExportMergeHandlerTest.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/excel/HardCanteenUserApptStatisticsExportMergeHandlerTest.java @@ -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 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); diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalExportServiceImplTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalExportServiceImplTest.java index 3dd5f51fb..b1baa3ab7 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalExportServiceImplTest.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardCanteenApptTotalExportServiceImplTest.java @@ -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 mealLabels = new LinkedHashMap<>(); + mealLabels.put("1", "早餐"); + mealLabels.put("2", "午餐"); + mealLabels.put("3", "晚餐"); + + List 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 依赖构造导出服务,当前测试仅执行内存中的统计行转换。 *