套餐名移到每日菜谱

This commit is contained in:
zjf committed 2026-09-24 16:23:26 +08:00
1 parent 65b35b897b
commit 09da5208ba
11 files changed
+271 -159

No files matched your search

@@ -9,9 +9,8 @@ import java.math.BigDecimal;
* 每日菜谱导入/导出 Excel 行(导出含菜谱 ID,导入时可按 ID 更新)
* <p>列顺序:日期、餐段、套餐名、菜品名、分类、单价、套餐价、线上库存、线下库存、配料、菜谱ID、菜品SPU ID、子SPU ID、填写说明
* <p>线上/线下库存挂在每日菜谱行上,不再写入菜品列表 {@code canteen_spu.stock}。
* <p>菜谱ID / 菜品SPU ID / 子SPU ID 必须用文本导出,否则 Excel 对超长雪花 ID 会丢精度。
* <p>业务列(日期/餐段/套餐名/套餐价/库存等)导出时每行写全,不合并;套餐块仅「菜谱ID」「菜品SPU ID」可合并。
* 「子SPU ID」=每行组成菜 ID,不合并。单品行「菜品SPU ID」=该单品,「子SPU ID」可空。
* <p>菜谱ID / 菜品SPU ID / 子SPU ID 必须用文本导出(带制表符前缀),否则 Excel 对超长雪花 ID 会丢精度。
* <p>业务列与 ID 列导出时每行写全,均不合并。
* <p>无「菜品SPU ID」时一律新建菜品/套餐;无「子SPU ID」时组成菜按菜名+配料回退或新建。
*/
@Data
@@ -58,6 +58,10 @@ public class ExcelSnowflakeIdConverter implements Converter<String> {
private static WriteCellData<?> toTextCell(String value) {
String text = value == null ? "" : value.trim();
// 前导制表符:Excel/WPS 打开时更不容易自动改成数字
if (!text.isEmpty() && !text.startsWith("\t") && !text.startsWith("'")) {
text = "\t" + text;
}
WriteCellData<String> cell = new WriteCellData<>(text);
cell.setType(CellDataTypeEnum.STRING);
return cell;
@@ -82,6 +86,9 @@ public class ExcelSnowflakeIdConverter implements Converter<String> {
return null;
}
String s = raw.trim();
while (s.startsWith("\t") || s.startsWith("'") || s.startsWith("\u200B")) {
s = s.substring(1).trim();
}
if (s.endsWith(".0")) {
s = s.substring(0, s.length() - 2);
}
@@ -30,12 +30,6 @@ public class ADCanteenSpuListVO {
@Schema(description = "菜品英文名称")
private String nameEn;
/**
* 套餐名(仅套餐时有值)
*/
@Schema(description = "套餐名")
private String comboName;
/**
* 商品简介
*/
@@ -27,7 +27,10 @@ public class DailyMenuAdminVO {
private String name;
/** 套餐标签(canteen_spu.introduction),导出套餐名优先用此字段,避免名称中的 `-` 被截断 */
/** 套餐名(canteen_spu.combo_name),导出 Excel「套餐名」列优先 */
private String comboName;
/** 套餐标签兜底(canteen_spu.introduction),combo_name 为空时导出用 */
private String introduction;
private String picUrl;
@@ -53,6 +53,16 @@ public class DailyMenuVO {
*/
private Integer spuType;
/**
* 套餐名(canteen_spu.combo_name)
*/
private String comboName;
/**
* 套餐标签兜底(canteen_spu.introduction)
*/
private String introduction;
/**
* 套餐组成
*/
@@ -9,15 +9,14 @@ import org.apache.poi.ss.usermodel.HorizontalAlignment;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.VerticalAlignment;
import org.apache.poi.ss.util.CellRangeAddress;
/**
* 每日菜谱导入模板 / 导出写后处理。
* <p>列:0日期 1餐段 2套餐名 3菜品名 4分类 5单价 6套餐价 7线上库存 8线下库存 9配料
* 10菜谱ID 11菜品SPU ID 12子SPU ID 13填写说明
* <p>为减少填写混乱:日期/餐段/套餐名/套餐价/库存/说明等<strong>每行写全、不合并</strong>;
* 仅套餐块内合并「菜谱ID」「菜品SPU ID」(同一排菜共用)。子SPU ID 每行独立、不合并。
* <p>须配合 {@code @ResponseExcel(inMemory = true)},否则 SXSSF 刷盘后日期段导出会在改单元格/合并时失败。
* <p>业务列每行写全、不合并。雪花 ID 三列也<strong>每行写全、不合并</strong>
* (合并会导致 EasyExcel 读空;Excel 把 19 位 ID 当数字会丢精度并串号)。
* <p>须配合 {@code @ResponseExcel(inMemory = true)},否则 SXSSF 刷盘后改单元格会失败。
*/
public class CanteenDailyMenuTemplateMergeHandler implements WorkbookWriteHandler {
@@ -31,6 +30,9 @@ public class CanteenDailyMenuTemplateMergeHandler implements WorkbookWriteHandle
private static final int COL_SPU = 11;
private static final int COL_CHILD_SPU = 12;
/** 写入 ID 前缀,迫使 Excel/WPS 保持文本,避免 19 位雪花被当数字丢精度 */
public static final String ID_TEXT_PREFIX = "\t";
@Override
public void afterWorkbookDispose(WriteWorkbookHolder writeWorkbookHolder) {
Sheet sheet = writeWorkbookHolder.getWorkbook().getSheetAt(0);
@@ -48,49 +50,20 @@ public class CanteenDailyMenuTemplateMergeHandler implements WorkbookWriteHandle
center.setAlignment(HorizontalAlignment.CENTER);
center.setVerticalAlignment(VerticalAlignment.CENTER);
fillEveryRowAndMergeIdsOnly(sheet, center);
forceIdColumnsAsText(sheet, writeWorkbookHolder);
CellStyle idTextStyle = writeWorkbookHolder.getWorkbook().createCellStyle();
idTextStyle.setDataFormat(writeWorkbookHolder.getWorkbook().createDataFormat().getFormat("@"));
idTextStyle.setAlignment(HorizontalAlignment.CENTER);
idTextStyle.setVerticalAlignment(VerticalAlignment.CENTER);
fillEveryRowNoMerge(sheet, center, idTextStyle);
setColumnWidths(sheet);
}
/** 菜谱ID / 菜品SPU / 子SPU 强制文本格式,降低 Excel 自动改成数字丢精度的概率 */
private static void forceIdColumnsAsText(Sheet sheet, WriteWorkbookHolder holder) {
CellStyle textStyle = holder.getWorkbook().createCellStyle();
textStyle.setDataFormat(holder.getWorkbook().createDataFormat().getFormat("@"));
textStyle.setAlignment(HorizontalAlignment.CENTER);
textStyle.setVerticalAlignment(VerticalAlignment.CENTER);
int lastRow = sheet.getLastRowNum();
for (int r = 1; r <= lastRow; r++) {
for (int col : new int[] { COL_ID, COL_SPU, COL_CHILD_SPU }) {
Cell cell = getCell(sheet, r, col);
if (cell == null) {
continue;
}
if (cell.getCellType() == CellType.NUMERIC) {
double n = cell.getNumericCellValue();
String text = (n == Math.rint(n)) ? String.valueOf((long) n) : String.valueOf(n);
cell.setCellValue(text);
}
cell.setCellStyle(textStyle);
}
}
}
/**
* 将套餐续行空白补全为与首行一致的业务值(便于直接改表),并仅合并菜谱ID / 菜品SPU ID。
* 业务列与 ID 列均每行写全;ID 列强制文本格式 + 制表符前缀,禁止经 double 转写。
*/
private static void fillEveryRowAndMergeIdsOnly(Sheet sheet, CellStyle center) {
private static void fillEveryRowNoMerge(Sheet sheet, CellStyle center, CellStyle idTextStyle) {
int lastRow = sheet.getLastRowNum();
String[] dates = new String[lastRow + 1];
String[] meals = new String[lastRow + 1];
String[] combos = new String[lastRow + 1];
String[] menuIds = new String[lastRow + 1];
String[] spuIds = new String[lastRow + 1];
String[] comboPrices = new String[lastRow + 1];
String[] onlineStocks = new String[lastRow + 1];
String[] offlineStocks = new String[lastRow + 1];
boolean[] comboGroup = new boolean[lastRow + 1];
String lastDate = null;
String lastMeal = null;
String lastCombo = null;
@@ -107,6 +80,7 @@ public class CanteenDailyMenuTemplateMergeHandler implements WorkbookWriteHandle
String combo = cellText(sheet, r, COL_COMBO);
String menuId = cellText(sheet, r, COL_ID);
String spuId = cellText(sheet, r, COL_SPU);
String childSpuId = cellText(sheet, r, COL_CHILD_SPU);
String comboPrice = cellText(sheet, r, COL_COMBO_PRICE);
String online = cellText(sheet, r, COL_ONLINE_STOCK);
String offline = cellText(sheet, r, COL_OFFLINE_STOCK);
@@ -215,85 +189,51 @@ public class CanteenDailyMenuTemplateMergeHandler implements WorkbookWriteHandle
}
}
dates[r] = date;
meals[r] = meal;
combos[r] = combo;
menuIds[r] = menuId;
spuIds[r] = spuId;
comboPrices[r] = comboPrice;
onlineStocks[r] = online;
offlineStocks[r] = offline;
comboGroup[r] = inCombo && !isSingleCombo(combo);
}
// 业务列每行写全,不再合并(避免只改首行/续行读空)
for (int r = 1; r <= lastRow; r++) {
writeText(sheet, r, COL_DATE, dates[r], center);
writeText(sheet, r, COL_MEAL, meals[r], center);
writeText(sheet, r, COL_COMBO, combos[r], center);
if (comboGroup[r]) {
writeText(sheet, r, COL_COMBO_PRICE, comboPrices[r], center);
writeText(sheet, r, COL_ONLINE_STOCK, onlineStocks[r], center);
writeText(sheet, r, COL_OFFLINE_STOCK, offlineStocks[r], center);
writeText(sheet, r, COL_ID, menuIds[r], center);
writeText(sheet, r, COL_SPU, spuIds[r], center);
}
}
// 仅套餐块合并菜谱ID、菜品SPU ID
int runStart = 1;
for (int r = 2; r <= lastRow + 1; r++) {
boolean same = r <= lastRow
&& comboGroup[runStart]
&& comboGroup[r]
&& eq(dates[r], dates[runStart])
&& eq(meals[r], meals[runStart])
&& eq(combos[r], combos[runStart])
&& eq(menuIds[r], menuIds[runStart]);
if (!same) {
if (comboGroup[runStart] && runStart <= lastRow) {
int endRow = Math.min(r - 1, lastRow);
mergeRange(sheet, runStart, endRow, COL_ID, center);
mergeRange(sheet, runStart, endRow, COL_SPU, center);
}
runStart = r;
writeText(sheet, r, COL_DATE, date, center);
writeText(sheet, r, COL_MEAL, meal, center);
writeText(sheet, r, COL_COMBO, combo, center);
if (inCombo && !isSingleCombo(combo)) {
writeText(sheet, r, COL_COMBO_PRICE, comboPrice, center);
writeText(sheet, r, COL_ONLINE_STOCK, online, center);
writeText(sheet, r, COL_OFFLINE_STOCK, offline, center);
}
writeIdText(sheet, r, COL_ID, menuId, idTextStyle);
writeIdText(sheet, r, COL_SPU, spuId, idTextStyle);
writeIdText(sheet, r, COL_CHILD_SPU, childSpuId, idTextStyle);
}
}
private static void writeText(Sheet sheet, int rowIdx, int col, String value, CellStyle center) {
private static void writeText(Sheet sheet, int rowIdx, int col, String value, CellStyle style) {
if (isBlank(value)) {
return;
}
Cell cell = getOrCreateCell(sheet, rowIdx, col);
cell.setCellValue(value);
cell.setCellStyle(center);
cell.setCellStyle(style);
}
private static void mergeRange(Sheet sheet, int firstRow, int lastRow, int col, CellStyle center) {
if (firstRow > lastRow) {
private static void writeIdText(Sheet sheet, int rowIdx, int col, String value, CellStyle idTextStyle) {
Cell cell = getOrCreateCell(sheet, rowIdx, col);
if (isBlank(value)) {
cell.setBlank();
cell.setCellStyle(idTextStyle);
return;
}
if (firstRow == lastRow) {
Cell cell = getCell(sheet, firstRow, col);
if (cell != null) {
cell.setCellStyle(center);
}
return;
String plain = stripIdPrefix(value.trim());
cell.setCellValue(ID_TEXT_PREFIX + plain);
cell.setCellStyle(idTextStyle);
}
/** 导入读回时去掉导出加的制表符/引号/零宽前缀 */
public static String stripIdPrefix(String raw) {
if (raw == null) {
return null;
}
CellRangeAddress region = new CellRangeAddress(firstRow, lastRow, col, col);
for (int i = 0; i < sheet.getNumMergedRegions(); i++) {
if (sheet.getMergedRegion(i).intersects(region)) {
return;
}
}
sheet.addMergedRegion(region);
for (int r = firstRow; r <= lastRow; r++) {
Cell cell = getCell(sheet, r, col);
if (cell != null) {
cell.setCellStyle(center);
}
String s = raw.trim();
while (s.startsWith("\t") || s.startsWith("'") || s.startsWith("\u200B")) {
s = s.substring(1).trim();
}
return s;
}
private static Cell getOrCreateCell(Sheet sheet, int rowIdx, int col) {
@@ -313,6 +253,9 @@ public class CanteenDailyMenuTemplateMergeHandler implements WorkbookWriteHandle
return row == null ? null : row.getCell(col);
}
/**
* 读单元格文本。雪花 ID 绝不能走 {@code (long) double},否则相邻 ID 会串号。
*/
private static String cellText(Sheet sheet, int rowIdx, int col) {
Cell cell = getCell(sheet, rowIdx, col);
if (cell == null) {
@@ -320,21 +263,18 @@ public class CanteenDailyMenuTemplateMergeHandler implements WorkbookWriteHandle
}
CellType type = cell.getCellType();
if (type == CellType.STRING) {
return trimToNull(cell.getStringCellValue());
return trimToNull(stripIdPrefix(cell.getStringCellValue()));
}
if (type == CellType.NUMERIC) {
double n = cell.getNumericCellValue();
if (n == Math.rint(n)) {
return String.valueOf((long) n);
}
return String.valueOf(n);
return new java.math.BigDecimal(String.valueOf(cell.getNumericCellValue())).toPlainString()
.replaceAll("\\.0+$", "");
}
if (type == CellType.BOOLEAN) {
return String.valueOf(cell.getBooleanCellValue());
}
if (type == CellType.FORMULA) {
try {
return trimToNull(cell.getStringCellValue());
return trimToNull(stripIdPrefix(cell.getStringCellValue()));
}
catch (Exception e) {
return null;
@@ -355,13 +295,6 @@ public class CanteenDailyMenuTemplateMergeHandler implements WorkbookWriteHandle
return s == null || s.isBlank();
}
private static boolean eq(String a, String b) {
if (a == null) {
return b == null;
}
return a.equals(b);
}
private static boolean isSingleCombo(String combo) {
if (isBlank(combo)) {
return true;
@@ -407,9 +340,9 @@ public class CanteenDailyMenuTemplateMergeHandler implements WorkbookWriteHandle
sheet.setColumnWidth(7, 10 * 256);
sheet.setColumnWidth(8, 10 * 256);
sheet.setColumnWidth(9, 22 * 256);
sheet.setColumnWidth(10, 20 * 256);
sheet.setColumnWidth(11, 20 * 256);
sheet.setColumnWidth(12, 20 * 256);
sheet.setColumnWidth(10, 22 * 256);
sheet.setColumnWidth(11, 22 * 256);
sheet.setColumnWidth(12, 22 * 256);
sheet.setColumnWidth(13, 48 * 256);
}
}
@@ -96,13 +96,13 @@ public class CanteenDailyMenuImportHelper {
public List<CanteenDailyMenuImportExcel> templateRows() {
List<CanteenDailyMenuImportExcel> list = new ArrayList<>();
// 业务列每行写全(不靠合并续行);仅菜谱ID/菜品SPU ID 在套餐块内可能合并展示
// 业务列与雪花 ID 均每行写全(文本),避免 Excel 数字精度串号
list.add(row("2026-08-17", "午餐", "A套餐", "糖醋排骨", "荤菜", "青莲仔排", "12", "21", "80", "100",
"套餐多行:日期/餐段/套餐名/套餐价/库存每行都填;菜品SPU ID=套餐本体;子SPU ID=本行组成菜"));
"套餐多行:日期/餐段/套餐名/套餐价/库存/菜谱ID 每行都填;菜品SPU ID=套餐本体;子SPU ID=本行组成菜;ID列必须为文本"));
list.add(row("2026-08-17", "午餐", "A套餐", "蒜苔炒腊肉", "荤菜", null, "8", "21", "80", "100", null));
list.add(row("2026-08-17", "午餐", "A套餐", "清炒时蔬", "素菜", null, "3", "21", "80", "100", null));
list.add(row("2026-08-17", "午餐", "B套餐", "红烧肉", "荤菜", "五花肉、土豆", "10", "18", "60", "80",
"换套餐名即另一套;无菜谱ID时新建;有菜谱ID/套餐SPU则更新"));
"换套餐名即另一套;无菜谱ID时新建;有菜谱ID/套餐SPU则更新;打开Excel后勿把ID列改成数字"));
list.add(row("2026-08-17", "午餐", "B套餐", "土豆丝", "素菜", null, "4", "18", "60", "80", null));
list.add(row("2026-08-17", "午餐", "无", "千张大骨汤", "汤类", null, "0", null, "50", "50",
"单品:套餐名填「无」;菜品SPU ID=本行单品;子SPU ID可空"));
@@ -213,7 +213,7 @@ public class CanteenDailyMenuImportHelper {
row.setDishName(StrUtil.blankToDefault(item.getName(), ""));
row.setDishPriceYuan(fenToYuan(item.getPrice()));
if (written == 0) {
row.setTip("套餐块:同套餐名多行=一道排菜;仅菜谱ID/菜品SPU可合并展示");
row.setTip("套餐块:同套餐名多行=一道排菜;菜谱ID/菜品SPU ID 每行都填(文本),勿改成数字");
}
rows.add(row);
written++;
@@ -238,10 +238,13 @@ public class CanteenDailyMenuImportHelper {
}
/**
* 导出套餐名:优先 introduction(导入时写入的原始套餐标签,可含 -);
* 导出套餐名:优先 combo_name(打印/展示用套餐标签),其次 introduction,
* 否则尝试按「套餐名-首道菜名」还原,避免把「套餐A-周二午」截成「套餐A」。
*/
private static String resolveComboLabel(DailyMenuAdminVO menu) {
if (StrUtil.isNotBlank(menu.getComboName())) {
return menu.getComboName().trim();
}
if (StrUtil.isNotBlank(menu.getIntroduction())) {
return menu.getIntroduction().trim();
}
@@ -711,7 +714,9 @@ public class CanteenDailyMenuImportHelper {
// 预览日期一律用规范化后的 yyyy-MM-dd,与上方日期 Tab 一致
previewRow.setBizDate(dateStr);
previewRow.setMealName(StrUtil.trim(item.getMealName()));
previewRow.setComboName(StrUtil.trim(item.getComboName()));
// 单品在 normalize 后套餐名为空;预览回填「无」,确认导入再 normalize 时不会误继承上一套餐
String previewCombo = StrUtil.trim(item.getComboName());
previewRow.setComboName(StrUtil.isBlank(previewCombo) ? "无" : previewCombo);
previewRow.setDishName(StrUtil.trim(item.getDishName()));
previewRow.setCategoryName(StrUtil.trim(item.getCategoryName()));
previewRow.setDishPriceYuan(item.getDishPriceYuan());
@@ -848,6 +853,20 @@ public class CanteenDailyMenuImportHelper {
items.forEach(r -> r.setId(null));
menuId = null;
}
else {
// 菜谱ID 与 Excel 套餐/单品意图不一致时纠偏(误继承套餐名或 ID 串号)
Long healed = healMenuIdTypeMismatch(menuId, items, merchantId, groupDate, groupHour, cache);
if (healed == null) {
idAsNew++;
items.forEach(r -> r.setId(null));
menuId = null;
}
else if (!Objects.equals(healed, menuId)) {
menuId = healed;
String healedStr = String.valueOf(healed);
items.forEach(r -> r.setId(healedStr));
}
}
}
DayImportStat st = dayStats.computeIfAbsent(dateStr, DayImportStat::new);
if (menuId != null) {
@@ -1116,6 +1135,9 @@ public class CanteenDailyMenuImportHelper {
if (s.startsWith(ExcelSnowflakeIdConverter.NUMERIC_CORRUPT_PREFIX)) {
return null;
}
while (s.startsWith("\t") || s.startsWith("'") || s.startsWith("\u200B")) {
s = s.substring(1).trim();
}
if (s.endsWith(".0")) {
s = s.substring(0, s.length() - 2);
}
@@ -1476,6 +1498,78 @@ public class CanteenDailyMenuImportHelper {
pendingMenus.clear();
}
/**
* 菜谱ID 指向的排菜类型与 Excel 套餐名意图不一致时纠偏。
* @return 可用的菜谱ID;无法纠偏时返回 null(调用方按新增处理)
*/
private Long healMenuIdTypeMismatch(Long menuId, List<CanteenDailyMenuImportExcel> items, Long merchantId,
LocalDate date, Integer hourType, ImportCache cache) {
CanteenDailyMenuEntity menu = resolveMenu(menuId, cache);
if (menu == null || CollUtil.isEmpty(items)) {
return menuId;
}
CanteenDailyMenuImportExcel first = items.get(0);
String comboName = StrUtil.trim(first.getComboName());
boolean wantsCombo = StrUtil.isNotBlank(comboName) && !isSingleComboMarker(comboName);
CanteenSpuEntity menuSpu = menu.getSpuId() == null ? null : resolveSpuById(menu.getSpuId(), cache);
int menuSpuType = menuSpu == null || menuSpu.getSpuType() == null ? 1 : menuSpu.getSpuType();
boolean menuIsCombo = menuSpuType == 2;
if (wantsCombo == menuIsCombo) {
return menuId;
}
if (wantsCombo) {
Long comboSpuId = resolveComboExcelSpuId(items, cache);
if (comboSpuId != null && date != null && hourType != null) {
CanteenDailyMenuEntity alt = findMenuByDateHourSpu(merchantId, date, hourType, comboSpuId, cache);
if (alt != null) {
return alt.getId();
}
}
// 找不到正确套餐排菜:清空 ID 走新增,避免把单品排菜改成套餐
return null;
}
// Excel 要单品,菜谱却是套餐:按本行菜品 SPU 重挂
Long dishSpuId = parseExcelLong(first.getSpuId());
if (dishSpuId != null && date != null && hourType != null) {
CanteenSpuEntity dish = resolveSpuById(dishSpuId, cache);
int dishType = dish == null || dish.getSpuType() == null ? 1 : dish.getSpuType();
if (dishType != 2) {
CanteenDailyMenuEntity alt = findMenuByDateHourSpu(merchantId, date, hourType, dishSpuId, cache);
if (alt != null) {
return alt.getId();
}
}
}
return null;
}
/** 按商户+日期+餐段+SPU 查找已有排菜 */
private CanteenDailyMenuEntity findMenuByDateHourSpu(Long merchantId, LocalDate date, Integer hourType, Long spuId,
ImportCache cache) {
if (merchantId == null || date == null || hourType == null || spuId == null) {
return null;
}
for (CanteenDailyMenuEntity m : cache.menuById.values()) {
if (m != null
&& merchantId.equals(m.getMerchantId())
&& date.equals(m.getStartTime())
&& hourType.equals(m.getHourType())
&& spuId.equals(m.getSpuId())) {
return m;
}
}
CanteenDailyMenuEntity db = canteenDailyMenuService.getOne(Wrappers.<CanteenDailyMenuEntity>lambdaQuery()
.eq(CanteenDailyMenuEntity::getMerchantId, merchantId)
.eq(CanteenDailyMenuEntity::getStartTime, date)
.eq(CanteenDailyMenuEntity::getHourType, hourType)
.eq(CanteenDailyMenuEntity::getSpuId, spuId)
.last("LIMIT 1"), false);
if (db != null && db.getId() != null) {
cache.menuById.put(db.getId(), db);
}
return db;
}
/**
* 按菜谱 ID 更新一组行(单品一行,套餐多行)
*/
@@ -1538,6 +1632,7 @@ public class CanteenDailyMenuImportHelper {
Integer onlineStock = items.get(0).getOnlineStock();
Integer offlineStock = items.get(0).getOfflineStock();
Long comboExcelSpuId = resolveComboExcelSpuId(items, cache);
CanteenDailyMenuEntity target = menu;
if (comboExcelSpuId == null && menu.getSpuId() != null) {
CanteenSpuEntity menuSpu = resolveSpuById(menu.getSpuId(), cache);
int menuSpuType = menuSpu == null || menuSpu.getSpuType() == null ? 1 : menuSpu.getSpuType();
@@ -1545,13 +1640,33 @@ public class CanteenDailyMenuImportHelper {
comboExcelSpuId = menu.getSpuId();
}
else {
return "菜谱ID " + menu.getId() + " 当前挂的是单品「"
+ StrUtil.blankToDefault(menuSpu == null ? null : menuSpu.getName(), String.valueOf(menu.getSpuId()))
+ "」,但 Excel 套餐名为「" + comboName + "」。"
+ "常见原因:菜谱ID/SPU在Excel被当成数字丢了精度,套餐与单品串到同一菜谱ID。"
+ "请重新导出,并确保「菜谱ID」「菜品SPU ID」列为文本后再导入。";
// 与 Excel 套餐意图不一致:尝试按套餐 SPU + 日期餐段重挂到正确排菜
Long remappedSpu = null;
for (CanteenDailyMenuImportExcel item : items) {
Long sid = parseExcelLong(item == null ? null : item.getSpuId());
if (sid == null) {
continue;
}
CanteenSpuEntity s = resolveSpuById(sid, cache);
if (s != null && s.getSpuType() != null && s.getSpuType() == 2) {
remappedSpu = sid;
break;
}
}
CanteenDailyMenuEntity alt = remappedSpu == null ? null
: findMenuByDateHourSpu(merchantId, date, hourType, remappedSpu, cache);
if (alt == null) {
return "菜谱ID " + menu.getId() + " 当前挂的是单品「"
+ StrUtil.blankToDefault(menuSpu == null ? null : menuSpu.getName(),
String.valueOf(menu.getSpuId()))
+ "」,但 Excel 套餐名为「" + comboName
+ "」。请清空本行「菜谱ID」按新增导入,或检查套餐名是否误填到了单品行。";
}
target = alt;
comboExcelSpuId = remappedSpu;
}
}
final CanteenDailyMenuEntity targetMenu = target;
Map<String, Long> existChildByKey = existingComboChildKeyMap(comboExcelSpuId, cache);
boolean uniformSpu = isUniformSpuId(items);
Set<String> seenDishKeys = new LinkedHashSet<>();
@@ -1562,7 +1677,7 @@ public class CanteenDailyMenuImportHelper {
}
String dish = StrUtil.trim(item.getDishName());
if (StrUtil.isBlank(dish)) {
return "菜谱ID " + menu.getId() + ":套餐组成菜品名不能为空";
return "菜谱ID " + targetMenu.getId() + ":套餐组成菜品名不能为空";
}
String dishKey = dishIdentityKey(dish, item.getIngredients());
if (!seenDishKeys.add(dishKey)) {
@@ -1577,13 +1692,13 @@ public class CanteenDailyMenuImportHelper {
}
children = dedupeComboChildren(children);
if (children.isEmpty()) {
return "菜谱ID " + menu.getId() + ":套餐没有有效组成菜";
return "菜谱ID " + targetMenu.getId() + ":套餐没有有效组成菜";
}
if (comboPrice == null) {
CanteenSpuEntity existCombo = cache.comboByName.values().stream()
.filter(s -> Objects.equals(s.getId(), menu.getSpuId()))
.filter(s -> Objects.equals(s.getId(), targetMenu.getSpuId()))
.findFirst()
.orElseGet(() -> canteenSpuService.getById(menu.getSpuId()));
.orElseGet(() -> canteenSpuService.getById(targetMenu.getSpuId()));
comboPrice = existCombo != null && existCombo.getPrice() != null ? existCombo.getPrice() : 0;
}
String mainName = children.get(0).getName();
@@ -1592,12 +1707,12 @@ public class CanteenDailyMenuImportHelper {
items.get(0).getCategoryName(), defaultCategoryId, categoryMap, fullComboName,
comboName, comboPrice, null, children, resolveComboIngredients(items.get(0)),
comboExcelSpuId, cache);
menu.setStartTime(date);
menu.setHourType(hourType);
menu.setSpuId(combo.spu.getId());
menu.setOnlineStock(CanteenDailyMenuStockSupport.quotaOrDefault(onlineStock));
menu.setOfflineStock(CanteenDailyMenuStockSupport.quotaOrDefault(offlineStock));
canteenDailyMenuService.updateById(menu);
targetMenu.setStartTime(date);
targetMenu.setHourType(hourType);
targetMenu.setSpuId(combo.spu.getId());
targetMenu.setOnlineStock(CanteenDailyMenuStockSupport.quotaOrDefault(onlineStock));
targetMenu.setOfflineStock(CanteenDailyMenuStockSupport.quotaOrDefault(offlineStock));
canteenDailyMenuService.updateById(targetMenu);
return null;
}
@@ -1887,6 +2002,10 @@ public class CanteenDailyMenuImportHelper {
if (changeIntro) {
exist.setIntroduction(pkgLabel);
}
boolean changeComboName = StrUtil.isNotBlank(pkgLabel) && !Objects.equals(exist.getComboName(), pkgLabel);
if (changeComboName) {
exist.setComboName(pkgLabel);
}
boolean changeCategory = false;
if (StrUtil.isNotBlank(normalizeCategoryName(categoryName))) {
Long newCatId = resolveOrCreateCategoryId(categoryName, defaultCategoryId, categoryMap, merchantId,
@@ -1912,7 +2031,7 @@ public class CanteenDailyMenuImportHelper {
canteenSpuService.updateDeep(exist);
cache.comboChildIds.put(exist.getId(), new ArrayList<>(newChildIds));
}
else if (changePrice || changeStock || changeIntro || changeDesc || changeCategory) {
else if (changePrice || changeStock || changeIntro || changeComboName || changeDesc || changeCategory) {
var uw = Wrappers.<CanteenSpuEntity>lambdaUpdate().eq(CanteenSpuEntity::getId, exist.getId());
if (changePrice) {
uw.set(CanteenSpuEntity::getPrice, exist.getPrice());
@@ -1924,6 +2043,9 @@ public class CanteenDailyMenuImportHelper {
if (changeIntro) {
uw.set(CanteenSpuEntity::getIntroduction, exist.getIntroduction());
}
if (changeComboName) {
uw.set(CanteenSpuEntity::getComboName, exist.getComboName());
}
if (changeDesc) {
uw.set(CanteenSpuEntity::getDescription, exist.getDescription());
}
@@ -1941,6 +2063,7 @@ public class CanteenDailyMenuImportHelper {
}
CanteenSpuEntity combo = newBaseSpu(merchantId, deptId, categoryId, fullName, 2);
combo.setIntroduction(pkgLabel);
combo.setComboName(pkgLabel);
applyComboIngredients(combo, ingredients);
combo.setPrice(priceFen);
combo.setMarketPrice(priceFen);
@@ -2130,6 +2253,7 @@ public class CanteenDailyMenuImportHelper {
* 合并单元格旧模板读入后,非首行日期/餐段/套餐名/分类为空,需向下填充(新导出已每行写全,仍兼容旧表)。
* 套餐名填「无」「单品」「单点」或「单品-xxx / 单点-xxx」表示单品,不参与套餐继承。
* <p>换日/换餐段时必须清空菜谱ID继承,否则「同名套餐」跨天会被合成一组,组成菜看起来像重复。
* <p>确认导入时单品「无」常被清成空串;若本行已有独立菜谱ID,禁止再继承上一套餐名,否则会把套餐名串到单品 ID 上。
*/
private static void normalizeMergedCells(List<CanteenDailyMenuImportExcel> rows) {
String lastDate = null;
@@ -2182,7 +2306,26 @@ public class CanteenDailyMenuImportHelper {
row.setCategoryName(lastCategory);
}
String combo = StrUtil.trim(row.getComboName());
String thisMenuId = StrUtil.trim(row.getId());
if (StrUtil.isBlank(combo)) {
/*
* 确认导入时单品「无」会变成空串再走一遍本方法;若仍继承上一套餐名,
* 会把 C2 等套餐名挂到下一道单品的菜谱ID 上(如 …5895),导入直接失败。
* 规则:本行已有独立菜谱ID且与上一套餐不同 → 按单品,切断继承。
*/
if (StrUtil.isNotBlank(thisMenuId) && (lastMenuId == null || !thisMenuId.equals(lastMenuId))) {
row.setComboName(null);
lastCombo = null;
lastMenuId = thisMenuId;
if (StrUtil.isNotBlank(row.getSpuId())) {
lastSpuId = StrUtil.trim(row.getSpuId());
row.setSpuId(lastSpuId);
}
else {
lastSpuId = null;
}
continue;
}
row.setComboName(lastCombo);
combo = lastCombo;
}
@@ -641,11 +641,16 @@ public class CanteenDailyMenuServiceImpl extends ServiceImpl<CanteenDailyMenuMap
for (DailyMenuAppVO vo : list) {
int type = vo.getSpuType() == null ? 1 : vo.getSpuType();
vo.setSpuType(type);
// 旧数据仅写了 introduction:App/收银展示套餐名时回落
if (type == 2 && StrUtil.isBlank(vo.getComboName()) && StrUtil.isNotBlank(vo.getIntroduction())) {
vo.setComboName(vo.getIntroduction().trim());
}
if (type == 2) {
vo.setComboItems(comboMap.getOrDefault(vo.getSpuId(), Collections.emptyList()));
}
else {
vo.setComboItems(Collections.emptyList());
vo.setComboName(null);
}
}
}
@@ -128,7 +128,7 @@ public class CanteenSpuImportHelper {
List<OnlineSpuComboItemVO> items = comboMap.getOrDefault(spu.getId(), List.of());
if (CollUtil.isEmpty(items)) {
CanteenSpuImportExcel row = baseExportRow(spu, categoryNameMap);
row.setComboName(spu.getName());
row.setComboName(exportComboLabel(spu));
row.setComboId(String.valueOf(spu.getId()));
row.setDishId(null);
row.setName("");
@@ -140,7 +140,7 @@ public class CanteenSpuImportHelper {
OnlineSpuComboItemVO item = items.get(i);
CanteenSpuEntity child = childCache.get(item.getChildSpuId());
CanteenSpuImportExcel row = new CanteenSpuImportExcel();
row.setComboName(spu.getName());
row.setComboName(exportComboLabel(spu));
row.setComboId(String.valueOf(spu.getId()));
row.setName(item.getName() != null ? item.getName() : (child != null ? child.getName() : null));
Long childId = child != null ? child.getId() : item.getChildSpuId();
@@ -658,6 +658,8 @@ public class CanteenSpuImportHelper {
existCombo.setIsShow(parseIsShow(first.getIsShowName()));
}
existCombo.setSpuType(2);
existCombo.setComboName(comboName);
existCombo.setIntroduction(comboName);
existCombo.setComboItems(comboItems);
if (existCombo.getPicUrl() == null) {
existCombo.setPicUrl("");
@@ -678,6 +680,7 @@ public class CanteenSpuImportHelper {
combo.setStatus(parseStatus(first.getStatusName()));
combo.setIsShow(parseIsShow(first.getIsShowName()));
combo.setIntroduction(comboName);
combo.setComboName(comboName);
combo.setComboItems(comboItems);
canteenSpuService.saveDeep(combo);
createStock(combo);
@@ -1123,6 +1126,20 @@ public class CanteenSpuImportHelper {
return comboName.trim();
}
/** 导出「套餐名」列:优先 combo_name,其次 introduction,最后商品名 */
private static String exportComboLabel(CanteenSpuEntity spu) {
if (spu == null) {
return "";
}
if (StrUtil.isNotBlank(spu.getComboName())) {
return spu.getComboName().trim();
}
if (StrUtil.isNotBlank(spu.getIntroduction())) {
return spu.getIntroduction().trim();
}
return StrUtil.blankToDefault(spu.getName(), "");
}
private static boolean isSingleMarker(String combo) {
return "无".equals(combo) || "-".equals(combo) || "单品".equals(combo)
|| "(无)".equals(combo) || "(无)".equals(combo);
@@ -409,7 +409,6 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
item.setIntroduction(entity.getIntroduction());
item.setDescription(entity.getDescription());
item.setNameEn(entity.getNameEn());
item.setComboName(entity.getComboName());
int type = entity.getSpuType() == null ? 1 : entity.getSpuType();
item.setSpuType(type);
if (type == 2) {
@@ -442,7 +441,6 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
vo.setIntroduction(detail.getIntroduction());
vo.setDescription(detail.getDescription());
vo.setNameEn(detail.getNameEn());
vo.setComboName(detail.getComboName());
int type = detail.getSpuType() == null ? 1 : detail.getSpuType();
vo.setSpuType(type);
if (type == 2) {
@@ -61,7 +61,9 @@
<!-- 数据权限拦截器外层按 create_by / dept_id 过滤,须透传到临时结果集 -->
sp.dept_id,
t.create_by,
IFNULL(sp.spu_type, 1) as spu_type
IFNULL(sp.spu_type, 1) as spu_type,
sp.combo_name,
sp.introduction
FROM canteen_daily_menu t
LEFT JOIN canteen_spu sp
on t.spu_id=sp.id
@@ -307,6 +309,7 @@
t.merchant_id,
sp.id AS spu_id,
sp.name,
sp.combo_name,
sp.introduction,
sp.price,
sp.market_price,