每日菜谱修改优化

This commit is contained in:
zjf committed 2026-08-28 10:42:45 +08:00
1 parent 3cd0bf56b8
commit 96c08c61bf
6 files changed
+535 -118

No files matched your search

@@ -8,6 +8,7 @@ import java.math.BigDecimal;
/**
* 每日菜谱导入/导出 Excel 行(导出含菜谱 ID,导入时可按 ID 更新)
* <p>列顺序:日期、餐段、套餐名、菜品名、分类、单价、套餐价、库存、配料、菜谱ID、填写说明
* <p>配料:单品写单品 SPU;套餐仅在首行填写,写入套餐 SPU
*/
@Data
public class CanteenDailyMenuImportExcel {
@@ -27,6 +27,9 @@ public class DailyMenuAdminVO {
private String name;
/** 套餐标签(canteen_spu.introduction),导出套餐名优先用此字段,避免名称中的 `-` 被截断 */
private String introduction;
private String picUrl;
private Long categoryId;
@@ -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<CanteenDailyMenuImportExcel> exportImportExcel(@RequestParam("merchantId") Long merchantId,
@RequestParam("bizDate") String bizDate) {
List<CanteenDailyMenuImportExcel> 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<CanteenDailyMenuImportExcel> rows = dailyMenuImportHelper.exportRows(merchantId, rangeStart, rangeEnd);
// @ResponseExcel 空 List 不会落盘,导致前端收到空 blob;至少写一行空对象以输出表头
if (rows == null || rows.isEmpty()) {
return List.of(new CanteenDailyMenuImportExcel());
@@ -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) {
@@ -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<CanteenDailyMenuImportExcel> templateRows() {
List<CanteenDailyMenuImportExcel> 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<CanteenDailyMenuImportExcel> exportRows(Long merchantId, String bizDate) {
if (merchantId == null || StrUtil.isBlank(bizDate)) {
public List<CanteenDailyMenuImportExcel> 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<DailyMenuAdminVO> 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<CanteenDailyMenuImportExcel> rows = new ArrayList<>();
for (DailyMenuAdminVO menu : menus) {
rows.addAll(toExportRows(menu));
for (LocalDate date = start; !date.isAfter(end); date = date.plusDays(1)) {
List<DailyMenuAdminVO> 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<CanteenDailyMenuImportExcel> 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<CanteenDailyMenuImportExcel> 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<String, List<CanteenDailyMenuImportExcel>> groups = new LinkedHashMap<>();
Set<LocalDate> 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<String> clearedDayHour = new LinkedHashSet<>();
List<CanteenDailyMenuEntity> 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<CanteenDailyMenuImportExcel> readRowsStreaming(MultipartFile file) throws IOException {
List<CanteenDailyMenuImportExcel> rows = new ArrayList<>();
EasyExcel.read(file.getInputStream(), CanteenDailyMenuImportExcel.class,
new ReadListener<CanteenDailyMenuImportExcel>() {
@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<LocalDate> dates) {
ImportCache cache = new ImportCache();
// 只取导入比对所需字段;更新时用 UpdateWrapper,避免 pic_url 等 ALWAYS 字段被写成 null
List<CanteenSpuEntity> spus = canteenSpuService.list(Wrappers.<CanteenSpuEntity>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<CanteenDailyMenuEntity> menus = canteenDailyMenuService.list(
Wrappers.<CanteenDailyMenuEntity>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<CanteenDailyMenuEntity> pendingMenus) {
if (CollUtil.isEmpty(pendingMenus)) {
return;
}
canteenDailyMenuService.saveBatch(pendingMenus, MENU_BATCH_SIZE);
pendingMenus.clear();
}
/**
* 按菜谱 ID 更新一组行(单品一行,套餐多行)
*/
private String upsertGroupById(Long menuId, List<CanteenDailyMenuImportExcel> items, Long merchantId, Long deptId,
Long defaultCategoryId, Map<String, Long> categoryMap) {
Long defaultCategoryId, Map<String, Long> 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<String, Long> categoryMap, LocalDate date, Integer hourType) {
Long deptId, Long defaultCategoryId, Map<String, Long> 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<CanteenDailyMenuImportExcel> items,
Long merchantId, Long deptId, Long defaultCategoryId, Map<String, Long> categoryMap, String comboName,
LocalDate date, Integer hourType) {
LocalDate date, Integer hourType, ImportCache cache) {
List<CanteenSpuEntity> 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<String> clearedDayHour) {
/**
* @return true 表示新增了一条排菜
*/
private boolean schedule(Long merchantId, Long deptId, LocalDate date, Integer hourType, Long spuId,
boolean replaceDay, Set<String> clearedDayHour, ImportCache cache,
List<CanteenDailyMenuEntity> pendingMenus) {
String dh = date + "|" + hourType;
if (replaceDay && !clearedDayHour.contains(dh)) {
canteenDailyMenuService.remove(Wrappers.<CanteenDailyMenuEntity>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.<CanteenDailyMenuEntity>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<String, Long> categoryMap, String name, String ingredients, Integer priceFen, Integer stock,
ImportCache cache) {
String clean = cleanDishName(name);
CanteenSpuEntity exist = canteenSpuService.getOne(Wrappers.<CanteenSpuEntity>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.<CanteenSpuEntity>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<CanteenSpuEntity> children) {
CanteenSpuEntity exist = canteenSpuService.getOne(Wrappers.<CanteenSpuEntity>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<String, Long> categoryMap, String fullName, String pkgLabel, Integer priceFen, Integer stock,
List<CanteenSpuEntity> children, String ingredients, ImportCache cache) {
CanteenSpuEntity exist = cache.comboByName.get(fullName);
List<CanteenSpuComboItemEntity> 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<String, Long> loadCategoryMap(Long merchantId) {
List<CanteenCategoryEntity> list = canteenCategoryMapper.selectList(Wrappers.<CanteenCategoryEntity>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<String, Long> categoryMap) {
if (StrUtil.isNotBlank(categoryName) && categoryMap != null) {
Long id = categoryMap.get(categoryName.trim());
private Long resolveOrCreateCategoryId(String categoryName, Long defaultCategoryId, Map<String, Long> 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<String, Long> 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<CanteenDailyMenuImportExcel> 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 {
}
}
/**
* 单品标记(不按套餐组块处理):
* <ul>
* <li>无 / - / (无) / (无)</li>
* <li>单品、单品-xxx、单品1、【单品】等以「单品」开头</li>
* <li>单点、单点-xxx、单点1、【单点】等以「单点」开头</li>
* </ul>
*/
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 <p><br></p>} 被当成“已有配料”。
*/
private static String toPlainIngredients(String raw) {
if (StrUtil.isBlank(raw)) {
return "";
}
String text = raw.replaceAll("(?is)<br\\s*/?>", "\n")
.replaceAll("(?is)</p>", "\n")
.replaceAll("(?is)<p[^>]*>", "")
.replaceAll("(?is)<[^>]+>", "");
text = text.replace("&nbsp;", " ")
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.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<String, CanteenSpuEntity> dishByName = new HashMap<>();
final Map<String, CanteenSpuEntity> comboByName = new HashMap<>();
final Set<String> existingMenuKeys = new HashSet<>();
}
}
@@ -180,6 +180,7 @@
t.merchant_id,
sp.id AS spu_id,
sp.name,
sp.introduction,
sp.price,
sp.market_price,
sp.pic_url,