From 09da5208ba9d2b2ccf4c2c83ef185f36846b58fe Mon Sep 17 00:00:00 2001
From: zjf <1799650030@qq.com>
Date: Thu, 24 Sep 2026 16:23:26 +0800
Subject: [PATCH] =?UTF-8?q?=E5=A5=97=E9=A4=90=E5=90=8D=E7=A7=BB=E5=88=B0?=
=?UTF-8?q?=E6=AF=8F=E6=97=A5=E8=8F=9C=E8=B0=B1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../dto/CanteenDailyMenuImportExcel.java | 5 +-
.../excel/ExcelSnowflakeIdConverter.java | 7 +
.../canteen/vo/ADCanteenSpuListVO.java | 6 -
.../pjcloud/canteen/vo/DailyMenuAdminVO.java | 5 +-
.../bsd/pjcloud/canteen/vo/DailyMenuVO.java | 10 +
.../CanteenDailyMenuTemplateMergeHandler.java | 177 ++++++-----------
.../impl/CanteenDailyMenuImportHelper.java | 187 +++++++++++++++---
.../impl/CanteenDailyMenuServiceImpl.java | 5 +
.../service/impl/CanteenSpuImportHelper.java | 21 +-
.../service/impl/CanteenSpuServiceImpl.java | 2 -
.../mapper/CanteenDailyMenuMapper.xml | 5 +-
11 files changed, 271 insertions(+), 159 deletions(-)
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 bc2be31dd..541824a2e 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
@@ -9,9 +9,8 @@ import java.math.BigDecimal;
* 每日菜谱导入/导出 Excel 行(导出含菜谱 ID,导入时可按 ID 更新)
*
列顺序:日期、餐段、套餐名、菜品名、分类、单价、套餐价、线上库存、线下库存、配料、菜谱ID、菜品SPU ID、子SPU ID、填写说明
*
线上/线下库存挂在每日菜谱行上,不再写入菜品列表 {@code canteen_spu.stock}。
- *
菜谱ID / 菜品SPU ID / 子SPU ID 必须用文本导出,否则 Excel 对超长雪花 ID 会丢精度。
- *
业务列(日期/餐段/套餐名/套餐价/库存等)导出时每行写全,不合并;套餐块仅「菜谱ID」「菜品SPU ID」可合并。
- * 「子SPU ID」=每行组成菜 ID,不合并。单品行「菜品SPU ID」=该单品,「子SPU ID」可空。
+ *
菜谱ID / 菜品SPU ID / 子SPU ID 必须用文本导出(带制表符前缀),否则 Excel 对超长雪花 ID 会丢精度。
+ *
业务列与 ID 列导出时每行写全,均不合并。
*
无「菜品SPU ID」时一律新建菜品/套餐;无「子SPU ID」时组成菜按菜名+配料回退或新建。
*/
@Data
diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/excel/ExcelSnowflakeIdConverter.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/excel/ExcelSnowflakeIdConverter.java
index b60120300..154e990b8 100644
--- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/excel/ExcelSnowflakeIdConverter.java
+++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/excel/ExcelSnowflakeIdConverter.java
@@ -58,6 +58,10 @@ public class ExcelSnowflakeIdConverter implements Converter {
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 cell = new WriteCellData<>(text);
cell.setType(CellDataTypeEnum.STRING);
return cell;
@@ -82,6 +86,9 @@ public class ExcelSnowflakeIdConverter implements Converter {
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);
}
diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/ADCanteenSpuListVO.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/ADCanteenSpuListVO.java
index 9d260d055..a41885b14 100644
--- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/ADCanteenSpuListVO.java
+++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/ADCanteenSpuListVO.java
@@ -30,12 +30,6 @@ public class ADCanteenSpuListVO {
@Schema(description = "菜品英文名称")
private String nameEn;
- /**
- * 套餐名(仅套餐时有值)
- */
- @Schema(description = "套餐名")
- private String comboName;
-
/**
* 商品简介
*/
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 4c27dcfd6..957122a0a 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,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;
diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/DailyMenuVO.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/DailyMenuVO.java
index 1dc321e09..ed20e4797 100644
--- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/DailyMenuVO.java
+++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/DailyMenuVO.java
@@ -53,6 +53,16 @@ public class DailyMenuVO {
*/
private Integer spuType;
+ /**
+ * 套餐名(canteen_spu.combo_name)
+ */
+ private String comboName;
+
+ /**
+ * 套餐标签兜底(canteen_spu.introduction)
+ */
+ private String introduction;
+
/**
* 套餐组成
*/
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 f054dcc55..9fa844628 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
@@ -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;
/**
* 每日菜谱导入模板 / 导出写后处理。
* 列:0日期 1餐段 2套餐名 3菜品名 4分类 5单价 6套餐价 7线上库存 8线下库存 9配料
* 10菜谱ID 11菜品SPU ID 12子SPU ID 13填写说明
- *
为减少填写混乱:日期/餐段/套餐名/套餐价/库存/说明等每行写全、不合并;
- * 仅套餐块内合并「菜谱ID」「菜品SPU ID」(同一排菜共用)。子SPU ID 每行独立、不合并。
- *
须配合 {@code @ResponseExcel(inMemory = true)},否则 SXSSF 刷盘后日期段导出会在改单元格/合并时失败。
+ *
业务列每行写全、不合并。雪花 ID 三列也每行写全、不合并
+ * (合并会导致 EasyExcel 读空;Excel 把 19 位 ID 当数字会丢精度并串号)。
+ *
须配合 {@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);
}
}
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 c9544d8ae..27e88bcc3 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
@@ -96,13 +96,13 @@ public class CanteenDailyMenuImportHelper {
public List templateRows() {
List 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 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.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 existChildByKey = existingComboChildKeyMap(comboExcelSpuId, cache);
boolean uniformSpu = isUniformSpuId(items);
Set 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.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」表示单品,不参与套餐继承。
* 换日/换餐段时必须清空菜谱ID继承,否则「同名套餐」跨天会被合成一组,组成菜看起来像重复。
+ *
确认导入时单品「无」常被清成空串;若本行已有独立菜谱ID,禁止再继承上一套餐名,否则会把套餐名串到单品 ID 上。
*/
private static void normalizeMergedCells(List 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;
}
diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuServiceImpl.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuServiceImpl.java
index 43cdbe705..f2234db08 100644
--- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuServiceImpl.java
+++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuServiceImpl.java
@@ -641,11 +641,16 @@ public class CanteenDailyMenuServiceImpl extends ServiceImpl 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);
diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImpl.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImpl.java
index 1db09efd0..4da093605 100644
--- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImpl.java
+++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImpl.java
@@ -409,7 +409,6 @@ public class CanteenSpuServiceImpl extends ServiceImpl
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,