diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenDailyMenuImportExcel.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenDailyMenuImportExcel.java index dfda531c1..ae521dfd3 100644 --- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenDailyMenuImportExcel.java +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenDailyMenuImportExcel.java @@ -8,6 +8,7 @@ import java.math.BigDecimal; /** * 每日菜谱导入/导出 Excel 行(导出含菜谱 ID,导入时可按 ID 更新) *

列顺序:日期、餐段、套餐名、菜品名、分类、单价、套餐价、库存、配料、菜谱ID、填写说明 + *

配料:单品写单品 SPU;套餐仅在首行填写,写入套餐 SPU */ @Data public class CanteenDailyMenuImportExcel { diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/DailyMenuAdminVO.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/DailyMenuAdminVO.java index cb03778ca..9acc862b5 100644 --- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/DailyMenuAdminVO.java +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/DailyMenuAdminVO.java @@ -27,6 +27,9 @@ public class DailyMenuAdminVO { private String name; + /** 套餐标签(canteen_spu.introduction),导出套餐名优先用此字段,避免名称中的 `-` 被截断 */ + private String introduction; + private String picUrl; private Long categoryId; diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenDailyMenuController.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenDailyMenuController.java index 9da962dc3..d53529169 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenDailyMenuController.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenDailyMenuController.java @@ -87,15 +87,19 @@ public class CanteenDailyMenuController { } /** - * 导出某一天的每日菜谱(与导入模板列一致,含菜谱 ID / SPU ID) + * 导出日期段内的每日菜谱(与导入模板列一致,含菜谱 ID / SPU ID) */ - @Operation(summary = "导出当日菜谱(可再导入更新)") + @Operation(summary = "导出菜谱(可再导入更新)", description = "按 startDate~endDate 导出,最多 62 天") @ResponseExcel(name = "每日菜谱", writeHandler = CanteenDailyMenuTemplateMergeHandler.class) @GetMapping("/import/export") @PreAuthorize("@pms.hasPermission('canteen_canteenDailyMenu_export')") public List exportImportExcel(@RequestParam("merchantId") Long merchantId, - @RequestParam("bizDate") String bizDate) { - List rows = dailyMenuImportHelper.exportRows(merchantId, bizDate); + @RequestParam(value = "startDate", required = false) String startDate, + @RequestParam(value = "endDate", required = false) String endDate, + @RequestParam(value = "bizDate", required = false) String bizDate) { + String rangeStart = StrUtil.blankToDefault(startDate, bizDate); + String rangeEnd = StrUtil.blankToDefault(endDate, bizDate); + List rows = dailyMenuImportHelper.exportRows(merchantId, rangeStart, rangeEnd); // @ResponseExcel 空 List 不会落盘,导致前端收到空 blob;至少写一行空对象以输出表头 if (rows == null || rows.isEmpty()) { return List.of(new CanteenDailyMenuImportExcel()); diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/excel/CanteenDailyMenuTemplateMergeHandler.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/excel/CanteenDailyMenuTemplateMergeHandler.java index a67004233..ade33a4f7 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/excel/CanteenDailyMenuTemplateMergeHandler.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/excel/CanteenDailyMenuTemplateMergeHandler.java @@ -22,6 +22,7 @@ public class CanteenDailyMenuTemplateMergeHandler implements WorkbookWriteHandle private static final int COL_COMBO = 2; private static final int COL_COMBO_PRICE = 6; private static final int COL_STOCK = 7; + private static final int COL_INGREDIENTS = 8; private static final int COL_ID = 9; private static final int COL_SPU_ID = 10; private static final int COL_TIP = 11; @@ -138,6 +139,7 @@ public class CanteenDailyMenuTemplateMergeHandler implements WorkbookWriteHandle mergeRange(sheet, runStart, endRow, COL_COMBO, center); mergeRange(sheet, runStart, endRow, COL_COMBO_PRICE, center); mergeRange(sheet, runStart, endRow, COL_STOCK, center); + mergeRange(sheet, runStart, endRow, COL_INGREDIENTS, center); mergeRange(sheet, runStart, endRow, COL_ID, center); mergeRange(sheet, runStart, endRow, COL_SPU_ID, center); mergeRange(sheet, runStart, endRow, COL_TIP, center); @@ -238,12 +240,37 @@ public class CanteenDailyMenuTemplateMergeHandler implements WorkbookWriteHandle } private static boolean isSingleCombo(String combo) { - return isBlank(combo) - || "无".equals(combo) - || "-".equals(combo) - || "单品".equals(combo) - || "(无)".equals(combo) - || "(无)".equals(combo); + if (isBlank(combo)) { + return true; + } + String c = stripMarkerWrap(combo.trim()); + if (c.isEmpty()) { + return true; + } + if ("无".equals(c) || "-".equals(c) || "无套餐".equals(c) || "不套餐".equals(c)) { + return true; + } + return c.startsWith("单品") || c.startsWith("单点"); + } + + private static String stripMarkerWrap(String s) { + String t = s; + while (t.length() >= 2) { + char a = t.charAt(0); + char b = t.charAt(t.length() - 1); + boolean wrap = (a == '(' && b == ')') + || (a == '(' && b == ')') + || (a == '【' && b == '】') + || (a == '[' && b == ']') + || (a == '「' && b == '」') + || (a == '"' && b == '"') + || (a == '\'' && b == '\''); + if (!wrap) { + break; + } + t = t.substring(1, t.length() - 1).trim(); + } + return t; } private static void setColumnWidths(Sheet sheet) { diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuImportHelper.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuImportHelper.java index 1680ddf4f..c30de52c5 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuImportHelper.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuImportHelper.java @@ -3,6 +3,8 @@ package com.bsd.pjcloud.canteen.service.impl; import cn.hutool.core.collection.CollUtil; import cn.hutool.core.util.StrUtil; import com.alibaba.excel.EasyExcel; +import com.alibaba.excel.context.AnalysisContext; +import com.alibaba.excel.read.listener.ReadListener; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.bsd.pjcloud.canteen.dto.CanteenDailyMenuImportExcel; import com.bsd.pjcloud.canteen.entity.CanteenCategoryEntity; @@ -29,6 +31,7 @@ import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; @@ -45,6 +48,15 @@ public class CanteenDailyMenuImportHelper { private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + /** 上传文件上限,防止大文件撑爆堆内存 */ + private static final long MAX_FILE_BYTES = 5L * 1024 * 1024; + + /** 数据行上限(不含表头);合并单元格场景仍需整表在内存,用行数硬顶防 OOM */ + private static final int MAX_IMPORT_ROWS = 5000; + + /** 排菜批量落库大小 */ + private static final int MENU_BATCH_SIZE = 200; + private final CanteenSpuService canteenSpuService; private final CanteenDailyMenuService canteenDailyMenuService; private final CanteenMerchantScopeHelper merchantScopeHelper; @@ -53,17 +65,17 @@ public class CanteenDailyMenuImportHelper { public List templateRows() { List list = new ArrayList<>(); list.add(row("2026-08-17", "午餐", "A套餐", "糖醋排骨", "荤菜", "青莲仔排", "12", "21", "100", - "分类按名称匹配;套餐价/库存只填首行。已有菜品不选默认分类也可导入")); - list.add(row(null, null, null, "蒜苔炒腊肉", "荤菜", "蒜苔,腊肉,胡萝卜", "8", null, null, null)); - list.add(row(null, null, null, "清炒时蔬", "素菜", "空心菜", "3", null, null, null)); - list.add(row(null, null, "B套餐", "红烧肉", "荤菜", "五花肉", "10", "18", "80", + "分类按名称匹配;套餐价/库存/配料只填首行。已有菜品不选默认分类也可导入")); + list.add(row(null, null, null, "蒜苔炒腊肉", "荤菜", null, "8", null, null, null)); + list.add(row(null, null, null, "清炒时蔬", "素菜", null, "3", null, null, null)); + list.add(row(null, null, "B套餐", "红烧肉", "荤菜", "五花肉、土豆", "10", "18", "80", "换套餐名即另一套;名称已存在不新增")); - list.add(row(null, null, null, "土豆丝", "素菜", "土豆", "4", null, null, null)); + list.add(row(null, null, null, "土豆丝", "素菜", null, "4", null, null, null)); list.add(row(null, null, "无", "千张大骨汤", "汤类", null, "0", null, "50", - "单品:套餐名填「无」;菜品已存在则复用")); - list.add(row(null, "早餐", "无", "榨菜肉丝面", "面食", "榨菜,肉丝", "6.4", null, "30", - "早餐单品")); - list.add(row(null, null, "无", "豆浆", "饮品", null, "2", null, "100", null)); + "单品:套餐名填「无」「单品」「单点」或「单品-xxx/单点-xxx」;价格填单价列")); + list.add(row(null, "早餐", "单品-1", "榨菜肉丝面", "面食", "榨菜,肉丝", "6.4", null, "30", + "单品-xxx / 单点-xxx 一律按单品,套餐价可留空")); + list.add(row(null, null, "单点-2", "豆浆", "饮品", null, "2", null, "100", null)); return list; } @@ -90,23 +102,36 @@ public class CanteenDailyMenuImportHelper { } /** - * 导出指定日期的每日菜谱(与导入模板列一致,含菜谱 ID) + * 导出指定日期段的每日菜谱(与导入模板列一致,含菜谱 ID / SPU ID) */ - public List exportRows(Long merchantId, String bizDate) { - if (merchantId == null || StrUtil.isBlank(bizDate)) { + public List exportRows(Long merchantId, String startDate, String endDate) { + if (merchantId == null || StrUtil.isBlank(startDate) || StrUtil.isBlank(endDate)) { return List.of(); } - LocalDate date = parseDate(bizDate.trim()); - if (date == null) { + LocalDate start = parseDate(startDate.trim()); + LocalDate end = parseDate(endDate.trim()); + if (start == null || end == null) { return List.of(); } - List menus = canteenDailyMenuService.selectAdminList(date.format(DATE_FMT), merchantId, null); - if (CollUtil.isEmpty(menus)) { - return List.of(); + if (end.isBefore(start)) { + LocalDate tmp = start; + start = end; + end = tmp; + } + long days = end.toEpochDay() - start.toEpochDay() + 1; + if (days > 62) { + throw new IllegalArgumentException("导出日期段不能超过 62 天"); } List rows = new ArrayList<>(); - for (DailyMenuAdminVO menu : menus) { - rows.addAll(toExportRows(menu)); + for (LocalDate date = start; !date.isAfter(end); date = date.plusDays(1)) { + List menus = canteenDailyMenuService.selectAdminList(date.format(DATE_FMT), merchantId, + null); + if (CollUtil.isEmpty(menus)) { + continue; + } + for (DailyMenuAdminVO menu : menus) { + rows.addAll(toExportRows(menu)); + } } return rows; } @@ -132,8 +157,10 @@ public class CanteenDailyMenuImportHelper { row.setComboName(comboLabel); row.setPriceYuan(comboPrice); row.setStock(menu.getStock()); - row.setCategoryName(menu.getCategoryName()); + row.setIngredients(toExcelIngredients(menu.getIngredients())); } + // 每行都带分类,改菜名新建子菜时不丢分类 + row.setCategoryName(menu.getCategoryName()); row.setDishName(StrUtil.blankToDefault(item.getName(), "")); row.setDishPriceYuan(fenToYuan(item.getPrice())); rows.add(row); @@ -150,17 +177,41 @@ public class CanteenDailyMenuImportHelper { row.setCategoryName(menu.getCategoryName()); row.setDishPriceYuan(fenToYuan(menu.getPrice())); row.setStock(menu.getStock()); - row.setIngredients(menu.getIngredients()); + row.setIngredients(toExcelIngredients(menu.getIngredients())); rows.add(row); } return rows; } + /** + * 导出套餐名:优先 introduction(导入时写入的原始套餐标签,可含 -); + * 否则尝试按「套餐名-首道菜名」还原,避免把「套餐A-周二午」截成「套餐A」。 + */ private static String resolveComboLabel(DailyMenuAdminVO menu) { - if (StrUtil.isNotBlank(menu.getName()) && menu.getName().contains("-")) { - return menu.getName().substring(0, menu.getName().indexOf("-")); + if (StrUtil.isNotBlank(menu.getIntroduction())) { + return menu.getIntroduction().trim(); } - return menu.getName(); + String name = StrUtil.trim(menu.getName()); + if (StrUtil.isBlank(name)) { + return ""; + } + if (CollUtil.isNotEmpty(menu.getComboItems())) { + String firstDish = StrUtil.trim(menu.getComboItems().get(0).getName()); + if (StrUtil.isNotBlank(firstDish)) { + String firstLine = firstDish.contains("\n") ? firstDish.substring(0, firstDish.indexOf('\n')).trim() + : firstDish; + for (String dishPart : new String[] { firstDish, firstLine }) { + if (StrUtil.isBlank(dishPart)) { + continue; + } + String suffix = "-" + dishPart; + if (name.endsWith(suffix) && name.length() > suffix.length()) { + return name.substring(0, name.length() - suffix.length()); + } + } + } + } + return name; } private static String formatMeal(Integer hourType) { @@ -190,11 +241,17 @@ public class CanteenDailyMenuImportHelper { if (file == null || file.isEmpty()) { return R.failed("请上传 Excel 文件"); } - List rows = EasyExcel.read(file.getInputStream()) - .head(CanteenDailyMenuImportExcel.class) - .sheet() - .headRowNumber(1) - .doReadSync(); + if (file.getSize() > MAX_FILE_BYTES) { + return R.failed("文件过大,请控制在 " + (MAX_FILE_BYTES / 1024 / 1024) + "MB 以内"); + } + + List rows; + try { + rows = readRowsStreaming(file); + } + catch (IllegalArgumentException ex) { + return R.failed(ex.getMessage()); + } if (CollUtil.isEmpty(rows)) { return R.failed("导入数据为空"); } @@ -211,12 +268,17 @@ public class CanteenDailyMenuImportHelper { } dataRows.add(row); } + rows = null; if (CollUtil.isEmpty(dataRows)) { return R.failed("导入数据为空"); } + if (dataRows.size() > MAX_IMPORT_ROWS) { + return R.failed("有效数据行不能超过 " + MAX_IMPORT_ROWS + " 行"); + } // groupKey = date|hourType|comboOrEmpty|menuIdOrNew Map> groups = new LinkedHashMap<>(); + Set involvedDates = new HashSet<>(); int line = 1; for (CanteenDailyMenuImportExcel row : dataRows) { line++; @@ -235,6 +297,7 @@ public class CanteenDailyMenuImportHelper { errors.add("第" + line + "行:菜品名不能为空"); continue; } + involvedDates.add(date); String combo = StrUtil.trim(row.getComboName()); String comboKey = StrUtil.blankToDefault(combo, ""); Long menuId = row.getId(); @@ -254,7 +317,9 @@ public class CanteenDailyMenuImportHelper { return R.failed(String.join(";", errors.subList(0, Math.min(errors.size(), 20)))); } + ImportCache cache = buildImportCache(merchantId, involvedDates); Set clearedDayHour = new LinkedHashSet<>(); + List pendingMenus = new ArrayList<>(); int updated = 0; int createdDish = 0; int createdCombo = 0; @@ -265,7 +330,7 @@ public class CanteenDailyMenuImportHelper { Long menuId = items.stream().map(CanteenDailyMenuImportExcel::getId).filter(Objects::nonNull).findFirst() .orElse(null); if (menuId != null) { - String err = upsertGroupById(menuId, items, merchantId, deptId, categoryId, categoryMap); + String err = upsertGroupById(menuId, items, merchantId, deptId, categoryId, categoryMap, cache); if (err != null) { errors.add(err); } @@ -284,13 +349,16 @@ public class CanteenDailyMenuImportHelper { for (CanteenDailyMenuImportExcel item : items) { try { FindOrCreate r = findOrCreateDish(merchantId, deptId, - resolveCategoryId(item.getCategoryName(), categoryId, categoryMap), - item.getDishName(), item.getIngredients(), resolveDishPriceFen(item), item.getStock()); + item.getCategoryName(), categoryId, categoryMap, + item.getDishName(), item.getIngredients(), resolveDishPriceFen(item), item.getStock(), + cache); if (r.created) { createdDish++; } - schedule(merchantId, deptId, date, hourType, r.spu.getId(), replaceDay, clearedDayHour); - scheduled++; + if (schedule(merchantId, deptId, date, hourType, r.spu.getId(), replaceDay, clearedDayHour, + cache, pendingMenus)) { + scheduled++; + } } catch (IllegalArgumentException ex) { errors.add(ex.getMessage()); @@ -312,8 +380,8 @@ public class CanteenDailyMenuImportHelper { } try { FindOrCreate r = findOrCreateDish(merchantId, deptId, - resolveCategoryId(item.getCategoryName(), categoryId, categoryMap), - item.getDishName(), item.getIngredients(), resolveDishPriceFen(item), null); + item.getCategoryName(), categoryId, categoryMap, + item.getDishName(), null, resolveDishPriceFen(item), null, cache); if (r.created) { createdDish++; } @@ -335,19 +403,26 @@ public class CanteenDailyMenuImportHelper { String fullComboName = comboName + "-" + mainName; try { FindOrCreate combo = findOrCreateCombo(merchantId, deptId, - resolveCategoryId(first.getCategoryName(), categoryId, categoryMap), - fullComboName, comboName, comboPrice, comboStock, children); + first.getCategoryName(), categoryId, categoryMap, + fullComboName, comboName, comboPrice, comboStock, children, + resolveComboIngredients(first), cache); if (combo.created) { createdCombo++; } - schedule(merchantId, deptId, date, hourType, combo.spu.getId(), replaceDay, clearedDayHour); - scheduled++; + if (schedule(merchantId, deptId, date, hourType, combo.spu.getId(), replaceDay, clearedDayHour, + cache, pendingMenus)) { + scheduled++; + } } catch (IllegalArgumentException ex) { errors.add(ex.getMessage()); } } + if (pendingMenus.size() >= MENU_BATCH_SIZE) { + flushPendingMenus(pendingMenus); + } } + flushPendingMenus(pendingMenus); if (CollUtil.isNotEmpty(errors)) { return R.failed(String.join(";", errors.subList(0, Math.min(errors.size(), 20)))); } @@ -356,11 +431,87 @@ public class CanteenDailyMenuImportHelper { updated, createdDish, createdCombo, scheduled)); } + /** + * EasyExcel 流式按行解析;合并单元格需整表,故仍收集 List,但用行数硬顶防 OOM。 + */ + private static List readRowsStreaming(MultipartFile file) throws IOException { + List rows = new ArrayList<>(); + EasyExcel.read(file.getInputStream(), CanteenDailyMenuImportExcel.class, + new ReadListener() { + @Override + public void invoke(CanteenDailyMenuImportExcel data, AnalysisContext context) { + if (rows.size() >= MAX_IMPORT_ROWS) { + throw new IllegalArgumentException("导入行数不能超过 " + MAX_IMPORT_ROWS + " 行"); + } + rows.add(data); + } + + @Override + public void doAfterAllAnalysed(AnalysisContext context) { + // no-op + } + }).sheet().headRowNumber(1).doRead(); + return rows; + } + + private ImportCache buildImportCache(Long merchantId, Set dates) { + ImportCache cache = new ImportCache(); + // 只取导入比对所需字段;更新时用 UpdateWrapper,避免 pic_url 等 ALWAYS 字段被写成 null + List spus = canteenSpuService.list(Wrappers.lambdaQuery() + .eq(CanteenSpuEntity::getMerchantId, merchantId) + .select(CanteenSpuEntity::getId, CanteenSpuEntity::getName, CanteenSpuEntity::getSpuType, + CanteenSpuEntity::getPrice, CanteenSpuEntity::getMarketPrice, CanteenSpuEntity::getStock, + CanteenSpuEntity::getDescription, CanteenSpuEntity::getCategoryId, + CanteenSpuEntity::getMerchantId, CanteenSpuEntity::getDeptId, + CanteenSpuEntity::getPicUrl)); + if (CollUtil.isNotEmpty(spus)) { + for (CanteenSpuEntity spu : spus) { + if (spu == null || StrUtil.isBlank(spu.getName())) { + continue; + } + if (spu.getPicUrl() == null) { + spu.setPicUrl(""); + } + int type = spu.getSpuType() == null ? 1 : spu.getSpuType(); + if (type == 2) { + cache.comboByName.putIfAbsent(spu.getName(), spu); + } + else { + cache.dishByName.putIfAbsent(spu.getName(), spu); + } + } + } + if (CollUtil.isNotEmpty(dates)) { + List menus = canteenDailyMenuService.list( + Wrappers.lambdaQuery() + .eq(CanteenDailyMenuEntity::getMerchantId, merchantId) + .in(CanteenDailyMenuEntity::getStartTime, dates) + .select(CanteenDailyMenuEntity::getStartTime, CanteenDailyMenuEntity::getHourType, + CanteenDailyMenuEntity::getSpuId)); + if (CollUtil.isNotEmpty(menus)) { + for (CanteenDailyMenuEntity m : menus) { + if (m.getStartTime() != null && m.getHourType() != null && m.getSpuId() != null) { + cache.existingMenuKeys.add(menuKey(m.getStartTime(), m.getHourType(), m.getSpuId())); + } + } + } + } + return cache; + } + + private void flushPendingMenus(List pendingMenus) { + if (CollUtil.isEmpty(pendingMenus)) { + return; + } + canteenDailyMenuService.saveBatch(pendingMenus, MENU_BATCH_SIZE); + pendingMenus.clear(); + } + /** * 按菜谱 ID 更新一组行(单品一行,套餐多行) */ private String upsertGroupById(Long menuId, List items, Long merchantId, Long deptId, - Long defaultCategoryId, Map categoryMap) { + Long defaultCategoryId, Map categoryMap, ImportCache cache) { CanteenDailyMenuEntity menu = canteenDailyMenuService.getById(menuId); if (menu == null) { return "菜谱ID " + menuId + " 不存在"; @@ -380,10 +531,11 @@ public class CanteenDailyMenuImportHelper { String comboName = StrUtil.trim(first.getComboName()); try { if (StrUtil.isBlank(comboName) || isSingleComboMarker(comboName)) { - return upsertSingleById(menu, first, merchantId, deptId, defaultCategoryId, categoryMap, date, hourType); + return upsertSingleById(menu, first, merchantId, deptId, defaultCategoryId, categoryMap, date, hourType, + cache); } return upsertComboGroupById(menu, items, merchantId, deptId, defaultCategoryId, categoryMap, comboName, date, - hourType); + hourType, cache); } catch (IllegalArgumentException ex) { return ex.getMessage(); @@ -391,14 +543,15 @@ public class CanteenDailyMenuImportHelper { } private String upsertSingleById(CanteenDailyMenuEntity menu, CanteenDailyMenuImportExcel row, Long merchantId, - Long deptId, Long defaultCategoryId, Map categoryMap, LocalDate date, Integer hourType) { + Long deptId, Long defaultCategoryId, Map categoryMap, LocalDate date, Integer hourType, + ImportCache cache) { String dish = StrUtil.trim(row.getDishName()); if (StrUtil.isBlank(dish)) { return "菜谱ID " + menu.getId() + ":菜品名不能为空"; } FindOrCreate created = findOrCreateDish(merchantId, deptId, - resolveCategoryId(row.getCategoryName(), defaultCategoryId, categoryMap), dish, row.getIngredients(), - resolveDishPriceFen(row), row.getStock()); + row.getCategoryName(), defaultCategoryId, categoryMap, dish, row.getIngredients(), + resolveDishPriceFen(row), row.getStock(), cache); menu.setStartTime(date); menu.setHourType(hourType); menu.setSpuId(created.spu.getId()); @@ -408,7 +561,7 @@ public class CanteenDailyMenuImportHelper { private String upsertComboGroupById(CanteenDailyMenuEntity menu, List items, Long merchantId, Long deptId, Long defaultCategoryId, Map categoryMap, String comboName, - LocalDate date, Integer hourType) { + LocalDate date, Integer hourType, ImportCache cache) { List children = new ArrayList<>(); Integer comboPrice = null; Integer comboStock = items.get(0).getStock(); @@ -422,19 +575,22 @@ public class CanteenDailyMenuImportHelper { return "菜谱ID " + menu.getId() + ":套餐组成菜品名不能为空"; } FindOrCreate r = findOrCreateDish(merchantId, deptId, - resolveCategoryId(item.getCategoryName(), defaultCategoryId, categoryMap), dish, - item.getIngredients(), resolveDishPriceFen(item), null); + item.getCategoryName(), defaultCategoryId, categoryMap, dish, + null, resolveDishPriceFen(item), null, cache); children.add(r.spu); } if (comboPrice == null) { - CanteenSpuEntity existCombo = canteenSpuService.getById(menu.getSpuId()); + CanteenSpuEntity existCombo = cache.comboByName.values().stream() + .filter(s -> Objects.equals(s.getId(), menu.getSpuId())) + .findFirst() + .orElseGet(() -> canteenSpuService.getById(menu.getSpuId())); comboPrice = existCombo != null && existCombo.getPrice() != null ? existCombo.getPrice() : 0; } String mainName = children.get(0).getName(); String fullComboName = comboName + "-" + mainName; FindOrCreate combo = findOrCreateCombo(merchantId, deptId, - resolveCategoryId(items.get(0).getCategoryName(), defaultCategoryId, categoryMap), fullComboName, - comboName, comboPrice, comboStock, children); + items.get(0).getCategoryName(), defaultCategoryId, categoryMap, fullComboName, + comboName, comboPrice, comboStock, children, resolveComboIngredients(items.get(0)), cache); menu.setStartTime(date); menu.setHourType(hourType); menu.setSpuId(combo.spu.getId()); @@ -442,8 +598,12 @@ public class CanteenDailyMenuImportHelper { return null; } - private void schedule(Long merchantId, Long deptId, LocalDate date, Integer hourType, Long spuId, - boolean replaceDay, Set clearedDayHour) { + /** + * @return true 表示新增了一条排菜 + */ + private boolean schedule(Long merchantId, Long deptId, LocalDate date, Integer hourType, Long spuId, + boolean replaceDay, Set clearedDayHour, ImportCache cache, + List pendingMenus) { String dh = date + "|" + hourType; if (replaceDay && !clearedDayHour.contains(dh)) { canteenDailyMenuService.remove(Wrappers.lambdaQuery() @@ -451,14 +611,11 @@ public class CanteenDailyMenuImportHelper { .eq(CanteenDailyMenuEntity::getStartTime, date) .eq(CanteenDailyMenuEntity::getHourType, hourType)); clearedDayHour.add(dh); + cache.existingMenuKeys.removeIf(k -> k.startsWith(date + "|" + hourType + "|")); } - boolean exists = canteenDailyMenuService.count(Wrappers.lambdaQuery() - .eq(CanteenDailyMenuEntity::getMerchantId, merchantId) - .eq(CanteenDailyMenuEntity::getStartTime, date) - .eq(CanteenDailyMenuEntity::getHourType, hourType) - .eq(CanteenDailyMenuEntity::getSpuId, spuId)) > 0; - if (exists) { - return; + String key = menuKey(date, hourType, spuId); + if (cache.existingMenuKeys.contains(key)) { + return false; } CanteenDailyMenuEntity menu = new CanteenDailyMenuEntity(); menu.setMerchantId(merchantId); @@ -466,58 +623,87 @@ public class CanteenDailyMenuImportHelper { menu.setStartTime(date); menu.setHourType(hourType); menu.setSpuId(spuId); - canteenDailyMenuService.save(menu); + pendingMenus.add(menu); + cache.existingMenuKeys.add(key); + return true; } - private FindOrCreate findOrCreateDish(Long merchantId, Long deptId, Long categoryId, String name, - String ingredients, Integer priceFen, Integer stock) { + private FindOrCreate findOrCreateDish(Long merchantId, Long deptId, String categoryName, Long defaultCategoryId, + Map categoryMap, String name, String ingredients, Integer priceFen, Integer stock, + ImportCache cache) { String clean = cleanDishName(name); - CanteenSpuEntity exist = canteenSpuService.getOne(Wrappers.lambdaQuery() - .eq(CanteenSpuEntity::getMerchantId, merchantId) - .eq(CanteenSpuEntity::getName, clean) - .eq(CanteenSpuEntity::getSpuType, 1) - .last("limit 1"), false); + CanteenSpuEntity exist = cache.dishByName.get(clean); if (exist != null) { - boolean needUpdate = false; - if (StrUtil.isNotBlank(ingredients) && StrUtil.isBlank(exist.getDescription())) { - exist.setDescription(ingredients.trim()); - needUpdate = true; + String importIngredients = normalizeImportIngredients(ingredients); + boolean changeDesc = false; + boolean changePrice = false; + boolean changeStock = false; + boolean changeCategory = false; + if (StrUtil.isNotBlank(importIngredients)) { + String existPlain = toPlainIngredients(exist.getDescription()); + if (!importIngredients.equals(existPlain)) { + exist.setDescription(importIngredients); + changeDesc = true; + } } - if (priceFen != null && priceFen > 0 && (exist.getPrice() == null || exist.getPrice() == 0)) { + if (priceFen != null && priceFen > 0 && !Objects.equals(exist.getPrice(), priceFen)) { exist.setPrice(priceFen); exist.setMarketPrice(priceFen); - needUpdate = true; + changePrice = true; } if (stock != null && stock >= 0 && !Objects.equals(exist.getStock(), stock)) { exist.setStock(stock); - needUpdate = true; + changeStock = true; } - if (needUpdate) { - canteenSpuService.updateById(exist); + // Excel 明确填了分类才改;空着不覆盖已有分类 + if (StrUtil.isNotBlank(normalizeCategoryName(categoryName))) { + Long newCatId = resolveOrCreateCategoryId(categoryName, defaultCategoryId, categoryMap, merchantId, + deptId); + if (newCatId != null && !Objects.equals(exist.getCategoryId(), newCatId)) { + exist.setCategoryId(newCatId); + changeCategory = true; + } + } + if (changeDesc || changePrice || changeStock || changeCategory) { + // 禁止 updateById(半截实体):pic_url 等为 ALWAYS,null 会写库报错 + var uw = Wrappers.lambdaUpdate().eq(CanteenSpuEntity::getId, exist.getId()); + if (changeDesc) { + uw.set(CanteenSpuEntity::getDescription, exist.getDescription()); + } + if (changePrice) { + uw.set(CanteenSpuEntity::getPrice, exist.getPrice()); + uw.set(CanteenSpuEntity::getMarketPrice, exist.getMarketPrice()); + } + if (changeStock) { + uw.set(CanteenSpuEntity::getStock, exist.getStock()); + } + if (changeCategory) { + uw.set(CanteenSpuEntity::getCategoryId, exist.getCategoryId()); + } + canteenSpuService.update(uw); } return new FindOrCreate(exist, false); } + Long categoryId = resolveOrCreateCategoryId(categoryName, defaultCategoryId, categoryMap, merchantId, deptId); if (categoryId == null) { - throw new IllegalArgumentException("新建菜品「" + clean + "」缺少分类:请在 Excel 填写分类名,或选择默认分类"); + throw new IllegalArgumentException(missingCategoryMessage("新建菜品「" + clean + "」", categoryName, categoryMap)); } CanteenSpuEntity spu = newBaseSpu(merchantId, deptId, categoryId, clean, 1); - spu.setDescription(StrUtil.trim(ingredients)); + spu.setDescription(normalizeImportIngredients(ingredients)); spu.setPrice(priceFen == null ? 0 : priceFen); spu.setMarketPrice(spu.getPrice()); if (stock != null && stock >= 0) { spu.setStock(stock); } canteenSpuService.save(spu); + cache.dishByName.put(clean, spu); return new FindOrCreate(spu, true); } - private FindOrCreate findOrCreateCombo(Long merchantId, Long deptId, Long categoryId, String fullName, - String pkgLabel, Integer priceFen, Integer stock, List children) { - CanteenSpuEntity exist = canteenSpuService.getOne(Wrappers.lambdaQuery() - .eq(CanteenSpuEntity::getMerchantId, merchantId) - .eq(CanteenSpuEntity::getName, fullName) - .eq(CanteenSpuEntity::getSpuType, 2) - .last("limit 1"), false); + private FindOrCreate findOrCreateCombo(Long merchantId, Long deptId, String categoryName, Long defaultCategoryId, + Map categoryMap, String fullName, String pkgLabel, Integer priceFen, Integer stock, + List children, String ingredients, ImportCache cache) { + CanteenSpuEntity exist = cache.comboByName.get(fullName); List items = new ArrayList<>(); int sort = 0; for (CanteenSpuEntity child : children) { @@ -533,15 +719,32 @@ public class CanteenDailyMenuImportHelper { if (stock != null && stock >= 0) { exist.setStock(stock); } + if (StrUtil.isNotBlank(pkgLabel)) { + exist.setIntroduction(pkgLabel); + } + if (StrUtil.isNotBlank(normalizeCategoryName(categoryName))) { + Long newCatId = resolveOrCreateCategoryId(categoryName, defaultCategoryId, categoryMap, merchantId, + deptId); + if (newCatId != null) { + exist.setCategoryId(newCatId); + } + } + applyComboIngredients(exist, ingredients); exist.setComboItems(items); + if (exist.getPicUrl() == null) { + exist.setPicUrl(""); + } canteenSpuService.updateDeep(exist); + cache.comboByName.put(fullName, exist); return new FindOrCreate(exist, false); } + Long categoryId = resolveOrCreateCategoryId(categoryName, defaultCategoryId, categoryMap, merchantId, deptId); if (categoryId == null) { - throw new IllegalArgumentException("新建套餐「" + fullName + "」缺少分类:请在 Excel 填写分类名,或选择默认分类"); + throw new IllegalArgumentException(missingCategoryMessage("新建套餐「" + fullName + "」", categoryName, categoryMap)); } CanteenSpuEntity combo = newBaseSpu(merchantId, deptId, categoryId, fullName, 2); combo.setIntroduction(pkgLabel); + applyComboIngredients(combo, ingredients); combo.setPrice(priceFen); combo.setMarketPrice(priceFen); if (stock != null && stock >= 0) { @@ -549,9 +752,29 @@ public class CanteenDailyMenuImportHelper { } combo.setComboItems(items); canteenSpuService.saveDeep(combo); + cache.comboByName.put(fullName, combo); return new FindOrCreate(combo, true); } + private static String menuKey(LocalDate date, Integer hourType, Long spuId) { + return date + "|" + hourType + "|" + spuId; + } + + private static void applyComboIngredients(CanteenSpuEntity combo, String ingredients) { + String importIngredients = normalizeImportIngredients(ingredients); + if (StrUtil.isBlank(importIngredients)) { + return; + } + String existPlain = toPlainIngredients(combo.getDescription()); + if (!importIngredients.equals(existPlain)) { + combo.setDescription(importIngredients); + } + } + + private static String resolveComboIngredients(CanteenDailyMenuImportExcel first) { + return first == null ? "" : normalizeImportIngredients(first.getIngredients()); + } + private Map loadCategoryMap(Long merchantId) { List list = canteenCategoryMapper.selectList(Wrappers.lambdaQuery() .eq(CanteenCategoryEntity::getMerchantId, merchantId)); @@ -560,26 +783,84 @@ public class CanteenDailyMenuImportHelper { return map; } for (CanteenCategoryEntity c : list) { - if (c != null && StrUtil.isNotBlank(c.getName()) && c.getId() != null) { - map.put(c.getName().trim(), c.getId()); + if (c == null || c.getId() == null || StrUtil.isBlank(c.getName())) { + continue; + } + String raw = c.getName().trim(); + String normalized = normalizeCategoryName(raw); + map.putIfAbsent(raw, c.getId()); + if (StrUtil.isNotBlank(normalized)) { + map.putIfAbsent(normalized, c.getId()); } } return map; } /** - * Excel 分类名优先;匹配不到再用默认分类(可为 null) + * Excel 分类名:商户下已有则引用;没有则自动新建;分类名为空时用兜底分类。 */ - private static Long resolveCategoryId(String categoryName, Long defaultCategoryId, Map categoryMap) { - if (StrUtil.isNotBlank(categoryName) && categoryMap != null) { - Long id = categoryMap.get(categoryName.trim()); + private Long resolveOrCreateCategoryId(String categoryName, Long defaultCategoryId, Map categoryMap, + Long merchantId, Long deptId) { + String key = normalizeCategoryName(categoryName); + if (StrUtil.isNotBlank(key) && categoryMap != null) { + Long id = categoryMap.get(key); + if (id == null) { + id = categoryMap.get(StrUtil.trim(categoryName)); + } if (id != null) { return id; } + CanteenCategoryEntity cat = new CanteenCategoryEntity(); + cat.setName(key); + cat.setMerchantId(merchantId); + cat.setDeptId(deptId); + cat.setParentId(0L); + cat.setSort(0); + cat.setStatus(1); + cat.setPicUrl(""); + // 批量导入直接 insert,避免每条都打 MQTT + canteenCategoryMapper.insert(cat); + Long newId = cat.getId(); + categoryMap.put(key, newId); + String raw = StrUtil.trim(categoryName); + if (StrUtil.isNotBlank(raw)) { + categoryMap.putIfAbsent(raw, newId); + } + return newId; } return defaultCategoryId; } + private static String normalizeCategoryName(String name) { + if (name == null) { + return ""; + } + StringBuilder sb = new StringBuilder(name.length()); + for (int i = 0; i < name.length(); i++) { + char c = name.charAt(i); + if (c == '\r' || c == '\n' || c == '\t' || c == '\u00A0' || c == '\u3000') { + sb.append(' '); + continue; + } + // 全角数字 → 半角,避免「荤菜1」匹配不上「荤菜1」 + if (c >= '0' && c <= '9') { + sb.append((char) ('0' + (c - '0'))); + continue; + } + sb.append(c); + } + return sb.toString().replaceAll(" +", " ").trim(); + } + + private static String missingCategoryMessage(String prefix, String excelCategory, Map categoryMap) { + String excel = normalizeCategoryName(excelCategory); + if (StrUtil.isNotBlank(excel)) { + // 理论上走自动建分类后不会到这里;保留兜底文案 + return prefix + ":分类「" + excel + "」处理失败,请重试或选择兜底分类"; + } + return prefix + ":缺少分类,请在 Excel「分类」列填写,或导入时选择兜底分类"; + } + private CanteenSpuEntity newBaseSpu(Long merchantId, Long deptId, Long categoryId, String name, int spuType) { CanteenSpuEntity spu = new CanteenSpuEntity(); spu.setName(name); @@ -608,13 +889,14 @@ public class CanteenDailyMenuImportHelper { } /** - * 合并单元格模板读入后,非首行日期/餐段/套餐名为空,需向下填充。 - * 套餐名填「无」「-」「单品」表示单品,不参与套餐继承。 + * 合并单元格模板读入后,非首行日期/餐段/套餐名/分类为空,需向下填充。 + * 套餐名填「无」「单品」「单点」或「单品-xxx / 单点-xxx」表示单品,不参与套餐继承。 */ private static void normalizeMergedCells(List rows) { String lastDate = null; String lastMeal = null; String lastCombo = null; + String lastCategory = null; Long lastMenuId = null; Long lastSpuId = null; for (CanteenDailyMenuImportExcel row : rows) { @@ -635,6 +917,13 @@ public class CanteenDailyMenuImportHelper { lastMeal = StrUtil.trim(row.getMealName()); row.setMealName(lastMeal); } + if (StrUtil.isBlank(row.getCategoryName())) { + row.setCategoryName(lastCategory); + } + else { + lastCategory = normalizeCategoryName(row.getCategoryName()); + row.setCategoryName(lastCategory); + } String combo = StrUtil.trim(row.getComboName()); if (StrUtil.isBlank(combo)) { row.setComboName(lastCombo); @@ -670,9 +959,51 @@ public class CanteenDailyMenuImportHelper { } } + /** + * 单品标记(不按套餐组块处理): + *

+ */ private static boolean isSingleComboMarker(String combo) { - return "无".equals(combo) || "-".equals(combo) || "单品".equals(combo) - || "(无)".equals(combo) || "(无)".equals(combo); + if (StrUtil.isBlank(combo)) { + return false; + } + String c = stripMarkerWrap(combo.trim()); + if (c.isEmpty()) { + return false; + } + if ("无".equals(c) || "-".equals(c) || "无套餐".equals(c) || "不套餐".equals(c)) { + return true; + } + return c.startsWith("单品") || c.startsWith("单点"); + } + + /** 去掉首尾常见括号/书名号,便于识别【单品】、(单点-1)等 */ + private static String stripMarkerWrap(String s) { + String t = s; + while (t.length() >= 2) { + char a = t.charAt(0); + char b = t.charAt(t.length() - 1); + boolean wrap = (a == '(' && b == ')') + || (a == '(' && b == ')') + || (a == '【' && b == '】') + || (a == '[' && b == ']') + || (a == '「' && b == '」') + || (a == '"' && b == '"') + || (a == '\'' && b == '\''); + if (!wrap) { + break; + } + t = t.substring(1, t.length() - 1).trim(); + } + // 兼容表头写成「(无)」整段 + if ("无".equals(t) || "单品".equals(t) || "单点".equals(t)) { + return t; + } + return t; } private static Integer parseMeal(String meal) { @@ -712,7 +1043,7 @@ public class CanteenDailyMenuImportHelper { } /** - * 菜品单价:优先「菜品单价(元)」;未填则单品可回退「套餐价(元)」兼容旧模板;都无则 0。 + * 菜品单价:优先「单价(元)」;单品未填单价时回退「套餐价(元)」兼容旧表。 */ private static int resolveDishPriceFen(CanteenDailyMenuImportExcel item) { Integer dishFen = toFen(item.getDishPriceYuan()); @@ -720,25 +1051,68 @@ public class CanteenDailyMenuImportHelper { return Math.max(dishFen, 0); } Integer legacy = toFen(item.getPriceYuan()); - if (legacy != null && StrUtil.isBlank(item.getComboName())) { + if (legacy != null && (StrUtil.isBlank(item.getComboName()) || isSingleComboMarker(item.getComboName()))) { return Math.max(legacy, 0); } return 0; } + /** + * 规范化菜品名:保留中英文及换行(Excel 内多行英文说明不再截断)。 + */ static String cleanDishName(String raw) { if (raw == null) { return ""; } - String s = raw.replace("\r", "\n").trim(); - String first = s.split("\n")[0].trim(); - first = first.replaceAll("[\\p{So}\\p{Cn}]", "").trim(); - String cn = first.replaceAll("[A-Za-z].*$", "").trim(); - cn = cn.replaceAll("[@#%&&]+$", "").replaceAll("\\s+", ""); - if (cn.length() >= 2) { - return cn; + String normalized = raw.replace("\r\n", "\n").replace('\r', '\n').trim(); + if (StrUtil.isBlank(normalized)) { + return ""; } - return first.replaceAll("\\s+", " ").trim(); + StringBuilder sb = new StringBuilder(); + for (String line : normalized.split("\n")) { + String trimmed = StrUtil.trim(line).replaceAll("\\s+", " "); + if (StrUtil.isBlank(trimmed)) { + continue; + } + if (sb.length() > 0) { + sb.append('\n'); + } + sb.append(trimmed); + } + return sb.toString(); + } + + /** 富文本配料转 Excel 纯文本 */ + private static String toExcelIngredients(String raw) { + return toPlainIngredients(raw); + } + + private static String normalizeImportIngredients(String raw) { + return toPlainIngredients(raw); + } + + /** + * 去掉 HTML 标签与常见空白实体,避免编辑器空内容 {@code


} 被当成“已有配料”。 + */ + private static String toPlainIngredients(String raw) { + if (StrUtil.isBlank(raw)) { + return ""; + } + String text = raw.replaceAll("(?is)", "\n") + .replaceAll("(?is)

", "\n") + .replaceAll("(?is)]*>", "") + .replaceAll("(?is)<[^>]+>", ""); + text = text.replace(" ", " ") + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\r", "\n"); + text = text.lines() + .map(String::trim) + .filter(StrUtil::isNotBlank) + .reduce((a, b) -> a + "," + b) + .orElse(""); + return StrUtil.trim(text); } private static class FindOrCreate { @@ -750,4 +1124,11 @@ public class CanteenDailyMenuImportHelper { this.created = created; } } + + /** 导入过程内存缓存:避免每行菜品/排菜都打库 */ + private static class ImportCache { + final Map dishByName = new HashMap<>(); + final Map comboByName = new HashMap<>(); + final Set existingMenuKeys = new HashSet<>(); + } } diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/mapper/CanteenDailyMenuMapper.xml b/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/mapper/CanteenDailyMenuMapper.xml index 256b5b08d..db71e310b 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/mapper/CanteenDailyMenuMapper.xml +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/mapper/CanteenDailyMenuMapper.xml @@ -180,6 +180,7 @@ t.merchant_id, sp.id AS spu_id, sp.name, + sp.introduction, sp.price, sp.market_price, sp.pic_url,