每日导入导出修改优化,增加根据订单自动计算剩余库存

This commit is contained in:
zjf committed 2026-09-14 18:35:41 +08:00
1 parent 364d378708
commit 2edbdcce79
7 files changed
+331 -88

No files matched your search

@@ -7,11 +7,12 @@ import java.math.BigDecimal;
/**
* 每日菜谱导入/导出 Excel 行(导出含菜谱 ID,导入时可按 ID 更新)
* <p>列顺序:日期、餐段、套餐名、菜品名、分类、单价、套餐价、线上库存、线下库存、配料、菜谱ID、菜品SPU ID、填写说明
* <p>列顺序:日期、餐段、套餐名、菜品名、分类、单价、套餐价、线上库存、线下库存、配料、菜谱ID、菜品SPU ID、子SPU ID、填写说明
* <p>线上/线下库存挂在每日菜谱行上,不再写入菜品列表 {@code canteen_spu.stock}。
* <p>菜谱ID / 菜品SPU ID 必须用文本导出,否则 Excel 对超长雪花 ID 会丢精度导致再导入报「不存在」。
* <p>套餐块「菜品SPU ID」=套餐本体一个 ID(首行合并);组成菜靠菜名。单品行=该单品 ID。
* <p>无「菜品SPU ID」时一律新建菜品(不按菜名复用);有 ID 才按 ID 更新。
* <p>菜谱ID / 菜品SPU ID / 子SPU ID 必须用文本导出,否则 Excel 对超长雪花 ID 会丢精度。
* <p>业务列(日期/餐段/套餐名/套餐价/库存等)导出时每行写全,不合并;套餐块仅「菜谱ID」「菜品SPU ID」可合并。
* 「子SPU ID」=每行组成菜 ID,不合并。单品行「菜品SPU ID」=该单品,「子SPU ID」可空。
* <p>无「菜品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;
}
@@ -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;
}
}
@@ -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<Integer> 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')")
@@ -12,8 +12,11 @@ import org.apache.poi.ss.usermodel.VerticalAlignment;
import org.apache.poi.ss.util.CellRangeAddress;
/**
* 每日菜谱导入模板 / 导出:按数据动态合并日期、餐段、套餐名、套餐价、菜谱ID、套餐SPU、说明。
* <p>列:0日期 1餐段 2套餐名 3菜品名 4分类 5单价 6套餐价 7线上库存 8线下库存 9配料 10菜谱ID 11SPU ID 12填写说明
* 每日菜谱导入模板 / 导出写后处理。
* <p>列:0日期 1餐段 2套餐名 3菜品名 4分类 5单价 6套餐价 7线上库存 8线下库存 9配料
* 10菜谱ID 11菜品SPU ID 12子SPU ID 13填写说明
* <p>为减少填写混乱:日期/餐段/套餐名/套餐价/库存/说明等<strong>每行写全、不合并</strong>;
* 仅套餐块内合并「菜谱ID」「菜品SPU ID」(同一排菜共用)。子SPU ID 每行独立、不合并。
*/
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);
}
}
@@ -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<CanteenDailyMenuEntity
* PC 管理端每日菜谱列表(含库存与订单统计)
*/
List<DailyMenuAdminVO> selectAdminList(String time, Long merchantId, Long categoryId);
/**
* 按硬件订单净销量回填当日线上/线下 sold(不改配额)。
* <p>口径与 hardware {@code listDailyMenuNetSold} 一致:CANTEEN_DAILY_MENU、
* 已支付/已完成,扣已完成售后;orderType=2→onlineSold,1→offlineSold。
* 当日无对应订单的菜谱行 sold 置 0。
*
* @param merchantId 食堂商户
* @param mealDate 用餐日
* @return 实际改写行数
*/
R<Integer> syncChannelSoldFromOrders(Long merchantId, LocalDate mealDate);
}
@@ -96,18 +96,19 @@ public class CanteenDailyMenuImportHelper {
public List<CanteenDailyMenuImportExcel> templateRows() {
List<CanteenDailyMenuImportExcel> 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<Long> 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<String, List<CanteenDailyMenuImportExcel>> e : groups.entrySet()) {
List<CanteenDailyMenuImportExcel> 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 {
/**
* 解析/创建单品。
* <p>有「菜品SPU ID」→ 按 ID 复用并按需更新;无 ID → 一律新建,不再按菜名复用。
* <p>有「菜品SPU ID」/「子SPU ID」→ 按 ID 复用并按需更新名称、配料、价格、分类;无 ID → 一律新建,不再按菜名复用。
* <p>{@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<String, Long> 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.<CanteenSpuEntity>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」表示单品,不参与套餐继承。
* <p>换日/换餐段时必须清空菜谱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;
}
@@ -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<CanteenDailyMenuMap
private final CanteenDailyMenuStockSupport dailyMenuStockSupport;
private final RemoteHardOrderService remoteHardOrderService;
/**
* 按逻辑删除后的当前数据批量校验日期、餐段和 SPU 组合,避免 App 页面缓存仍包含已下架菜品。
* 基础 SPU 的全局上下架、归属和库存由 Hardware 下单服务继续按现有逻辑独立复核。
@@ -483,6 +490,76 @@ public class CanteenDailyMenuServiceImpl extends ServiceImpl<CanteenDailyMenuMap
return rows;
}
/**
* 按订单净销量覆盖当日 {@code online_sold}/{@code offline_sold},配额字段不动。
* 无订单命中的菜谱行销量置 0,用于历史 sold 未累计时的运维回填。
*/
@Override
@Transactional(rollbackFor = Exception.class)
public R<Integer> syncChannelSoldFromOrders(Long merchantId, LocalDate mealDate) {
if (merchantId == null || mealDate == null) {
return R.failed("商户和用餐日不能为空");
}
List<CanteenDailyMenuEntity> menus = list(Wrappers.<CanteenDailyMenuEntity>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<List<DailyMenuNetSoldVO>> soldR = remoteHardOrderService.listDailyMenuNetSold(query);
if (soldR == null || !soldR.isOk()) {
String tip = soldR == null ? "订单服务无响应" : soldR.getMsg();
return R.failed(StrUtil.blankToDefault(tip, "查询订单净销量失败"));
}
List<DailyMenuNetSoldVO> netRows = soldR.getData() == null ? List.of() : soldR.getData();
// key = hourType|spuId → [onlineSold, offlineSold]
Map<String, int[]> 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<CanteenDailyMenuEntity> 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 -> "周一";