From 2edbdcce799ae163038693f882cfbc91c490bc43 Mon Sep 17 00:00:00 2001 From: zjf <1799650030@qq.com> Date: Mon, 14 Sep 2026 18:35:41 +0800 Subject: [PATCH] =?UTF-8?q?=E6=AF=8F=E6=97=A5=E5=AF=BC=E5=85=A5=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E4=BF=AE=E6=94=B9=E4=BC=98=E5=8C=96=EF=BC=8C=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E6=A0=B9=E6=8D=AE=E8=AE=A2=E5=8D=95=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E8=AE=A1=E7=AE=97=E5=89=A9=E4=BD=99=E5=BA=93=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/CanteenDailyMenuImportExcel.java | 24 ++- .../vo/CanteenDailyMenuImportPreviewVO.java | 12 ++ .../CanteenDailyMenuController.java | 22 +++ .../CanteenDailyMenuTemplateMergeHandler.java | 164 +++++++++++++----- .../service/CanteenDailyMenuService.java | 13 ++ .../impl/CanteenDailyMenuImportHelper.java | 107 ++++++++---- .../impl/CanteenDailyMenuServiceImpl.java | 77 ++++++++ 7 files changed, 331 insertions(+), 88 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 d9576fad3..bc2be31dd 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 @@ -7,11 +7,12 @@ import java.math.BigDecimal; /** * 每日菜谱导入/导出 Excel 行(导出含菜谱 ID,导入时可按 ID 更新) - *

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

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

线上/线下库存挂在每日菜谱行上,不再写入菜品列表 {@code canteen_spu.stock}。 - *

菜谱ID / 菜品SPU ID 必须用文本导出,否则 Excel 对超长雪花 ID 会丢精度导致再导入报「不存在」。 - *

套餐块「菜品SPU ID」=套餐本体一个 ID(首行合并);组成菜靠菜名。单品行=该单品 ID。 - *

无「菜品SPU ID」时一律新建菜品(不按菜名复用);有 ID 才按 ID 更新。 + *

菜谱ID / 菜品SPU ID / 子SPU ID 必须用文本导出,否则 Excel 对超长雪花 ID 会丢精度。 + *

业务列(日期/餐段/套餐名/套餐价/库存等)导出时每行写全,不合并;套餐块仅「菜谱ID」「菜品SPU ID」可合并。 + * 「子SPU ID」=每行组成菜 ID,不合并。单品行「菜品SPU ID」=该单品,「子SPU ID」可空。 + *

无「菜品SPU ID」时一律新建菜品/套餐;无「子SPU ID」时组成菜按菜名+配料回退或新建。 */ @Data public class CanteenDailyMenuImportExcel { @@ -41,14 +42,14 @@ public class CanteenDailyMenuImportExcel { private BigDecimal priceYuan; /** - * 当日线上可预约库存(挂每日菜谱)。单品填本行;套餐填首行。 + * 当日线上可预约库存(挂每日菜谱)。单品填本行;套餐组成行建议每行都填相同值。 * 不填:默认 600。 */ @ExcelProperty(value = "线上库存", index = 7) private Integer onlineStock; /** - * 当日线下销售库存(挂每日菜谱,安卓消费机读此池)。单品填本行;套餐填首行。 + * 当日线下销售库存(挂每日菜谱,安卓消费机读此池)。单品填本行;套餐组成行建议每行都填相同值。 * 不填:默认 600。 */ @ExcelProperty(value = "线下库存", index = 8) @@ -65,13 +66,20 @@ public class CanteenDailyMenuImportExcel { private String id; /** - * 关联菜品 SPU ID(文本)。套餐块仅填首行=套餐本体 ID(与菜谱ID一样合并展示);组成菜靠菜名+配料,不写子菜 ID。 + * 关联菜品 SPU ID(文本)。套餐块各行填同一套餐本体 ID(导出可合并单元格)。 * 单品行=该单品 ID。导入:有则按 ID 更新;无则新建。 */ @ExcelProperty(value = "菜品SPU ID", index = 11, converter = com.bsd.pjcloud.canteen.excel.ExcelSnowflakeIdConverter.class) private String spuId; + /** + * 套餐组成菜 SPU ID(文本,每行一个,不合并)。 + * 有则按 ID 更新/复用组成菜;无则按「菜名+配料」回退或新建。单品行可留空。 + */ + @ExcelProperty(value = "子SPU ID", index = 12, converter = com.bsd.pjcloud.canteen.excel.ExcelSnowflakeIdConverter.class) + private String childSpuId; + /** 仅示例说明用,导入逻辑忽略 */ - @ExcelProperty(value = "填写说明", index = 12) + @ExcelProperty(value = "填写说明", index = 13) private String tip; } diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/CanteenDailyMenuImportPreviewVO.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/CanteenDailyMenuImportPreviewVO.java index fa262867f..1d4b27102 100644 --- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/CanteenDailyMenuImportPreviewVO.java +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/CanteenDailyMenuImportPreviewVO.java @@ -74,6 +74,15 @@ public class CanteenDailyMenuImportPreviewVO { @Schema(description = "菜品SPU ID") private String spuId; + @Schema(description = "子SPU ID(套餐组成菜)") + private String childSpuId; + + /** + * 排菜分组键:同一套餐多行组成菜共用,前端按此统计「一道菜」而不是 Excel 行数。 + */ + @Schema(description = "排菜分组键") + private String groupKey; + /** INSERT / UPDATE / SKIP_LOCKED */ @Schema(description = "动作") private String action; @@ -99,6 +108,9 @@ public class CanteenDailyMenuImportPreviewVO { private int insertCount; private int updateCount; private int skipLockedCount; + /** 排菜道数(套餐整组算 1) */ private int rowCount; + /** Excel 明细行数(套餐组成菜多行会大于 rowCount) */ + private int excelRowCount; } } diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenDailyMenuController.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenDailyMenuController.java index ff7a72137..db6e62d46 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenDailyMenuController.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenDailyMenuController.java @@ -42,6 +42,7 @@ import com.bsd.pjcloud.canteen.service.CanteenDailyMenuLockLogService; import com.bsd.pjcloud.canteen.service.impl.CanteenDailyMenuImportHelper; import org.springframework.web.multipart.MultipartFile; +import java.time.LocalDate; import java.util.List; /** @@ -293,6 +294,27 @@ public class CanteenDailyMenuController { return dailyMenuLockLogService.setDayLock(dto); } + /** + * 按硬件订单净销量回填当日线上/线下 sold(配额不动)。 + * 用于历史 sold 未累计、或怀疑销量漂移时的运维纠偏。 + */ + @Operation(summary = "按订单重算当日分池销量", description = "覆盖 online_sold/offline_sold,不改配额") + @SysLog("按订单重算每日菜谱销量") + @PutMapping("/syncChannelSold") + @PreAuthorize("@pms.hasPermission('canteen_canteenDailyMenu_edit')") + public R syncChannelSold(@RequestParam("merchantId") Long merchantId, + @RequestParam("mealDate") String mealDate) { + if (merchantId == null || StrUtil.isBlank(mealDate)) { + return R.failed("商户和用餐日不能为空"); + } + try { + return canteenDailyMenuService.syncChannelSoldFromOrders(merchantId, LocalDate.parse(mealDate.trim())); + } + catch (Exception ex) { + return R.failed("用餐日格式应为 yyyy-MM-dd"); + } + } + @Operation(summary = "分页查询每日菜谱导入日志") @GetMapping("/import/log/page") @PreAuthorize("@pms.hasPermission('canteen_canteenDailyMenu_view')") 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 5819fcbe3..b14be31f4 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 @@ -12,8 +12,11 @@ import org.apache.poi.ss.usermodel.VerticalAlignment; import org.apache.poi.ss.util.CellRangeAddress; /** - * 每日菜谱导入模板 / 导出:按数据动态合并日期、餐段、套餐名、套餐价、菜谱ID、套餐SPU、说明。 - *

列:0日期 1餐段 2套餐名 3菜品名 4分类 5单价 6套餐价 7线上库存 8线下库存 9配料 10菜谱ID 11SPU ID 12填写说明 + * 每日菜谱导入模板 / 导出写后处理。 + *

列:0日期 1餐段 2套餐名 3菜品名 4分类 5单价 6套餐价 7线上库存 8线下库存 9配料 + * 10菜谱ID 11菜品SPU ID 12子SPU ID 13填写说明 + *

为减少填写混乱:日期/餐段/套餐名/套餐价/库存/说明等每行写全、不合并; + * 仅套餐块内合并「菜谱ID」「菜品SPU ID」(同一排菜共用)。子SPU ID 每行独立、不合并。 */ public class CanteenDailyMenuTemplateMergeHandler implements WorkbookWriteHandler { @@ -25,7 +28,7 @@ public class CanteenDailyMenuTemplateMergeHandler implements WorkbookWriteHandle private static final int COL_OFFLINE_STOCK = 8; private static final int COL_ID = 10; private static final int COL_SPU = 11; - private static final int COL_TIP = 12; + private static final int COL_CHILD_SPU = 12; @Override public void afterWorkbookDispose(WriteWorkbookHolder writeWorkbookHolder) { @@ -44,12 +47,12 @@ public class CanteenDailyMenuTemplateMergeHandler implements WorkbookWriteHandle center.setAlignment(HorizontalAlignment.CENTER); center.setVerticalAlignment(VerticalAlignment.CENTER); - mergeByRuns(sheet, center); + fillEveryRowAndMergeIdsOnly(sheet, center); forceIdColumnsAsText(sheet, writeWorkbookHolder); setColumnWidths(sheet); } - /** 菜谱ID / SPU ID 强制文本格式,降低 Excel 自动改成数字丢精度的概率 */ + /** 菜谱ID / 菜品SPU / 子SPU 强制文本格式,降低 Excel 自动改成数字丢精度的概率 */ private static void forceIdColumnsAsText(Sheet sheet, WriteWorkbookHolder holder) { CellStyle textStyle = holder.getWorkbook().createCellStyle(); textStyle.setDataFormat(holder.getWorkbook().createDataFormat().getFormat("@")); @@ -57,7 +60,7 @@ public class CanteenDailyMenuTemplateMergeHandler implements WorkbookWriteHandle textStyle.setVerticalAlignment(VerticalAlignment.CENTER); int lastRow = sheet.getLastRowNum(); for (int r = 1; r <= lastRow; r++) { - for (int col : new int[] { COL_ID, COL_SPU }) { + for (int col : new int[] { COL_ID, COL_SPU, COL_CHILD_SPU }) { Cell cell = getCell(sheet, r, col); if (cell == null) { continue; @@ -73,20 +76,28 @@ public class CanteenDailyMenuTemplateMergeHandler implements WorkbookWriteHandle } /** - * 向下填充后,按连续相同值合并:餐段 / 套餐块(日期列不合并,每行写全)。 + * 将套餐续行空白补全为与首行一致的业务值(便于直接改表),并仅合并菜谱ID / 菜品SPU ID。 */ - private static void mergeByRuns(Sheet sheet, CellStyle center) { + private static void fillEveryRowAndMergeIdsOnly(Sheet sheet, CellStyle center) { 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; String lastMenuId = null; + String lastSpuId = null; + String lastComboPrice = null; + String lastOnline = null; + String lastOffline = null; boolean inCombo = false; for (int r = 1; r <= lastRow; r++) { @@ -94,6 +105,10 @@ public class CanteenDailyMenuTemplateMergeHandler implements WorkbookWriteHandle String meal = cellText(sheet, r, COL_MEAL); String combo = cellText(sheet, r, COL_COMBO); String menuId = cellText(sheet, r, COL_ID); + String spuId = cellText(sheet, r, COL_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); if (isBlank(date)) { date = lastDate; @@ -113,6 +128,10 @@ public class CanteenDailyMenuTemplateMergeHandler implements WorkbookWriteHandle inCombo = false; lastCombo = null; lastMenuId = null; + lastSpuId = null; + lastComboPrice = null; + lastOnline = null; + lastOffline = null; combo = "无"; } else { @@ -121,6 +140,18 @@ public class CanteenDailyMenuTemplateMergeHandler implements WorkbookWriteHandle if (!isBlank(menuId)) { lastMenuId = menuId; } + if (!isBlank(spuId)) { + lastSpuId = spuId; + } + if (!isBlank(comboPrice)) { + lastComboPrice = comboPrice; + } + if (!isBlank(online)) { + lastOnline = online; + } + if (!isBlank(offline)) { + lastOffline = offline; + } } } else if (inCombo) { @@ -129,40 +160,86 @@ public class CanteenDailyMenuTemplateMergeHandler implements WorkbookWriteHandle menuId = lastMenuId; } else { - // 新菜谱 ID 出现,视为新套餐组起点 lastMenuId = menuId; } + if (isBlank(spuId)) { + spuId = lastSpuId; + } + else { + lastSpuId = spuId; + } + if (isBlank(comboPrice)) { + comboPrice = lastComboPrice; + } + else { + lastComboPrice = comboPrice; + } + if (isBlank(online)) { + online = lastOnline; + } + else { + lastOnline = online; + } + if (isBlank(offline)) { + offline = lastOffline; + } + else { + lastOffline = offline; + } } else { combo = "无"; lastMenuId = null; + lastSpuId = null; + lastComboPrice = null; + lastOnline = null; + lastOffline = null; + } + + if (inCombo && !isSingleCombo(combo)) { + if (isBlank(menuId)) { + menuId = lastMenuId; + } + if (isBlank(spuId)) { + spuId = lastSpuId; + } + if (isBlank(comboPrice)) { + comboPrice = lastComboPrice; + } + if (isBlank(online)) { + online = lastOnline; + } + if (isBlank(offline)) { + offline = lastOffline; + } } 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++) { - if (isBlank(dates[r])) { - continue; + 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); } - Cell cell = getCell(sheet, r, COL_DATE); - if (cell == null) { - Row row = sheet.getRow(r); - if (row == null) { - row = sheet.createRow(r); - } - cell = row.createCell(COL_DATE); - } - cell.setCellValue(dates[r]); - cell.setCellStyle(center); } - mergeEqualRuns(sheet, center, lastRow, meals, COL_MEAL, dates, null); + // 仅套餐块合并菜谱ID、菜品SPU ID int runStart = 1; for (int r = 2; r <= lastRow + 1; r++) { boolean same = r <= lastRow @@ -175,35 +252,21 @@ public class CanteenDailyMenuTemplateMergeHandler implements WorkbookWriteHandle if (!same) { if (comboGroup[runStart] && runStart <= lastRow) { int endRow = Math.min(r - 1, lastRow); - mergeRange(sheet, runStart, endRow, COL_COMBO, center); - mergeRange(sheet, runStart, endRow, COL_COMBO_PRICE, center); - mergeRange(sheet, runStart, endRow, COL_ONLINE_STOCK, center); - mergeRange(sheet, runStart, endRow, COL_OFFLINE_STOCK, center); - // 配料不合并:组成菜各自配料(同名不同配料靠此区分) mergeRange(sheet, runStart, endRow, COL_ID, center); - // 套餐 SPU ID 与菜谱 ID 一样合并:整块一个套餐本体 ID mergeRange(sheet, runStart, endRow, COL_SPU, center); - mergeRange(sheet, runStart, endRow, COL_TIP, center); } runStart = r; } } } - private static void mergeEqualRuns(Sheet sheet, CellStyle center, int lastRow, String[] values, int col, - String[] key1, String[] key2) { - int runStart = 1; - for (int r = 2; r <= lastRow + 1; r++) { - boolean same = r <= lastRow - && !isBlank(values[runStart]) - && eq(values[r], values[runStart]) - && (key1 == null || eq(key1[r], key1[runStart])) - && (key2 == null || eq(key2[r], key2[runStart])); - if (!same) { - mergeRange(sheet, runStart, Math.min(r - 1, lastRow), col, center); - runStart = r; - } + private static void writeText(Sheet sheet, int rowIdx, int col, String value, CellStyle center) { + if (isBlank(value)) { + return; } + Cell cell = getOrCreateCell(sheet, rowIdx, col); + cell.setCellValue(value); + cell.setCellStyle(center); } private static void mergeRange(Sheet sheet, int firstRow, int lastRow, int col, CellStyle center) { @@ -226,6 +289,18 @@ public class CanteenDailyMenuTemplateMergeHandler implements WorkbookWriteHandle } } + private static Cell getOrCreateCell(Sheet sheet, int rowIdx, int col) { + Row row = sheet.getRow(rowIdx); + if (row == null) { + row = sheet.createRow(rowIdx); + } + Cell cell = row.getCell(col); + if (cell == null) { + cell = row.createCell(col); + } + return cell; + } + private static Cell getCell(Sheet sheet, int rowIdx, int col) { Row row = sheet.getRow(rowIdx); return row == null ? null : row.getCell(col); @@ -327,6 +402,7 @@ public class CanteenDailyMenuTemplateMergeHandler implements WorkbookWriteHandle sheet.setColumnWidth(9, 22 * 256); sheet.setColumnWidth(10, 20 * 256); sheet.setColumnWidth(11, 20 * 256); - sheet.setColumnWidth(12, 48 * 256); + sheet.setColumnWidth(12, 20 * 256); + sheet.setColumnWidth(13, 48 * 256); } } diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenDailyMenuService.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenDailyMenuService.java index cbee5ecdd..d3cacff07 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenDailyMenuService.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/CanteenDailyMenuService.java @@ -13,6 +13,7 @@ import com.bsd.pjcloud.canteen.vo.app.DailyMenuAppVO; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.security.service.PjcloudUser; +import java.time.LocalDate; import java.util.ArrayList; import java.util.List; @@ -98,4 +99,16 @@ public interface CanteenDailyMenuService extends IService selectAdminList(String time, Long merchantId, Long categoryId); + + /** + * 按硬件订单净销量回填当日线上/线下 sold(不改配额)。 + *

口径与 hardware {@code listDailyMenuNetSold} 一致:CANTEEN_DAILY_MENU、 + * 已支付/已完成,扣已完成售后;orderType=2→onlineSold,1→offlineSold。 + * 当日无对应订单的菜谱行 sold 置 0。 + * + * @param merchantId 食堂商户 + * @param mealDate 用餐日 + * @return 实际改写行数 + */ + R syncChannelSoldFromOrders(Long merchantId, LocalDate mealDate); } 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 db5da35fd..c9544d8ae 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,18 +96,19 @@ public class CanteenDailyMenuImportHelper { public List templateRows() { List list = new ArrayList<>(); + // 业务列每行写全(不靠合并续行);仅菜谱ID/菜品SPU ID 在套餐块内可能合并展示 list.add(row("2026-08-17", "午餐", "A套餐", "糖醋排骨", "荤菜", "青莲仔排", "12", "21", "80", "100", - "无菜品SPU ID则新建;有则按ID更新。套餐价/库存/套餐SPU填首行;配料按组成菜分行填(同名不同配料会建两道菜)")); - list.add(row(null, null, null, "蒜苔炒腊肉", "荤菜", null, "8", null, null, null, null)); - list.add(row(null, null, null, "清炒时蔬", "素菜", null, "3", null, null, null, null)); - list.add(row(null, null, "B套餐", "红烧肉", "荤菜", "五花肉、土豆", "10", "18", "60", "80", - "换套餐名即另一套;无菜谱ID时新建套餐;有菜谱ID/套餐SPU则更新该套餐")); - list.add(row(null, null, null, "土豆丝", "素菜", null, "4", null, null, null, null)); - list.add(row(null, null, "无", "千张大骨汤", "汤类", null, "0", null, "50", "50", - "单品:套餐名填「无」「单品」「单点」或「单品-xxx/单点-xxx」;价格填单价列;库存填本行线上/线下")); - list.add(row(null, "早餐", "单品-1", "榨菜肉丝面", "面食", "榨菜,肉丝", "6.4", null, "30", "40", + "套餐多行:日期/餐段/套餐名/套餐价/库存每行都填;菜品SPU ID=套餐本体;子SPU 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则更新")); + 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可空")); + list.add(row("2026-08-17", "早餐", "单品-1", "榨菜肉丝面", "面食", "榨菜,肉丝", "6.4", null, "30", "40", "单品-xxx / 单点-xxx 一律按单品,套餐价可留空;不填库存则默认600")); - list.add(row(null, null, "单点-2", "豆浆", "饮品", null, "2", null, "100", "100", null)); + list.add(row("2026-08-17", "早餐", "单点-2", "豆浆", "饮品", null, "2", null, "100", "100", null)); list.add(row("2026-08-18", "午餐,晚餐", "无", "番茄蛋汤", "汤类", null, "2", null, "80", "80", "多餐拆行:无菜品SPU ID时各餐段各自新建菜品;每日线上/线下库存各自独立")); return list; @@ -187,6 +188,7 @@ public class CanteenDailyMenuImportHelper { Set exportedChildIds = new HashSet<>(); int written = 0; String comboSpuId = menu.getSpuId() == null ? null : String.valueOf(menu.getSpuId()); + String menuIdStr = menu.getId() == null ? null : String.valueOf(menu.getId()); for (OnlineSpuComboItemVO item : items) { if (item == null) { continue; @@ -195,22 +197,24 @@ public class CanteenDailyMenuImportHelper { if (childId != null && !exportedChildIds.add(childId)) { continue; } + // 套餐组成行:业务列每行写全,避免依赖合并单元格;子SPU 仍各自一行 CanteenDailyMenuImportExcel row = new CanteenDailyMenuImportExcel(); - if (written == 0) { - row.setId(menu.getId() == null ? null : String.valueOf(menu.getId())); - row.setSpuId(comboSpuId); - row.setBizDate(dateStr); - row.setMealName(meal); - row.setComboName(comboLabel); - row.setPriceYuan(comboPrice); - row.setOnlineStock(menu.getOnlineStock()); - row.setOfflineStock(menu.getOfflineStock()); - } - // 套餐只导出一个套餐 SPU;配料列按组成菜各自导出(同名不同配料靠此区分) + row.setId(menuIdStr); + row.setSpuId(comboSpuId); + row.setBizDate(dateStr); + row.setMealName(meal); + row.setComboName(comboLabel); + row.setPriceYuan(comboPrice); + row.setOnlineStock(menu.getOnlineStock()); + row.setOfflineStock(menu.getOfflineStock()); + row.setChildSpuId(childId == null ? null : String.valueOf(childId)); row.setIngredients(toExcelIngredients(item.getIngredients())); row.setCategoryName(menu.getCategoryName()); row.setDishName(StrUtil.blankToDefault(item.getName(), "")); row.setDishPriceYuan(fenToYuan(item.getPrice())); + if (written == 0) { + row.setTip("套餐块:同套餐名多行=一道排菜;仅菜谱ID/菜品SPU可合并展示"); + } rows.add(row); written++; } @@ -634,6 +638,7 @@ public class CanteenDailyMenuImportHelper { for (Map.Entry> e : groups.entrySet()) { List items = e.getValue(); + String groupKey = e.getKey(); CanteenDailyMenuImportExcel firstRow = items.get(0); LocalDate groupDate = parseDate(firstRow.getBizDate()); Integer groupHour = parseMeal(firstRow.getMealName()); @@ -663,7 +668,7 @@ public class CanteenDailyMenuImportHelper { actionLabel = "已锁定跳过"; tip = dateStr + " 已锁定,确认导入时不会写入"; selected = false; - totalSkip += items.size(); + totalSkip++; } else if (isUpdate) { action = "UPDATE"; @@ -686,9 +691,11 @@ public class CanteenDailyMenuImportHelper { s.setLocked(lockedDates.contains(d)); return s; }); - day.setRowCount(day.getRowCount() + items.size()); + // 按排菜组计数(套餐多行组成菜只算 1 道),便于核对当日新增/更新数量 + day.setRowCount(day.getRowCount() + 1); + day.setExcelRowCount(day.getExcelRowCount() + items.size()); if (dayLocked) { - day.setSkipLockedCount(day.getSkipLockedCount() + items.size()); + day.setSkipLockedCount(day.getSkipLockedCount() + 1); } else if (isUpdate) { day.setUpdateCount(day.getUpdateCount() + 1); @@ -714,6 +721,8 @@ public class CanteenDailyMenuImportHelper { previewRow.setIngredients(StrUtil.trim(item.getIngredients())); previewRow.setId(StrUtil.trim(item.getId())); previewRow.setSpuId(StrUtil.trim(item.getSpuId())); + previewRow.setChildSpuId(StrUtil.trim(item.getChildSpuId())); + previewRow.setGroupKey(groupKey); previewRow.setAction(action); previewRow.setActionLabel(actionLabel); previewRow.setLocked(dayLocked); @@ -897,8 +906,8 @@ public class CanteenDailyMenuImportHelper { if (StrUtil.isBlank(cleanDishName(item.getDishName())) || !seenDishKeys.add(dishKey)) { continue; } - Long childSpuId = resolveComboChildExcelSpuId(item.getSpuId(), comboExcelSpuId, uniformSpu, - existChildByKey.get(dishKey), cache); + Long childSpuId = resolveComboChildExcelSpuId(item.getChildSpuId(), item.getSpuId(), comboExcelSpuId, + uniformSpu, existChildByKey.get(dishKey), cache); FindOrCreate r = findOrCreateDish(merchantId, deptId, item.getCategoryName(), categoryId, categoryMap, item.getDishName(), item.getIngredients(), resolveDishPriceFen(item), null, childSpuId, @@ -1144,6 +1153,9 @@ public class CanteenDailyMenuImportHelper { if (isNumericCorruptId(row.getSpuId())) { bad.add("第" + line + "行「菜品SPU ID」"); } + if (isNumericCorruptId(row.getChildSpuId())) { + bad.add("第" + line + "行「子SPU ID」"); + } if (bad.size() >= 8) { break; } @@ -1154,7 +1166,7 @@ public class CanteenDailyMenuImportHelper { throw new IllegalArgumentException( "检测到雪花 ID 被 Excel 当成数字保存(会丢末位精度,常见感叹号警告、位数变短)。\n" + "这会导致套餐/单品串组,并报「不是套餐,套餐行请填写套餐 SPU ID」。\n" - + "请重新从系统导出后再导入,不要把「菜谱ID」「菜品SPU ID」列改成数字格式。\n" + + "请重新从系统导出后再导入,不要把「菜谱ID」「菜品SPU ID」「子SPU ID」列改成数字格式。\n" + "问题位置:" + String.join("、", bad)); } @@ -1556,8 +1568,8 @@ public class CanteenDailyMenuImportHelper { if (!seenDishKeys.add(dishKey)) { continue; } - Long childSpuId = resolveComboChildExcelSpuId(item.getSpuId(), comboExcelSpuId, uniformSpu, - existChildByKey.get(dishKey), cache); + Long childSpuId = resolveComboChildExcelSpuId(item.getChildSpuId(), item.getSpuId(), comboExcelSpuId, + uniformSpu, existChildByKey.get(dishKey), cache); FindOrCreate r = findOrCreateDish(merchantId, deptId, item.getCategoryName(), defaultCategoryId, categoryMap, dish, item.getIngredients(), resolveDishPriceFen(item), null, childSpuId, cache); @@ -1636,11 +1648,25 @@ public class CanteenDailyMenuImportHelper { } /** - * 组成菜 SPU:套餐块统一填套餐本体 ID 时不按该 ID 建子菜; + * 组成菜 SPU:优先「子SPU ID」列;兼容旧表把组成菜 ID 写在「菜品SPU ID」且非整块统一套餐 ID 的情况; * 可回退到已有套餐组成中「菜名+配料」完全一致的子菜 ID。 */ - private Long resolveComboChildExcelSpuId(String rowSpuId, Long comboSpuId, boolean uniformSpu, + private Long resolveComboChildExcelSpuId(String childSpuIdRaw, String rowSpuId, Long comboSpuId, boolean uniformSpu, Long existChildByKey, ImportCache cache) { + Long fromChildCol = parseExcelLong(childSpuIdRaw); + if (fromChildCol != null) { + if (Objects.equals(fromChildCol, comboSpuId)) { + throw new IllegalArgumentException("「子SPU ID」不能填套餐本体 ID,请填组成菜的单品 SPU ID"); + } + CanteenSpuEntity exist = resolveSpuById(fromChildCol, cache); + if (exist != null) { + int type = exist.getSpuType() == null ? 1 : exist.getSpuType(); + if (type == 2) { + throw new IllegalArgumentException("「子SPU ID」" + fromChildCol + " 是套餐,组成菜必须是单品"); + } + } + return fromChildCol; + } if (uniformSpu) { return existChildByKey; } @@ -1717,7 +1743,7 @@ public class CanteenDailyMenuImportHelper { /** * 解析/创建单品。 - *

有「菜品SPU ID」→ 按 ID 复用并按需更新;无 ID → 一律新建,不再按菜名复用。 + *

有「菜品SPU ID」/「子SPU ID」→ 按 ID 复用并按需更新名称、配料、价格、分类;无 ID → 一律新建,不再按菜名复用。 *

{@code stock} 仅在显式传入时更新 {@code canteen_spu.stock};每日菜谱导入传 {@code null}, * 当日可售 = 配额(online_stock/offline_stock) − 销量;导入只写配额,不改销量。 */ @@ -1737,7 +1763,7 @@ public class CanteenDailyMenuImportHelper { if (type == 2) { throw new IllegalArgumentException("菜品SPU ID " + excelSpuId + " 是套餐,单品行请填写单品 SPU ID"); } - applyDishUpdates(exist, categoryName, defaultCategoryId, categoryMap, merchantId, deptId, ingredients, + applyDishUpdates(exist, clean, categoryName, defaultCategoryId, categoryMap, merchantId, deptId, ingredients, priceFen, stock); return new FindOrCreate(exist, false); } @@ -1758,14 +1784,19 @@ public class CanteenDailyMenuImportHelper { return new FindOrCreate(spu, true); } - private void applyDishUpdates(CanteenSpuEntity exist, String categoryName, Long defaultCategoryId, + private void applyDishUpdates(CanteenSpuEntity exist, String cleanName, String categoryName, Long defaultCategoryId, Map categoryMap, Long merchantId, Long deptId, String ingredients, Integer priceFen, Integer stock) { String importIngredients = normalizeImportIngredients(ingredients); + boolean changeName = false; boolean changeDesc = false; boolean changePrice = false; boolean changeStock = false; boolean changeCategory = false; + if (StrUtil.isNotBlank(cleanName) && !cleanName.equals(StrUtil.trim(exist.getName()))) { + exist.setName(cleanName); + changeName = true; + } if (StrUtil.isNotBlank(importIngredients)) { String existPlain = toPlainIngredients(exist.getDescription()); if (!importIngredients.equals(existPlain)) { @@ -1789,8 +1820,11 @@ public class CanteenDailyMenuImportHelper { changeCategory = true; } } - if (changeDesc || changePrice || changeStock || changeCategory) { + if (changeName || changeDesc || changePrice || changeStock || changeCategory) { var uw = Wrappers.lambdaUpdate().eq(CanteenSpuEntity::getId, exist.getId()); + if (changeName) { + uw.set(CanteenSpuEntity::getName, exist.getName()); + } if (changeDesc) { uw.set(CanteenSpuEntity::getDescription, exist.getDescription()); } @@ -2093,7 +2127,7 @@ public class CanteenDailyMenuImportHelper { } /** - * 合并单元格模板读入后,非首行日期/餐段/套餐名/分类为空,需向下填充。 + * 合并单元格旧模板读入后,非首行日期/餐段/套餐名/分类为空,需向下填充(新导出已每行写全,仍兼容旧表)。 * 套餐名填「无」「单品」「单点」或「单品-xxx / 单点-xxx」表示单品,不参与套餐继承。 *

换日/换餐段时必须清空菜谱ID继承,否则「同名套餐」跨天会被合成一组,组成菜看起来像重复。 */ @@ -2372,6 +2406,7 @@ public class CanteenDailyMenuImportHelper { copy.setIngredients(src.getIngredients()); copy.setId(src.getId()); copy.setSpuId(src.getSpuId()); + copy.setChildSpuId(src.getChildSpuId()); copy.setTip(src.getTip()); return copy; } 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 aad551ad9..7150da10b 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 @@ -7,6 +7,7 @@ import com.alibaba.nacos.common.utils.CollectionUtils; import com.baomidou.lock.LockInfo; import com.baomidou.lock.LockTemplate; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.bsd.pjcloud.canteen.dto.CanteenDailyMenuCVDTO; @@ -28,6 +29,10 @@ import com.bsd.pjcloud.canteen.vo.app.DailyMenuAppVO; import com.bsd.pjcloud.common.core.util.R; import com.bsd.pjcloud.common.data.datascope.DataScope; import com.bsd.pjcloud.common.security.service.PjcloudUser; +import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum; +import com.bsd.pjcloud.hard.api.dto.order.remote.DailyMenuNetSoldQueryDTO; +import com.bsd.pjcloud.hard.api.feign.RemoteHardOrderService; +import com.bsd.pjcloud.hard.api.vo.order.DailyMenuNetSoldVO; import com.bsd.pjcloud.mqtt.api.feign.RemoteMqttEventService; import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; import com.bsd.pjcloud.wallet.api.vo.MchManageVo; @@ -71,6 +76,8 @@ public class CanteenDailyMenuServiceImpl extends ServiceImpl syncChannelSoldFromOrders(Long merchantId, LocalDate mealDate) { + if (merchantId == null || mealDate == null) { + return R.failed("商户和用餐日不能为空"); + } + List menus = list(Wrappers.lambdaQuery() + .eq(CanteenDailyMenuEntity::getMerchantId, merchantId) + .eq(CanteenDailyMenuEntity::getStartTime, mealDate)); + if (CollectionUtils.isEmpty(menus)) { + return R.ok(0, "当日无菜谱记录"); + } + + DailyMenuNetSoldQueryDTO query = new DailyMenuNetSoldQueryDTO(); + query.setMerchantId(merchantId); + query.setMealDate(mealDate); + R> soldR = remoteHardOrderService.listDailyMenuNetSold(query); + if (soldR == null || !soldR.isOk()) { + String tip = soldR == null ? "订单服务无响应" : soldR.getMsg(); + return R.failed(StrUtil.blankToDefault(tip, "查询订单净销量失败")); + } + List netRows = soldR.getData() == null ? List.of() : soldR.getData(); + + // key = hourType|spuId → [onlineSold, offlineSold] + Map soldByHourProduct = new HashMap<>(); + for (DailyMenuNetSoldVO row : netRows) { + if (row == null || row.getProductId() == null || StrUtil.isBlank(row.getHourType())) { + continue; + } + String key = row.getHourType().trim() + "|" + row.getProductId(); + int[] pair = soldByHourProduct.computeIfAbsent(key, ignored -> new int[] { 0, 0 }); + int qty = row.getNetSold() == null ? 0 : Math.max(0, row.getNetSold()); + if (HardOrderTypeEnum.RESERVE.getType().equals(row.getOrderType())) { + pair[0] += qty; + } + else if (HardOrderTypeEnum.BELOW.getType().equals(row.getOrderType())) { + pair[1] += qty; + } + } + + List toUpdate = new ArrayList<>(); + for (CanteenDailyMenuEntity menu : menus) { + if (menu.getHourType() == null || menu.getSpuId() == null) { + continue; + } + String key = menu.getHourType() + "|" + menu.getSpuId(); + int[] pair = soldByHourProduct.getOrDefault(key, new int[] { 0, 0 }); + int onlineSold = pair[0]; + int offlineSold = pair[1]; + int curOnline = CanteenDailyMenuStockSupport.soldOrDefault(menu.getOnlineSold()); + int curOffline = CanteenDailyMenuStockSupport.soldOrDefault(menu.getOfflineSold()); + if (curOnline == onlineSold && curOffline == offlineSold) { + continue; + } + menu.setOnlineSold(onlineSold); + menu.setOfflineSold(offlineSold); + toUpdate.add(menu); + } + if (CollUtil.isNotEmpty(toUpdate)) { + updateBatchById(toUpdate); + } + log.info("每日菜谱按订单重算销量 merchantId={} mealDate={} menuRows={} updated={}", + merchantId, mealDate, menus.size(), toUpdate.size()); + return R.ok(toUpdate.size(), "已按订单重算 " + toUpdate.size() + " 条销量(共 " + menus.size() + " 条菜谱)"); + } + private static String weekdayLabel(DayOfWeek dayOfWeek) { return switch (dayOfWeek) { case MONDAY -> "周一";