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,