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 7c8f753fc..01f10dfb0 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,15 +7,15 @@ import java.math.BigDecimal;
/**
* 每日菜谱导入/导出 Excel 行(导出含菜谱 ID,导入时可按 ID 更新)
- *
列顺序:日期、餐段、套餐名、菜品名、分类、单价、套餐价、库存、配料、菜谱ID、菜品SPU ID、填写说明
- *
配料:单品写单品 SPU;套餐仅在首行填写,写入套餐 SPU
+ *
列顺序:日期、餐段、套餐名、菜品名、分类、单价、套餐价、线上库存、线下库存、配料、菜谱ID、菜品SPU ID、填写说明
+ *
线上/线下库存挂在每日菜谱行上,不再写入菜品列表 {@code canteen_spu.stock}。
*
菜谱ID / 菜品SPU ID 必须用文本导出,否则 Excel 对超长雪花 ID 会丢精度导致再导入报「不存在」。
*
无「菜品SPU ID」时一律新建菜品(不按菜名复用);有 ID 才按 ID 更新。
*/
@Data
public class CanteenDailyMenuImportExcel {
- @ExcelProperty(value = "日期", index = 0)
+ @ExcelProperty(value = "日期", index = 0, converter = com.bsd.pjcloud.canteen.excel.ExcelFlexibleDateStringConverter.class)
private String bizDate;
@ExcelProperty(value = "餐段", index = 1)
@@ -40,29 +40,37 @@ public class CanteenDailyMenuImportExcel {
private BigDecimal priceYuan;
/**
- * 可售库存。单品填本行;套餐填首行(挂在套餐 SPU 上)。
- * 不填:新建默认 9999,已存在则不改。
+ * 当日线上可预约库存(挂每日菜谱)。单品填本行;套餐填首行。
+ * 不填:默认 600。
*/
- @ExcelProperty(value = "库存", index = 7)
- private Integer stock;
+ @ExcelProperty(value = "线上库存", index = 7)
+ private Integer onlineStock;
- @ExcelProperty(value = "配料", index = 8)
+ /**
+ * 当日线下销售库存(挂每日菜谱,安卓消费机读此池)。单品填本行;套餐填首行。
+ * 不填:默认 600。
+ */
+ @ExcelProperty(value = "线下库存", index = 8)
+ private Integer offlineStock;
+
+ @ExcelProperty(value = "配料", index = 9)
private String ingredients;
/**
* 每日菜谱记录 ID(文本)。导入时有则更新,无则新增。
* 勿改成数字格式,否则 Excel 会丢精度。
*/
- @ExcelProperty(value = "菜谱ID", index = 9)
+ @ExcelProperty(value = "菜谱ID", index = 10)
private String id;
/**
- * 关联菜品/套餐 SPU ID(文本,仅导出参考,导入不依赖此列)。
+ * 关联菜品 SPU ID(文本)。套餐导出时为本行组成菜 ID,不是套餐本身 ID。
+ * 导入:有则按 ID 更新该菜;套餐主体仍靠「菜谱ID」关联已有套餐。
*/
- @ExcelProperty(value = "菜品SPU ID", index = 10)
+ @ExcelProperty(value = "菜品SPU ID", index = 11)
private String spuId;
/** 仅示例说明用,导入逻辑忽略 */
- @ExcelProperty(value = "填写说明", index = 11)
+ @ExcelProperty(value = "填写说明", index = 12)
private String tip;
}
diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenSpuUpdateStockReqDTO.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenSpuUpdateStockReqDTO.java
index c09b7c08a..8e27c48fb 100644
--- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenSpuUpdateStockReqDTO.java
+++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenSpuUpdateStockReqDTO.java
@@ -6,6 +6,7 @@ import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
+import java.time.LocalDate;
import java.util.List;
/**
@@ -31,6 +32,18 @@ public class CanteenSpuUpdateStockReqDTO {
@Pattern(regexp = "DEDUCT|APPLY|ROLLBACK", message = "库存操作类型错误")
private String operation;
+ /**
+ * 每日菜谱分池:ONLINE=线上可预约;OFFLINE=线下消费机。
+ * 空则仍扣 {@code canteen_spu.stock}(兼容非每日菜谱)。
+ */
+ @Pattern(regexp = "ONLINE|OFFLINE", message = "库存通道错误")
+ private String stockChannel;
+
+ /**
+ * 每日菜谱所属商户;stockChannel 非空时必填。
+ */
+ private Long merchantId;
+
/**
* 商品 SKU
*/
@@ -60,6 +73,16 @@ public class CanteenSpuUpdateStockReqDTO {
*/
private Integer beforeStock;
+ /**
+ * 每日菜谱用餐日;分池扣减时必填(可与同批其他行不同)。
+ */
+ private LocalDate mealDate;
+
+ /**
+ * 每日菜谱餐段;分池扣减时必填。
+ */
+ private Integer hourType;
+
}
}
diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenDailyMenuEntity.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenDailyMenuEntity.java
index 577bd9955..833570834 100644
--- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenDailyMenuEntity.java
+++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenDailyMenuEntity.java
@@ -49,6 +49,18 @@ public class CanteenDailyMenuEntity extends Model {
@Schema(description="归属时间 1早餐 2午餐 3晚餐")
private Integer hourType;
+ /**
+ * 线上可预约库存配额(人工设置;剩余=配额−线上净销量)
+ */
+ @Schema(description = "线上可预约库存配额")
+ private Integer onlineStock;
+
+ /**
+ * 线下销售库存配额(人工设置;剩余对外字段名仍用 stock)
+ */
+ @Schema(description = "线下销售库存配额")
+ private Integer offlineStock;
+
/**
* 商户id
*/
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 17d13aecd..fa262867f 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
@@ -59,8 +59,11 @@ public class CanteenDailyMenuImportPreviewVO {
@Schema(description = "套餐价(元)")
private BigDecimal priceYuan;
- @Schema(description = "库存")
- private Integer stock;
+ @Schema(description = "线上库存(每日)")
+ private Integer onlineStock;
+
+ @Schema(description = "线下库存(每日)")
+ private Integer offlineStock;
@Schema(description = "配料")
private String ingredients;
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 9acc862b5..71d25c964 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
@@ -41,9 +41,25 @@ public class DailyMenuAdminVO {
private Integer sort;
+ /** 线下库存配额(人工) */
+ private Integer offlineStock;
+
+ /** 线上可预约库存配额(人工) */
+ private Integer onlineStock;
+
+ /**
+ * 兼容旧字段:对安卓/列表默认展示「线下剩余」。
+ * 管理端同时看 remainOnlineStock / remainOfflineStock。
+ */
private Integer stock;
- /** 当前剩余可售库存,与 stock 同源 */
+ /** 线上剩余 = onlineStock − 预约净销量 */
+ private Integer remainOnlineStock;
+
+ /** 线下剩余 = offlineStock − 线下净销量 */
+ private Integer remainOfflineStock;
+
+ /** 剩余合计(线上剩余+线下剩余),兼容旧列 remainStock */
private Integer remainStock;
private Integer price;
diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/app/DailyMenuAppVO.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/app/DailyMenuAppVO.java
index ddff7d31a..a1256764c 100644
--- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/app/DailyMenuAppVO.java
+++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/app/DailyMenuAppVO.java
@@ -78,9 +78,16 @@ public class DailyMenuAppVO {
/**
* 当前可售库存。
+ * App/移动端:线上剩余;安卓消费机 AD 接口:线下剩余。字段名保持 stock 兼容旧客户端。
*/
private Integer stock;
+ /** 线上配额(可选透出) */
+ private Integer onlineStock;
+
+ /** 线下配额(可选透出) */
+ private Integer offlineStock;
+
/**
* 商品销量,仅用于详情页展示。
*/
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 ade33a4f7..258ba6dfb 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
@@ -13,7 +13,7 @@ import org.apache.poi.ss.util.CellRangeAddress;
/**
* 每日菜谱导入模板 / 导出:按数据动态合并日期、餐段、套餐名、套餐价、菜谱ID、说明。
- *
列:0日期 1餐段 2套餐名 3菜品名 4分类 5单价 6套餐价 7库存 8配料 9菜谱ID 10SPU ID 11填写说明
+ *
列:0日期 1餐段 2套餐名 3菜品名 4分类 5单价 6套餐价 7线上库存 8线下库存 9配料 10菜谱ID 11SPU ID 12填写说明
*/
public class CanteenDailyMenuTemplateMergeHandler implements WorkbookWriteHandler {
@@ -21,11 +21,11 @@ public class CanteenDailyMenuTemplateMergeHandler implements WorkbookWriteHandle
private static final int COL_MEAL = 1;
private static final int COL_COMBO = 2;
private static final int COL_COMBO_PRICE = 6;
- private static final int COL_STOCK = 7;
- private static final int COL_INGREDIENTS = 8;
- private static final int COL_ID = 9;
- private static final int COL_SPU_ID = 10;
- private static final int COL_TIP = 11;
+ private static final int COL_ONLINE_STOCK = 7;
+ private static final int COL_OFFLINE_STOCK = 8;
+ private static final int COL_INGREDIENTS = 9;
+ private static final int COL_ID = 10;
+ private static final int COL_TIP = 12;
@Override
public void afterWorkbookDispose(WriteWorkbookHolder writeWorkbookHolder) {
@@ -138,10 +138,11 @@ public class CanteenDailyMenuTemplateMergeHandler implements WorkbookWriteHandle
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_STOCK, center);
+ mergeRange(sheet, runStart, endRow, COL_ONLINE_STOCK, center);
+ mergeRange(sheet, runStart, endRow, COL_OFFLINE_STOCK, center);
mergeRange(sheet, runStart, endRow, COL_INGREDIENTS, center);
mergeRange(sheet, runStart, endRow, COL_ID, center);
- mergeRange(sheet, runStart, endRow, COL_SPU_ID, center);
+ // 菜品SPU ID 不合并:套餐每行对应组成菜自己的 ID
mergeRange(sheet, runStart, endRow, COL_TIP, center);
}
runStart = r;
@@ -282,9 +283,10 @@ public class CanteenDailyMenuTemplateMergeHandler implements WorkbookWriteHandle
sheet.setColumnWidth(5, 12 * 256);
sheet.setColumnWidth(6, 12 * 256);
sheet.setColumnWidth(7, 10 * 256);
- sheet.setColumnWidth(8, 22 * 256);
- sheet.setColumnWidth(9, 20 * 256);
+ sheet.setColumnWidth(8, 10 * 256);
+ sheet.setColumnWidth(9, 22 * 256);
sheet.setColumnWidth(10, 20 * 256);
- sheet.setColumnWidth(11, 48 * 256);
+ sheet.setColumnWidth(11, 20 * 256);
+ sheet.setColumnWidth(12, 48 * 256);
}
}
diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenDailyMenuMapper.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenDailyMenuMapper.java
index 80fe522b4..24501c78b 100644
--- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenDailyMenuMapper.java
+++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/CanteenDailyMenuMapper.java
@@ -62,4 +62,18 @@ public interface CanteenDailyMenuMapper extends PjcloudBaseMapper selectAdminList(@Param("time") String time, @Param("merchantId") Long merchantId,
@Param("categoryId") Long categoryId, DataScope dataScope);
+
+ /**
+ * 条件更新线上库存;expected 非空时要求更新前等于 expected。
+ */
+ int updateOnlineStockDelta(@Param("merchantId") Long merchantId, @Param("mealDate") java.time.LocalDate mealDate,
+ @Param("hourType") Integer hourType, @Param("spuId") Long spuId, @Param("delta") int delta,
+ @Param("expected") Integer expected);
+
+ /**
+ * 条件更新线下库存;expected 非空时要求更新前等于 expected。
+ */
+ int updateOfflineStockDelta(@Param("merchantId") Long merchantId, @Param("mealDate") java.time.LocalDate mealDate,
+ @Param("hourType") Integer hourType, @Param("spuId") Long spuId, @Param("delta") int delta,
+ @Param("expected") Integer expected);
}
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 538098cf9..3aa200d02 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
@@ -14,6 +14,7 @@ import com.bsd.pjcloud.canteen.entity.CanteenDailyMenuImportLogDayEntity;
import com.bsd.pjcloud.canteen.entity.CanteenDailyMenuImportLogEntity;
import com.bsd.pjcloud.canteen.entity.CanteenSpuComboItemEntity;
import com.bsd.pjcloud.canteen.entity.CanteenSpuEntity;
+import com.bsd.pjcloud.canteen.excel.ExcelFlexibleDateStringConverter;
import com.bsd.pjcloud.canteen.mapper.CanteenCategoryMapper;
import com.bsd.pjcloud.canteen.mapper.CanteenDailyMenuImportLogDayMapper;
import com.bsd.pjcloud.canteen.mapper.CanteenDailyMenuImportLogMapper;
@@ -29,16 +30,26 @@ import com.bsd.pjcloud.common.core.util.R;
import com.bsd.pjcloud.common.security.service.PjcloudUser;
import com.bsd.pjcloud.common.security.util.SecurityUtils;
import lombok.RequiredArgsConstructor;
+import org.apache.poi.ss.usermodel.Cell;
+import org.apache.poi.ss.usermodel.CellType;
+import org.apache.poi.ss.usermodel.DateUtil;
+import org.apache.poi.ss.usermodel.Row;
+import org.apache.poi.ss.usermodel.Sheet;
+import org.apache.poi.ss.usermodel.Workbook;
+import org.apache.poi.ss.usermodel.WorkbookFactory;
+import org.apache.poi.ss.util.CellRangeAddress;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
import java.io.IOException;
+import java.io.InputStream;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
-import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
@@ -49,6 +60,7 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
+import java.util.TreeSet;
import java.util.stream.Collectors;
/**
@@ -60,6 +72,8 @@ public class CanteenDailyMenuImportHelper {
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+ private static final String DATE_FORMAT_HINT = "yyyy-MM-dd 或 yyyy/M/d(如 2026/9/11)";
+
/** 上传文件上限,防止大文件撑爆堆内存 */
private static final long MAX_FILE_BYTES = 5L * 1024 * 1024;
@@ -80,25 +94,26 @@ public class CanteenDailyMenuImportHelper {
public List templateRows() {
List list = new ArrayList<>();
- list.add(row("2026-08-17", "午餐", "A套餐", "糖醋排骨", "荤菜", "青莲仔排", "12", "21", "100",
- "无菜品SPU ID则新建菜品(不按菜名复用);有ID才更新。套餐价/库存/配料填首行。ID列勿改数字格式"));
- list.add(row(null, null, null, "蒜苔炒腊肉", "荤菜", null, "8", null, null, null));
- list.add(row(null, null, null, "清炒时蔬", "素菜", null, "3", null, null, null));
- list.add(row(null, null, "B套餐", "红烧肉", "荤菜", "五花肉、土豆", "10", "18", "80",
- "换套餐名即另一套;无套餐SPU ID则新建套餐"));
- list.add(row(null, null, null, "土豆丝", "素菜", null, "4", null, null, null));
- list.add(row(null, null, "无", "千张大骨汤", "汤类", null, "0", null, "50",
- "单品:套餐名填「无」「单品」「单点」或「单品-xxx/单点-xxx」;价格填单价列"));
- list.add(row(null, "早餐", "单品-1", "榨菜肉丝面", "面食", "榨菜,肉丝", "6.4", null, "30",
- "单品-xxx / 单点-xxx 一律按单品,套餐价可留空"));
- list.add(row(null, null, "单点-2", "豆浆", "饮品", null, "2", null, "100", null));
- list.add(row("2026-08-18", "午餐,晚餐", "无", "番茄蛋汤", "汤类", null, "2", null, "80",
- "多餐拆行:无菜品SPU ID时各餐段各自新建菜品(库存独立)"));
+ list.add(row("2026-08-17", "午餐", "A套餐", "糖醋排骨", "荤菜", "青莲仔排", "12", "21", "80", "100",
+ "无菜品SPU ID则新建菜品;有则按本行菜品ID更新。套餐价/线上线下库存/配料填首行;套餐每行SPU=组成菜ID"));
+ 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则更新该排菜套餐组成"));
+ 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",
+ "单品-xxx / 单点-xxx 一律按单品,套餐价可留空;不填库存则默认600"));
+ list.add(row(null, null, "单点-2", "豆浆", "饮品", null, "2", null, "100", "100", null));
+ list.add(row("2026-08-18", "午餐,晚餐", "无", "番茄蛋汤", "汤类", null, "2", null, "80", "80",
+ "多餐拆行:无菜品SPU ID时各餐段各自新建菜品;每日线上/线下库存各自独立"));
return list;
}
private static CanteenDailyMenuImportExcel row(String date, String meal, String combo, String dish,
- String categoryName, String ingredients, String dishPrice, String comboPrice, String stock, String tip) {
+ String categoryName, String ingredients, String dishPrice, String comboPrice, String onlineStock,
+ String offlineStock, String tip) {
CanteenDailyMenuImportExcel r = new CanteenDailyMenuImportExcel();
r.setBizDate(date);
r.setMealName(meal);
@@ -112,8 +127,11 @@ public class CanteenDailyMenuImportHelper {
if (StrUtil.isNotBlank(comboPrice)) {
r.setPriceYuan(new BigDecimal(comboPrice));
}
- if (StrUtil.isNotBlank(stock)) {
- r.setStock(Integer.valueOf(stock));
+ if (StrUtil.isNotBlank(onlineStock)) {
+ r.setOnlineStock(Integer.valueOf(onlineStock));
+ }
+ if (StrUtil.isNotBlank(offlineStock)) {
+ r.setOfflineStock(Integer.valueOf(offlineStock));
}
r.setTip(tip);
return r;
@@ -177,14 +195,16 @@ public class CanteenDailyMenuImportHelper {
CanteenDailyMenuImportExcel row = new CanteenDailyMenuImportExcel();
if (written == 0) {
row.setId(menu.getId() == null ? null : String.valueOf(menu.getId()));
- row.setSpuId(menu.getSpuId() == null ? null : String.valueOf(menu.getSpuId()));
row.setBizDate(dateStr);
row.setMealName(meal);
row.setComboName(comboLabel);
row.setPriceYuan(comboPrice);
- row.setStock(menu.getStock());
+ row.setOnlineStock(menu.getOnlineStock());
+ row.setOfflineStock(menu.getOfflineStock());
row.setIngredients(toExcelIngredients(menu.getIngredients()));
}
+ // 每行导出组成菜自己的 SPU ID(不是套餐 SPU);便于再导入按菜更新
+ row.setSpuId(childId == null ? null : String.valueOf(childId));
// 每行都带分类,改菜名新建子菜时不丢分类
row.setCategoryName(menu.getCategoryName());
row.setDishName(StrUtil.blankToDefault(item.getName(), ""));
@@ -203,7 +223,8 @@ public class CanteenDailyMenuImportHelper {
row.setDishName(menu.getName());
row.setCategoryName(menu.getCategoryName());
row.setDishPriceYuan(fenToYuan(menu.getPrice()));
- row.setStock(menu.getStock());
+ row.setOnlineStock(menu.getOnlineStock());
+ row.setOfflineStock(menu.getOfflineStock());
row.setIngredients(toExcelIngredients(menu.getIngredients()));
rows.add(row);
}
@@ -350,6 +371,10 @@ public class CanteenDailyMenuImportHelper {
if (CollUtil.isNotEmpty(errors)) {
return R.failed(formatImportErrors(errors));
}
+ R dateCheck = validateMenuIdDateConsistency(dataRows, dto.getMerchantId());
+ if (dateCheck != null) {
+ return dateCheck;
+ }
return executeImport(dataRows, dto.getMerchantId(), dto.getCategoryId(), replaceDay, dto.getFileName(), true);
}
@@ -411,10 +436,116 @@ public class CanteenDailyMenuImportHelper {
out.failed = R.failed("拆分多餐后有效数据行不能超过 " + MAX_IMPORT_ROWS + " 行,请减少一次导入量");
return out;
}
+ R dateCheck = validateMenuIdDateConsistency(dataRows, merchantId);
+ if (dateCheck != null) {
+ out.failed = dateCheck;
+ return out;
+ }
out.dataRows = dataRows;
return out;
}
+ /**
+ * 拦截「导出后只改了部分行日期」:带菜谱ID时未改日的行会更新原日,已改日的行会在新日新增,极易只改到部分库存。
+ *
+ * - 部分行仍是原日、部分行已改日 → 直接失败
+ * - 全部改到新日但仍带着旧日菜谱ID → 直接失败(避免误以为在更新)
+ *
+ * @return 失败时的 R;通过返回 null
+ */
+ private R validateMenuIdDateConsistency(List dataRows, Long merchantId) {
+ Set menuIds = new HashSet<>();
+ for (CanteenDailyMenuImportExcel row : dataRows) {
+ Long id = parseExcelLong(row.getId());
+ if (id != null) {
+ menuIds.add(id);
+ }
+ }
+ if (menuIds.isEmpty()) {
+ return null;
+ }
+ List menus = canteenDailyMenuService.listByIds(menuIds);
+ Map byId = new HashMap<>();
+ if (CollUtil.isNotEmpty(menus)) {
+ for (CanteenDailyMenuEntity m : menus) {
+ if (m != null && m.getId() != null) {
+ byId.put(m.getId(), m);
+ }
+ }
+ }
+ int keepOriginRows = 0;
+ int movedDateRows = 0;
+ Set excelDates = new TreeSet<>();
+ Set originDates = new TreeSet<>();
+ List samples = new ArrayList<>();
+ int line = 1;
+ for (CanteenDailyMenuImportExcel row : dataRows) {
+ line++;
+ LocalDate excelDate = parseDate(row.getBizDate());
+ if (excelDate != null) {
+ excelDates.add(excelDate);
+ }
+ Long id = parseExcelLong(row.getId());
+ if (id == null) {
+ continue;
+ }
+ CanteenDailyMenuEntity exist = byId.get(id);
+ if (exist == null || exist.getStartTime() == null || !merchantId.equals(exist.getMerchantId())) {
+ continue;
+ }
+ originDates.add(exist.getStartTime());
+ if (excelDate == null) {
+ continue;
+ }
+ if (excelDate.equals(exist.getStartTime())) {
+ keepOriginRows++;
+ }
+ else {
+ movedDateRows++;
+ if (samples.size() < 5) {
+ samples.add(String.format("第%d行「%s」菜谱ID原属 %s,Excel 为 %s", line,
+ StrUtil.blankToDefault(StrUtil.trim(row.getDishName()), "-"),
+ exist.getStartTime().format(DATE_FMT), excelDate.format(DATE_FMT)));
+ }
+ }
+ }
+ if (keepOriginRows > 0 && movedDateRows > 0) {
+ StringBuilder sb = new StringBuilder();
+ sb.append("检测到部分行改了日期、部分行仍是原日期(常见于只改了合并单元格首行或只改了部分套餐)。\n");
+ sb.append("带「菜谱ID」时:未改日期的行会更新原日,已改日期的行会在新日「新增」,库存容易只落到新日。\n");
+ sb.append("Excel 中的日期:").append(joinDates(excelDates));
+ sb.append(";菜谱ID 原属:").append(joinDates(originDates)).append("。\n");
+ sb.append("请二选一后重试:\n");
+ sb.append("1)把日期列全部改成目标日(含套餐续行,不要只改合并格第一行);\n");
+ sb.append("2)清空「菜谱ID」列,再按新日期新增导入。");
+ if (CollUtil.isNotEmpty(samples)) {
+ sb.append("\n示例:\n").append(String.join("\n", samples));
+ }
+ return R.failed(sb.toString());
+ }
+ if (keepOriginRows == 0 && movedDateRows > 0) {
+ StringBuilder sb = new StringBuilder();
+ sb.append("文件中「菜谱ID」原属日期与 Excel 日期不一致(原属 ");
+ sb.append(joinDates(originDates)).append(",Excel 为 ").append(joinDates(excelDates)).append(")。\n");
+ sb.append("继续导入将全部按「新增排菜」,不会更新原日记录,也容易造成两日数据并存。\n");
+ sb.append("请二选一后重试:\n");
+ sb.append("1)若要排到新日期:清空「菜谱ID」列后再导入;\n");
+ sb.append("2)若只改原日库存/菜品:把日期列改回 ").append(joinDates(originDates)).append("。");
+ if (CollUtil.isNotEmpty(samples)) {
+ sb.append("\n示例:\n").append(String.join("\n", samples));
+ }
+ return R.failed(sb.toString());
+ }
+ return null;
+ }
+
+ private static String joinDates(Set dates) {
+ if (CollUtil.isEmpty(dates)) {
+ return "-";
+ }
+ return dates.stream().map(d -> d.format(DATE_FMT)).collect(Collectors.joining("、"));
+ }
+
/**
* 构建导入预览:按组判定新增/更新,锁定日跳过。
*/
@@ -433,7 +564,7 @@ public class CanteenDailyMenuImportHelper {
LocalDate date = parseDate(row.getBizDate());
String dish = StrUtil.trim(row.getDishName());
if (date == null) {
- errors.add("第" + line + "行:日期格式应为 yyyy-MM-dd");
+ errors.add("第" + line + "行:日期格式应为 " + DATE_FORMAT_HINT);
continue;
}
if (hourType == null) {
@@ -545,16 +676,19 @@ public class CanteenDailyMenuImportHelper {
}
for (CanteenDailyMenuImportExcel item : items) {
+ item.setBizDate(dateStr);
CanteenDailyMenuImportPreviewVO.Row previewRow = new CanteenDailyMenuImportPreviewVO.Row();
previewRow.setRowNo(rowNos.get(item));
- previewRow.setBizDate(StrUtil.trim(item.getBizDate()));
+ // 预览日期一律用规范化后的 yyyy-MM-dd,与上方日期 Tab 一致
+ previewRow.setBizDate(dateStr);
previewRow.setMealName(StrUtil.trim(item.getMealName()));
previewRow.setComboName(StrUtil.trim(item.getComboName()));
previewRow.setDishName(StrUtil.trim(item.getDishName()));
previewRow.setCategoryName(StrUtil.trim(item.getCategoryName()));
previewRow.setDishPriceYuan(item.getDishPriceYuan());
previewRow.setPriceYuan(item.getPriceYuan());
- previewRow.setStock(item.getStock());
+ previewRow.setOnlineStock(item.getOnlineStock());
+ previewRow.setOfflineStock(item.getOfflineStock());
previewRow.setIngredients(StrUtil.trim(item.getIngredients()));
previewRow.setId(StrUtil.trim(item.getId()));
previewRow.setSpuId(StrUtil.trim(item.getSpuId()));
@@ -596,7 +730,7 @@ public class CanteenDailyMenuImportHelper {
LocalDate date = parseDate(row.getBizDate());
String dish = StrUtil.trim(row.getDishName());
if (date == null) {
- errors.add("第" + line + "行:日期格式应为 yyyy-MM-dd");
+ errors.add("第" + line + "行:日期格式应为 " + DATE_FORMAT_HINT);
continue;
}
if (hourType == null) {
@@ -705,15 +839,16 @@ public class CanteenDailyMenuImportHelper {
if (StrUtil.isBlank(comboName) || isSingleComboMarker(comboName)) {
for (CanteenDailyMenuImportExcel item : items) {
+ // 每日线上/线下库存挂菜谱行,不再写入 canteen_spu.stock
FindOrCreate r = findOrCreateDish(merchantId, deptId,
item.getCategoryName(), categoryId, categoryMap,
- item.getDishName(), item.getIngredients(), resolveDishPriceFen(item), item.getStock(),
+ item.getDishName(), item.getIngredients(), resolveDishPriceFen(item), null,
parseExcelLong(item.getSpuId()), cache);
if (r.created) {
createdDish++;
}
if (schedule(merchantId, deptId, date, hourType, r.spu.getId(), replaceDay, clearedDayHour,
- cache, pendingMenus)) {
+ cache, pendingMenus, item.getOnlineStock(), item.getOfflineStock())) {
scheduled++;
}
appendDish(st, item.getDishName(), hourType);
@@ -722,7 +857,8 @@ public class CanteenDailyMenuImportHelper {
else {
List children = new ArrayList<>();
Integer comboPrice = null;
- Integer comboStock = first.getStock();
+ Integer onlineStock = first.getOnlineStock();
+ Integer offlineStock = first.getOfflineStock();
Set seenDishKeys = new LinkedHashSet<>();
for (int i = 0; i < items.size(); i++) {
CanteenDailyMenuImportExcel item = items.get(i);
@@ -758,13 +894,13 @@ public class CanteenDailyMenuImportHelper {
String fullComboName = comboName + "-" + mainName;
FindOrCreate combo = findOrCreateCombo(merchantId, deptId,
first.getCategoryName(), categoryId, categoryMap,
- fullComboName, comboName, comboPrice, comboStock, children,
- resolveComboIngredients(first), parseExcelLong(first.getSpuId()), cache);
+ fullComboName, comboName, comboPrice, null, children,
+ resolveComboIngredients(first), null, cache);
if (combo.created) {
createdCombo++;
}
if (schedule(merchantId, deptId, date, hourType, combo.spu.getId(), replaceDay, clearedDayHour,
- cache, pendingMenus)) {
+ cache, pendingMenus, onlineStock, offlineStock)) {
scheduled++;
}
}
@@ -962,16 +1098,26 @@ public class CanteenDailyMenuImportHelper {
/**
* EasyExcel 流式按行解析;合并单元格需整表,故仍收集 List,但用行数硬顶防 OOM。
+ * 先用 POI 把第 0 列日期强制写成 yyyy-MM-dd 文本(处理 WPS 日期序列/合并单元格),
+ * 再交给 EasyExcel,避免同一天被解析成两个日期 Tab。
*/
private static List readRowsStreaming(MultipartFile file) throws IOException {
+ byte[] normalized;
+ try (InputStream in = file.getInputStream()) {
+ normalized = preprocessDateColumnToText(in);
+ }
List rows = new ArrayList<>();
- EasyExcel.read(file.getInputStream(), CanteenDailyMenuImportExcel.class,
+ EasyExcel.read(new ByteArrayInputStream(normalized), CanteenDailyMenuImportExcel.class,
new ReadListener() {
@Override
public void invoke(CanteenDailyMenuImportExcel data, AnalysisContext context) {
if (rows.size() >= MAX_IMPORT_ROWS) {
throw new IllegalArgumentException("导入行数不能超过 " + MAX_IMPORT_ROWS + " 行");
}
+ String canon = ExcelFlexibleDateStringConverter.canonicalize(data.getBizDate());
+ if (canon != null) {
+ data.setBizDate(canon);
+ }
rows.add(data);
}
@@ -983,6 +1129,91 @@ public class CanteenDailyMenuImportHelper {
return rows;
}
+ /**
+ * 将 Sheet 第 0 列(日期)统一写成 yyyy-MM-dd 文本;合并区整列填充,避免只改了合并首格、其它格仍是旧日期。
+ */
+ private static byte[] preprocessDateColumnToText(InputStream in) throws IOException {
+ try (Workbook wb = WorkbookFactory.create(in); ByteArrayOutputStream out = new ByteArrayOutputStream()) {
+ Sheet sheet = wb.getSheetAt(0);
+ if (sheet == null) {
+ wb.write(out);
+ return out.toByteArray();
+ }
+ // 先处理涉及日期列的合并区:用左上角日期填满整块,再拆开合并,避免续行读到空/旧值
+ List merges = new ArrayList<>(sheet.getMergedRegions());
+ List dateMergeIndexes = new ArrayList<>();
+ for (int i = 0; i < merges.size(); i++) {
+ CellRangeAddress range = merges.get(i);
+ if (range.getFirstColumn() > 0 || range.getLastColumn() < 0) {
+ continue;
+ }
+ Row firstRow = sheet.getRow(range.getFirstRow());
+ Cell firstCell = firstRow == null ? null : firstRow.getCell(0);
+ LocalDate date = readPoiCellAsLocalDate(firstCell);
+ if (date == null) {
+ continue;
+ }
+ String text = date.format(DATE_FMT);
+ for (int r = range.getFirstRow(); r <= range.getLastRow(); r++) {
+ writeDateText(sheet, r, text);
+ }
+ dateMergeIndexes.add(i);
+ }
+ for (int i = dateMergeIndexes.size() - 1; i >= 0; i--) {
+ sheet.removeMergedRegion(dateMergeIndexes.get(i));
+ }
+ // 再扫一遍非空数据行,把剩余日期格也写成文本
+ int last = sheet.getLastRowNum();
+ for (int r = 1; r <= last; r++) {
+ Row row = sheet.getRow(r);
+ if (row == null) {
+ continue;
+ }
+ Cell cell = row.getCell(0);
+ LocalDate date = readPoiCellAsLocalDate(cell);
+ if (date != null) {
+ writeDateText(sheet, r, date.format(DATE_FMT));
+ }
+ }
+ wb.write(out);
+ return out.toByteArray();
+ }
+ }
+
+ private static void writeDateText(Sheet sheet, int rowIdx, String text) {
+ Row row = sheet.getRow(rowIdx);
+ if (row == null) {
+ row = sheet.createRow(rowIdx);
+ }
+ Cell cell = row.getCell(0);
+ if (cell == null) {
+ cell = row.createCell(0);
+ }
+ cell.setCellValue(text);
+ }
+
+ private static LocalDate readPoiCellAsLocalDate(Cell cell) {
+ if (cell == null) {
+ return null;
+ }
+ CellType type = cell.getCellType();
+ if (type == CellType.FORMULA) {
+ type = cell.getCachedFormulaResultType();
+ }
+ if (type == CellType.NUMERIC) {
+ // 日期格式或裸序列都按序列天数换算,避免 getLocalDateTimeCellValue 时区偏差
+ double n = cell.getNumericCellValue();
+ if (DateUtil.isCellDateFormatted(cell) || (DateUtil.isValidExcelDate(n) && n >= 20000 && n < 80000)) {
+ return ExcelFlexibleDateStringConverter.fromExcelSerial(n);
+ }
+ return null;
+ }
+ if (type == CellType.STRING) {
+ return ExcelFlexibleDateStringConverter.parseLocalDate(cell.getStringCellValue());
+ }
+ return null;
+ }
+
private ImportCache buildImportCache(Long merchantId, Set dates, Set menuIds) {
ImportCache cache = new ImportCache();
// 只取导入比对所需字段;更新时用 UpdateWrapper,避免 pic_url 等 ALWAYS 字段被写成 null
@@ -1102,7 +1333,7 @@ public class CanteenDailyMenuImportHelper {
LocalDate date = parseDate(first.getBizDate());
Integer hourType = parseMeal(first.getMealName());
if (date == null) {
- return "菜谱ID " + menuId + ":日期格式应为 yyyy-MM-dd";
+ return "菜谱ID " + menuId + ":日期格式应为 " + DATE_FORMAT_HINT;
}
if (hourType == null) {
return "菜谱ID " + menuId + ":餐段应为 早餐/午餐/晚餐";
@@ -1130,10 +1361,12 @@ public class CanteenDailyMenuImportHelper {
}
FindOrCreate created = findOrCreateDish(merchantId, deptId,
row.getCategoryName(), defaultCategoryId, categoryMap, dish, row.getIngredients(),
- resolveDishPriceFen(row), row.getStock(), parseExcelLong(row.getSpuId()), cache);
+ resolveDishPriceFen(row), null, parseExcelLong(row.getSpuId()), cache);
menu.setStartTime(date);
menu.setHourType(hourType);
menu.setSpuId(created.spu.getId());
+ menu.setOnlineStock(CanteenDailyMenuStockSupport.quotaOrDefault(row.getOnlineStock()));
+ menu.setOfflineStock(CanteenDailyMenuStockSupport.quotaOrDefault(row.getOfflineStock()));
canteenDailyMenuService.updateById(menu);
return null;
}
@@ -1143,7 +1376,8 @@ public class CanteenDailyMenuImportHelper {
LocalDate date, Integer hourType, ImportCache cache) {
List children = new ArrayList<>();
Integer comboPrice = null;
- Integer comboStock = items.get(0).getStock();
+ Integer onlineStock = items.get(0).getOnlineStock();
+ Integer offlineStock = items.get(0).getOfflineStock();
Set seenDishKeys = new LinkedHashSet<>();
for (int i = 0; i < items.size(); i++) {
CanteenDailyMenuImportExcel item = items.get(i);
@@ -1178,11 +1412,13 @@ public class CanteenDailyMenuImportHelper {
String fullComboName = comboName + "-" + mainName;
FindOrCreate combo = findOrCreateCombo(merchantId, deptId,
items.get(0).getCategoryName(), defaultCategoryId, categoryMap, fullComboName,
- comboName, comboPrice, comboStock, children, resolveComboIngredients(items.get(0)),
- parseExcelLong(items.get(0).getSpuId()), cache);
+ comboName, comboPrice, null, children, resolveComboIngredients(items.get(0)),
+ menu.getSpuId(), 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);
return null;
}
@@ -1192,7 +1428,7 @@ public class CanteenDailyMenuImportHelper {
*/
private boolean schedule(Long merchantId, Long deptId, LocalDate date, Integer hourType, Long spuId,
boolean replaceDay, Set clearedDayHour, ImportCache cache,
- List pendingMenus) {
+ List pendingMenus, Integer onlineStock, Integer offlineStock) {
String dh = date + "|" + hourType;
if (replaceDay && !clearedDayHour.contains(dh)) {
canteenDailyMenuService.remove(Wrappers.lambdaQuery()
@@ -1212,6 +1448,8 @@ public class CanteenDailyMenuImportHelper {
menu.setStartTime(date);
menu.setHourType(hourType);
menu.setSpuId(spuId);
+ menu.setOnlineStock(CanteenDailyMenuStockSupport.quotaOrDefault(onlineStock));
+ menu.setOfflineStock(CanteenDailyMenuStockSupport.quotaOrDefault(offlineStock));
pendingMenus.add(menu);
cache.existingMenuKeys.add(key);
return true;
@@ -1219,7 +1457,9 @@ public class CanteenDailyMenuImportHelper {
/**
* 解析/创建单品。
- * 有「菜品SPU ID」→ 按 ID 复用并按需更新;无 ID → 一律新建,不再按菜名复用(避免多日导入互相覆盖库存)。
+ *
有「菜品SPU ID」→ 按 ID 复用并按需更新;无 ID → 一律新建,不再按菜名复用。
+ *
{@code stock} 仅在显式传入时更新 {@code canteen_spu.stock};每日菜谱导入传 {@code null},
+ * 当日可售走 {@code canteen_daily_menu.online_stock/offline_stock}。
*/
private FindOrCreate findOrCreateDish(Long merchantId, Long deptId, String categoryName, Long defaultCategoryId,
Map categoryMap, String name, String ingredients, Integer priceFen, Integer stock,
@@ -1594,6 +1834,7 @@ public class CanteenDailyMenuImportHelper {
*/
private static void normalizeMergedCells(List rows) {
String lastDate = null;
+ LocalDate lastDateValue = null;
String lastMeal = null;
String lastCombo = null;
String lastCategory = null;
@@ -1608,11 +1849,19 @@ public class CanteenDailyMenuImportHelper {
row.setBizDate(lastDate);
}
else {
- if (lastDate != null && !incomingDate.equals(lastDate)) {
+ // 统一成 yyyy-MM-dd,避免 2026/9/11 与 2026-09-11 被当成两天;并消化 Excel 序列/时区偏移
+ LocalDate parsed = parseDate(incomingDate);
+ String canonical = parsed != null ? parsed.format(DATE_FMT) : incomingDate;
+ if (lastDateValue != null && parsed != null && !parsed.equals(lastDateValue)) {
lastMenuId = null;
lastSpuId = null;
}
- lastDate = incomingDate;
+ else if (lastDate != null && parsed == null && !canonical.equals(lastDate)) {
+ lastMenuId = null;
+ lastSpuId = null;
+ }
+ lastDate = canonical;
+ lastDateValue = parsed;
row.setBizDate(lastDate);
}
String incomingMeal = StrUtil.trim(row.getMealName());
@@ -1828,7 +2077,8 @@ public class CanteenDailyMenuImportHelper {
copy.setCategoryName(src.getCategoryName());
copy.setDishPriceYuan(src.getDishPriceYuan());
copy.setPriceYuan(src.getPriceYuan());
- copy.setStock(src.getStock());
+ copy.setOnlineStock(src.getOnlineStock());
+ copy.setOfflineStock(src.getOfflineStock());
copy.setIngredients(src.getIngredients());
copy.setId(src.getId());
copy.setSpuId(src.getSpuId());
@@ -1837,15 +2087,7 @@ public class CanteenDailyMenuImportHelper {
}
private static LocalDate parseDate(String s) {
- if (StrUtil.isBlank(s)) {
- return null;
- }
- try {
- return LocalDate.parse(s.trim(), DATE_FMT);
- }
- catch (DateTimeParseException e) {
- return null;
- }
+ return com.bsd.pjcloud.canteen.excel.ExcelFlexibleDateStringConverter.parseLocalDate(s);
}
private static Integer toFen(BigDecimal yuan) {
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 dd494b835..3edf2bb72 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
@@ -68,6 +68,8 @@ public class CanteenDailyMenuServiceImpl extends ServiceImpl getCanteenMenuListScope(String day, Integer hourType, Long categoryId, Long merchantId) {
List list = baseMapper.getCanteenMenuList(day, hourType, categoryId, merchantId, DataScope.of());
fillDailyMenuAppCombo(list);
+ // 安卓消费机:stock = 当日线下剩余
+ LocalDate mealDate = null;
+ try {
+ mealDate = day == null ? null : LocalDate.parse(day);
+ }
+ catch (Exception ignored) {
+ mealDate = null;
+ }
+ dailyMenuStockSupport.fillAdOfflineRemain(list, merchantId, mealDate);
return list;
}
@@ -380,6 +397,7 @@ public class CanteenDailyMenuServiceImpl extends ServiceImpl list = baseMapper.getCanteenMenuListByDateRange(request, DataScope.of());
fillDailyMenuAppCombo(list);
+ dailyMenuStockSupport.fillAdOfflineRemainByRowDate(list, request.getMerchantId());
return list;
}
@@ -387,6 +405,8 @@ public class CanteenDailyMenuServiceImpl extends ServiceImpl pageData = baseMapper.selectTimeFoodPage(page, dailyMenuDTO);
fillDailyMenuAppCombo(pageData.getRecords());
+ dailyMenuStockSupport.fillAppOnlineRemain(pageData.getRecords(), dailyMenuDTO.getMerchantId(),
+ dailyMenuDTO.getStartTime());
return pageData;
}
@@ -401,6 +421,8 @@ public class CanteenDailyMenuServiceImpl extends ServiceImpl current) {
+ CanteenSpuEntity spu = baseMapper.selectById(item.getId());
+ return R.failed(MsgUtils.getMessage(ErrorCodes.HARD_PRODUCT_STOCK_LOW,
+ StrUtil.blankToDefault(spu == null ? null : spu.getName(), "商品")));
+ }
+ }
+ for (CanteenSpuUpdateStockReqDTO.Item item : dto.getItems()) {
+ int updated = offline
+ ? canteenDailyMenuMapper.updateOfflineStockDelta(dto.getMerchantId(), item.getMealDate(),
+ item.getHourType(), item.getId(), item.getIncrCount(), null)
+ : canteenDailyMenuMapper.updateOnlineStockDelta(dto.getMerchantId(), item.getMealDate(),
+ item.getHourType(), item.getId(), item.getIncrCount(), null);
+ if (updated == 0) {
+ throw new I18nBizException(ErrorCodes.CANTEEN_STOCK_INSUFFICIENT);
+ }
+ }
+ return R.ok();
+ }
+
+ /** 幂等分池扣减:改写 daily_menu.online_stock / offline_stock。 */
+ private R updateDailyMenuChannelStockIdempotent(CanteenSpuUpdateStockReqDTO dto, boolean applyOperation,
+ boolean rollbackOperation) {
+ if (dto.getMerchantId() == null
+ || dto.getItems().stream().anyMatch(item -> item.getMealDate() == null || item.getHourType() == null)) {
+ return R.failed(MsgUtils.getMessage(ErrorCodes.CANTEEN_STOCK_BIZ_PARAM_BAD));
+ }
+ boolean offline = "OFFLINE".equals(StrUtil.trim(dto.getStockChannel()));
+ String stateKey = ORDER_STOCK_BIZ_KEY_PREFIX + "daily:" + dto.getStockChannel() + ":" + dto.getBizId();
+ String state = stringRedisTemplate.opsForValue().get(stateKey);
+ if (applyOperation && ("APPLIED".equals(state) || "ROLLED_BACK".equals(state))) {
+ return R.ok();
+ }
+ if (rollbackOperation && ("ROLLED_BACK".equals(state) || ORDER_STOCK_BIZ_STATE_COMPENSATED.equals(state))) {
+ return R.ok();
+ }
+ for (CanteenSpuUpdateStockReqDTO.Item item : dto.getItems()) {
+ CanteenDailyMenuEntity menu = findDailyMenuRow(dto.getMerchantId(), item.getMealDate(), item.getHourType(),
+ item.getId());
+ if (menu == null) {
+ return R.failed(MsgUtils.getMessage(ErrorCodes.CANTEEN_STOCK_SPU_BAD));
+ }
+ int current = offline
+ ? CanteenDailyMenuStockSupport.quotaOrDefault(menu.getOfflineStock())
+ : CanteenDailyMenuStockSupport.quotaOrDefault(menu.getOnlineStock());
+ int expectedStock = item.getBeforeStock();
+ if (expectedStock < 0) {
+ return R.failed(MsgUtils.getMessage(ErrorCodes.CANTEEN_STOCK_SNAPSHOT_BAD));
+ }
+ if (Math.addExact(expectedStock, item.getIncrCount()) < 0) {
+ CanteenSpuEntity spu = baseMapper.selectById(item.getId());
+ return R.failed(MsgUtils.getMessage(ErrorCodes.HARD_PRODUCT_STOCK_LOW,
+ StrUtil.blankToDefault(spu == null ? null : spu.getName(), "商品")));
+ }
+ if (applyOperation && current != expectedStock) {
+ CanteenSpuEntity spu = baseMapper.selectById(item.getId());
+ return R.failed(MsgUtils.getMessage(ErrorCodes.CANTEEN_STOCK_CHANGED,
+ StrUtil.blankToDefault(spu == null ? null : spu.getName(), "商品")));
+ }
+ if (rollbackOperation && "APPLIED".equals(state)) {
+ int rollbackDelta = -item.getIncrCount();
+ if (rollbackDelta < 0 && current < Math.abs(rollbackDelta)) {
+ CanteenSpuEntity spu = baseMapper.selectById(item.getId());
+ return R.failed(MsgUtils.getMessage(ErrorCodes.HARD_PRODUCT_STOCK_LOW,
+ StrUtil.blankToDefault(spu == null ? null : spu.getName(), "商品")));
+ }
+ }
+ }
+ for (CanteenSpuUpdateStockReqDTO.Item item : dto.getItems()) {
+ int delta = applyOperation ? item.getIncrCount() : -item.getIncrCount();
+ int updated;
+ if (rollbackOperation && "APPLIED".equals(state)) {
+ updated = offline
+ ? canteenDailyMenuMapper.updateOfflineStockDelta(dto.getMerchantId(), item.getMealDate(),
+ item.getHourType(), item.getId(), delta, null)
+ : canteenDailyMenuMapper.updateOnlineStockDelta(dto.getMerchantId(), item.getMealDate(),
+ item.getHourType(), item.getId(), delta, null);
+ if (updated == 0) {
+ throw new I18nBizException(ErrorCodes.CANTEEN_STOCK_COMPENSATE_FAIL);
+ }
+ continue;
+ }
+ if (applyOperation) {
+ updated = offline
+ ? canteenDailyMenuMapper.updateOfflineStockDelta(dto.getMerchantId(), item.getMealDate(),
+ item.getHourType(), item.getId(), delta, item.getBeforeStock())
+ : canteenDailyMenuMapper.updateOnlineStockDelta(dto.getMerchantId(), item.getMealDate(),
+ item.getHourType(), item.getId(), delta, item.getBeforeStock());
+ if (updated == 0) {
+ throw new I18nBizException(ErrorCodes.CANTEEN_STOCK_CHANGED, "");
+ }
+ }
+ }
+ String targetState = applyOperation ? "APPLIED"
+ : "APPLIED".equals(state) ? ORDER_STOCK_BIZ_STATE_COMPENSATED : "ROLLED_BACK";
+ updateStockBizState(stateKey, state, targetState);
+ return R.ok();
+ }
+
+ private CanteenDailyMenuEntity findDailyMenuRow(Long merchantId, java.time.LocalDate mealDate, Integer hourType,
+ Long spuId) {
+ return canteenDailyMenuMapper.selectOne(Wrappers.lambdaQuery()
+ .eq(CanteenDailyMenuEntity::getMerchantId, merchantId)
+ .eq(CanteenDailyMenuEntity::getStartTime, mealDate)
+ .eq(CanteenDailyMenuEntity::getHourType, hourType)
+ .eq(CanteenDailyMenuEntity::getSpuId, spuId)
+ .last("limit 1"));
+ }
+
/**
* 写入库存幂等状态,并在食堂本地数据库事务回滚时恢复原状态,防止出现“状态成功、库存未提交”。
*
diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/mapper/CanteenDailyMenuMapper.xml b/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/mapper/CanteenDailyMenuMapper.xml
index ad49c3144..873aa65bf 100644
--- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/mapper/CanteenDailyMenuMapper.xml
+++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/mapper/CanteenDailyMenuMapper.xml
@@ -7,6 +7,8 @@
+
+
@@ -32,6 +34,8 @@
+
+
@@ -78,7 +82,9 @@
sp.slider_pic_urls,
sp.introduction,
sp.description,
- sp.stock,
+ IFNULL(t.offline_stock, 600) AS stock,
+ IFNULL(t.online_stock, 600) AS online_stock,
+ IFNULL(t.offline_stock, 600) AS offline_stock,
sp.sales_count,
sp.sort,
sp.is_show,
@@ -124,7 +130,9 @@
sp.slider_pic_urls,
sp.introduction,
sp.description,
- sp.stock,
+ IFNULL(t.offline_stock, 600) AS stock,
+ IFNULL(t.online_stock, 600) AS online_stock,
+ IFNULL(t.offline_stock, 600) AS offline_stock,
sp.sales_count,
sp.sort,
sp.is_show,
@@ -160,7 +168,9 @@
s.id as spu_id,
s.`name`,
s.price,
- s.stock,
+ IFNULL(m.online_stock, 600) as stock,
+ IFNULL(m.online_stock, 600) as online_stock,
+ IFNULL(m.offline_stock, 600) as offline_stock,
s.pic_url,
s.sort,
s.is_show,
@@ -195,7 +205,9 @@
s.name,
s.price,
s.market_price,
- s.stock,
+ IFNULL(m.online_stock, 600) AS stock,
+ IFNULL(m.online_stock, 600) AS online_stock,
+ IFNULL(m.offline_stock, 600) AS offline_stock,
s.sales_count,
s.pic_url,
s.slider_pic_urls,
@@ -226,7 +238,9 @@
sp.price,
sp.market_price,
sp.pic_url,
- sp.stock,
+ IFNULL(t.offline_stock, 600) AS stock,
+ IFNULL(t.online_stock, 600) AS online_stock,
+ IFNULL(t.offline_stock, 600) AS offline_stock,
sp.sort,
sp.description AS ingredients,
c.id AS category_id,
@@ -249,4 +263,32 @@
ORDER BY t.hour_type ASC, sp.sort ASC, t.id ASC
+
+
+ UPDATE canteen_daily_menu
+ SET online_stock = online_stock + #{delta}
+ WHERE del_flag = '0'
+ AND merchant_id = #{merchantId}
+ AND start_time = #{mealDate}
+ AND hour_type = #{hourType}
+ AND spu_id = #{spuId}
+ AND online_stock + #{delta} >= 0
+
+ AND online_stock = #{expected}
+
+
+
+
+ UPDATE canteen_daily_menu
+ SET offline_stock = offline_stock + #{delta}
+ WHERE del_flag = '0'
+ AND merchant_id = #{merchantId}
+ AND start_time = #{mealDate}
+ AND hour_type = #{hourType}
+ AND spu_id = #{spuId}
+ AND offline_stock + #{delta} >= 0
+
+ AND offline_stock = #{expected}
+
+
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/feign/RemoteHardOrderService.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/feign/RemoteHardOrderService.java
index b00587c2f..77b0acaa7 100644
--- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/feign/RemoteHardOrderService.java
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/feign/RemoteHardOrderService.java
@@ -59,4 +59,12 @@ public interface RemoteHardOrderService {
@PostMapping("/hard-order/inner/pending-reservation-count")
R> listPendingReservationCounts(@RequestBody List userIds);
+ /**
+ * 每日菜谱净销量(已支付/已完成 − 完成售后),按用餐日+餐段+SPU+订单类型。
+ */
+ @NoToken
+ @PostMapping("/hard-order/inner/daily-menu-net-sold")
+ R> listDailyMenuNetSold(
+ @RequestBody com.bsd.pjcloud.hard.api.dto.order.remote.DailyMenuNetSoldQueryDTO dto);
+
}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java
index fadbc2607..6b37a8541 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java
@@ -39,10 +39,12 @@ import com.bsd.pjcloud.hard.api.dto.order.app.HardAppCreateShopOrderDto;
import com.bsd.pjcloud.hard.api.dto.order.app.HardAppCreateTakeOutOrderDto;
import com.bsd.pjcloud.hard.api.dto.order.remote.HardUpdateShopStatusDto;
import com.bsd.pjcloud.hard.api.dto.order.remote.HardUpdateTakeOutStatusDto;
+import com.bsd.pjcloud.hard.api.dto.order.remote.DailyMenuNetSoldQueryDTO;
import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
import com.bsd.pjcloud.hard.api.vo.order.HardOrderVo;
import com.bsd.pjcloud.hard.api.vo.order.HardOrderStatisticsVo;
import com.bsd.pjcloud.hard.api.vo.order.HardPendingReservationCountVo;
+import com.bsd.pjcloud.hard.api.vo.order.DailyMenuNetSoldVO;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppCreateReserveOrdersVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppScanPayOrderVo;
import com.bsd.pjcloud.hard.api.vo.order.app.HardAppCreateShopOrderVo;
@@ -139,6 +141,16 @@ public class HardOrderController {
public R> listPendingReservationCounts(@RequestBody List userIds) {
return R.ok(hardwareOrderService.listPendingReservationCounts(userIds));
}
+
+ /**
+ * 食堂服务计算每日菜谱线上/线下剩余库存时调用。
+ */
+ @Inner
+ @Operation(summary = "每日菜谱净销量", description = "按用餐日+餐段+SPU+订单类型汇总净销量(已排除完成售后)")
+ @PostMapping("/inner/daily-menu-net-sold")
+ public R> listDailyMenuNetSold(@RequestBody DailyMenuNetSoldQueryDTO dto) {
+ return R.ok(hardwareOrderService.listDailyMenuNetSold(dto));
+ }
/* ************************************* end 远程调用 ************************************* */
/* ************************************* start app start ************************************* */
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardCarMapper.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardCarMapper.java
index e5fbe1cf0..56a491d5c 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardCarMapper.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/mapper/order/HardCarMapper.java
@@ -167,4 +167,10 @@ public interface HardCarMapper extends PjcloudBaseMapper {
* 个人菜品喜好排行
*/
List productLikeTop(@Param("dto") HardAppProductLikeTopDto dto);
+
+ /**
+ * 每日菜谱:按用餐日+餐段+SPU+订单类型汇总净销量(已支付/已完成 − 完成售后)。
+ */
+ List listDailyMenuNetSold(
+ @Param("dto") com.bsd.pjcloud.hard.api.dto.order.remote.DailyMenuNetSoldQueryDTO dto);
}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCarService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCarService.java
index 356b7b781..c62be5d57 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCarService.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardCarService.java
@@ -60,6 +60,12 @@ public interface HardCarService extends IService {
*/
List batchSaveDailyMenu(HardCarBatchSaveOrUpdateDto dto);
+ /**
+ * 每日菜谱按日净销量(购买 − 完成售后),供食堂计算线上/线下剩余库存。
+ */
+ List listDailyMenuNetSold(
+ com.bsd.pjcloud.hard.api.dto.order.remote.DailyMenuNetSoldQueryDTO dto);
+
/**
* 创建购物车
*/
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java
index 08c4cefff..9724fb02c 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardOrderService.java
@@ -74,6 +74,12 @@ public interface HardOrderService extends IService {
*/
List listPendingReservationCounts(List userIds);
+ /**
+ * 每日菜谱净销量(按用餐日),供食堂计算分池剩余库存。
+ */
+ List listDailyMenuNetSold(
+ com.bsd.pjcloud.hard.api.dto.order.remote.DailyMenuNetSoldQueryDTO dto);
+
HardOrderVo join(Long id);
/**
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCarServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCarServiceImpl.java
index 5e1f7acc0..30837a388 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCarServiceImpl.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardCarServiceImpl.java
@@ -94,6 +94,12 @@ public class HardCarServiceImpl extends ServiceImpl listDailyMenuNetSold(
+ com.bsd.pjcloud.hard.api.dto.order.remote.DailyMenuNetSoldQueryDTO dto) {
+ return baseMapper.listDailyMenuNetSold(dto);
+ }
+
/**
* 查询当前用户在指定商户的普通购物车,并按服务端商品来源补充原有超市展示字段。
*
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java
index 2c07fb8b4..00c251138 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImpl.java
@@ -37,6 +37,7 @@ import com.bsd.pjcloud.canteen.dto.delivery.CanteenDeliveryOrderSyncDTO;
import com.bsd.pjcloud.canteen.entity.CanteenSpuEntity;
import com.bsd.pjcloud.canteen.vo.app.OnlineSpuChoiceGroupsVO;
import com.bsd.pjcloud.canteen.vo.app.CanteenOnlineSpuAppVO;
+import com.bsd.pjcloud.canteen.vo.app.DailyMenuAppVO;
import com.bsd.pjcloud.common.core.constant.CacheConstants;
import com.bsd.pjcloud.common.core.constant.CommonConstants;
import com.bsd.pjcloud.common.core.constant.CommonRoleConstant;
@@ -301,6 +302,16 @@ public class HardOrderServiceImpl extends ServiceImpl listDailyMenuNetSold(
+ com.bsd.pjcloud.hard.api.dto.order.remote.DailyMenuNetSoldQueryDTO dto) {
+ if (dto == null || dto.getMerchantId() == null || dto.getMealDate() == null) {
+ return List.of();
+ }
+ List rows = hardCarService.listDailyMenuNetSold(dto);
+ return rows == null ? List.of() : rows;
+ }
+
/**
* 商户查询订单列表
*/
@@ -1566,7 +1577,7 @@ public class HardOrderServiceImpl extends ServiceImpl inventorySnapshot = validateDailyMenuCartProducts(merchantId, contexts, dinerUserIds.size());
+ Map inventorySnapshot = validateDailyMenuCartProducts(merchantId, contexts, dinerUserIds.size());
validateDailyMenuSingleProductLimit(merchant, contexts);
List dinerUsers = Optional.ofNullable(userService.getInnerUserById(dinerUserIds))
@@ -1660,14 +1671,14 @@ public class HardOrderServiceImpl extends ServiceImpl createdOrders,
List contexts,
int dinerCount,
- Map inventorySnapshot,
+ Map inventorySnapshot,
String inventoryBizId) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
throw new I18nBizException(ErrorCodes.HARD_RESERVE_TX_BAD);
}
List rollbackOrders = List.copyOf(createdOrders);
List rollbackContexts = List.copyOf(contexts);
- Map rollbackInventory = Map.copyOf(inventorySnapshot);
+ Map rollbackInventory = Map.copyOf(inventorySnapshot);
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
/**
* 本地事务明确回滚时补偿远程库存和钱包;状态未知时不主动反向操作,避免数据库实际已提交却误恢复。
@@ -2166,67 +2177,79 @@ public class HardOrderServiceImpl extends ServiceImpl validateDailyMenuCartProducts(Long merchantId,
+ private Map validateDailyMenuCartProducts(Long merchantId,
List contexts,
int dinerCount) {
- List productIds = contexts.stream()
- .flatMap(context -> context.carList().stream())
- .map(HardCarEntity::getProductId)
- .distinct()
- .toList();
- R> result = remoteCanteenSpuService.getInnerSpuListByIds(productIds);
- if (result == null || !result.isOk() || result.getData() == null) {
- throw new I18nBizException(result == null ? ErrorCodes.HARD_DAILY_PRODUCT_NO_RESP : ErrorCodes.HARD_DAILY_PRODUCT_QUERY_FAIL);
- }
- Map productMap = result.getData().stream()
- .filter(Objects::nonNull)
- .filter(product -> product.getId() != null)
- .collect(Collectors.toMap(CanteenSpuEntity::getId, Function.identity(), (first, second) -> first));
- Map requiredStock = contexts.stream()
- .flatMap(context -> context.carList().stream())
- .collect(Collectors.toMap(HardCarEntity::getProductId,
- car -> Math.multiplyExact(car.getProductCount(), dinerCount),
- Math::addExact,
- LinkedHashMap::new));
+ Map inventorySnapshot = new LinkedHashMap<>();
for (DailyMenuReserveGroupContext context : contexts) {
+ LocalDate mealDate = context.reservation().apptDate();
+ Integer hourType = parseHourType(context.reservation().hourType());
+ if (mealDate == null || hourType == null) {
+ throw new I18nBizException(ErrorCodes.HARD_DAILY_MENU_PRODUCT_BAD);
+ }
+ R> menuResult = remoteDailyMenuService.getCanteenList(
+ mealDate.toString(), hourType, null, merchantId);
+ if (menuResult == null || !menuResult.isOk() || menuResult.getData() == null) {
+ throw new I18nBizException(menuResult == null ? ErrorCodes.HARD_DAILY_PRODUCT_NO_RESP
+ : ErrorCodes.HARD_DAILY_PRODUCT_QUERY_FAIL);
+ }
+ Map menuBySpu = menuResult.getData().stream()
+ .filter(Objects::nonNull)
+ .filter(row -> row.getSpuId() != null)
+ .collect(Collectors.toMap(DailyMenuAppVO::getSpuId, Function.identity(), (a, b) -> a));
+ Map required = context.carList().stream()
+ .collect(Collectors.toMap(HardCarEntity::getProductId,
+ car -> Math.multiplyExact(car.getProductCount(), dinerCount),
+ Math::addExact,
+ LinkedHashMap::new));
for (HardCarEntity car : context.carList()) {
- CanteenSpuEntity product = productMap.get(car.getProductId());
- if (product == null || !Objects.equals(product.getMerchantId(), merchantId)
- || !Objects.equals(product.getIsShow(), "0")) {
+ DailyMenuAppVO row = menuBySpu.get(car.getProductId());
+ if (row == null) {
throw new I18nBizException(ErrorCodes.HARD_DAILY_MENU_PRODUCT_BAD);
}
- if (product.getPrice() == null || product.getPrice() < 0) {
- throw new I18nBizException(ErrorCodes.HARD_PRODUCT_PRICE_BAD, product.getName());
+ if (row.getPrice() == null || row.getPrice() < 0) {
+ throw new I18nBizException(ErrorCodes.HARD_PRODUCT_PRICE_BAD, row.getName());
}
- int productType = product.getSpuType() == null ? 1 : product.getSpuType();
- if (productType != 1 && productType != 2) {
- throw new I18nBizException(ErrorCodes.HARD_PRODUCT_MODE_BAD, product.getName());
- }
- long currentTotalPrice = Math.multiplyExact((long) product.getPrice(), car.getProductCount().longValue());
- if (!Objects.equals(product.getPrice(), car.getProductPrice())
+ long currentTotalPrice = Math.multiplyExact((long) row.getPrice(), car.getProductCount().longValue());
+ if (!Objects.equals(row.getPrice(), car.getProductPrice())
|| !Objects.equals(currentTotalPrice, car.getTotalPrice())) {
- throw new I18nBizException(ErrorCodes.HARD_PRODUCT_PRICE_CHANGED, product.getName());
+ throw new I18nBizException(ErrorCodes.HARD_PRODUCT_PRICE_CHANGED, row.getName());
}
}
- }
- for (Map.Entry entry : requiredStock.entrySet()) {
- CanteenSpuEntity product = productMap.get(entry.getKey());
- if (product.getStock() == null || product.getStock() < entry.getValue()) {
- throw new I18nBizException(ErrorCodes.HARD_PRODUCT_STOCK_LOW, product.getName());
+ for (Map.Entry entry : required.entrySet()) {
+ DailyMenuAppVO row = menuBySpu.get(entry.getKey());
+ int onlineRemain = row.getOnlineStock() == null
+ ? (row.getStock() == null ? 0 : row.getStock())
+ : row.getOnlineStock();
+ if (onlineRemain < entry.getValue()) {
+ throw new I18nBizException(ErrorCodes.HARD_PRODUCT_STOCK_LOW, row.getName());
+ }
+ inventorySnapshot.put(dailyStockKey(mealDate, hourType, entry.getKey()), onlineRemain);
}
}
- return requiredStock.keySet().stream().collect(Collectors.toMap(Function.identity(),
- productId -> productMap.get(productId).getStock(), (first, second) -> first, LinkedHashMap::new));
+ return inventorySnapshot;
}
+ private static Integer parseHourType(String hourType) {
+ if (StrUtil.isBlank(hourType)) {
+ return null;
+ }
+ try {
+ return Integer.valueOf(hourType.trim());
+ }
+ catch (Exception e) {
+ return null;
+ }
+ }
+
+ private static String dailyStockKey(LocalDate mealDate, Integer hourType, Long spuId) {
+ return mealDate + "|" + hourType + "|" + spuId;
+ }
+
/**
* 商户开启单商品限制时,服务端按商品份数再次拦截每个日期餐段超过一份的购物车。
*
@@ -2719,36 +2742,34 @@ public class HardOrderServiceImpl extends ServiceImpl contexts, int dinerCount,
- boolean reduce, Map inventorySnapshot,
+ boolean reduce, Map inventorySnapshot,
String inventoryBizId) {
- // ROLLBACK 必须原样携带 APPLY 的负增量,由食堂服务负责取反,不能在调用端提前改成正数。
int factor = -dinerCount;
- Map stockChanges = contexts.stream()
- .flatMap(context -> context.carList().stream())
- .collect(Collectors.toMap(HardCarEntity::getProductId,
- car -> Math.multiplyExact(car.getProductCount(), factor),
- Math::addExact,
- LinkedHashMap::new));
+ Long merchantId = contexts.get(0).parentCar().getMerchantId();
+ List items = new ArrayList<>();
+ for (DailyMenuReserveGroupContext context : contexts) {
+ LocalDate mealDate = context.reservation().apptDate();
+ Integer hourType = parseHourType(context.reservation().hourType());
+ for (HardCarEntity car : context.carList()) {
+ CanteenSpuUpdateStockReqDTO.Item item = new CanteenSpuUpdateStockReqDTO.Item();
+ item.setId(car.getProductId());
+ item.setIncrCount(Math.multiplyExact(car.getProductCount(), factor));
+ item.setMealDate(mealDate);
+ item.setHourType(hourType);
+ item.setBeforeStock(inventorySnapshot.get(dailyStockKey(mealDate, hourType, car.getProductId())));
+ items.add(item);
+ }
+ }
CanteenSpuUpdateStockReqDTO stockDto = new CanteenSpuUpdateStockReqDTO();
stockDto.setBizId(inventoryBizId);
stockDto.setOperation(reduce ? "APPLY" : "ROLLBACK");
- stockDto.setItems(stockChanges.entrySet().stream().map(entry -> {
- CanteenSpuUpdateStockReqDTO.Item item = new CanteenSpuUpdateStockReqDTO.Item();
- item.setId(entry.getKey());
- item.setIncrCount(entry.getValue());
- item.setBeforeStock(inventorySnapshot.get(entry.getKey()));
- return item;
- }).toList());
+ stockDto.setStockChannel("ONLINE");
+ stockDto.setMerchantId(merchantId);
+ stockDto.setItems(items);
R> result = remoteCanteenSpuService.updateSkuStockIdempotent(stockDto);
if (result == null || !result.isOk()) {
throw new I18nBizException(reduce ? ErrorCodes.HARD_DAILY_STOCK_DEDUCT_FAIL : ErrorCodes.HARD_DAILY_STOCK_RESTORE_FAIL);
@@ -2782,7 +2803,7 @@ public class HardOrderServiceImpl extends ServiceImpl createdOrders,
List contexts,
int dinerCount,
- Map inventorySnapshot,
+ Map inventorySnapshot,
String inventoryBizId) {
if (StrUtil.isNotBlank(inventoryBizId)) {
try {
@@ -4619,36 +4640,71 @@ public class HardOrderServiceImpl extends ServiceImpl Math.multiplyExact(car.getProductCount(), factor),
Math::addExact,
LinkedHashMap::new));
- R> productResult = remoteCanteenSpuService
- .getInnerSpuListByIds(new ArrayList<>(stockChanges.keySet()));
- if (productResult == null || !productResult.isOk() || productResult.getData() == null) {
- if (productResult == null) {
- throw new I18nBizException(ErrorCodes.HARD_CANTEEN_STOCK_NO_RESP);
- }
- if (StrUtil.isNotBlank(productResult.getMsg())) {
- throw new RuntimeException(productResult.getMsg());
- }
- throw new I18nBizException(ErrorCodes.HARD_CANTEEN_STOCK_QUERY_FAIL);
- }
- Map productMap = productResult.getData().stream()
- .filter(Objects::nonNull)
- .filter(product -> product.getId() != null)
- .collect(Collectors.toMap(CanteenSpuEntity::getId, Function.identity(), (first, second) -> first));
- Map inventorySnapshot = new LinkedHashMap<>();
- for (Map.Entry entry : stockChanges.entrySet()) {
- CanteenSpuEntity product = productMap.get(entry.getKey());
- if (product == null || !Objects.equals(product.getMerchantId(), order.getMerchantId())) {
- throw new I18nBizException(ErrorCodes.HARD_CANTEEN_SPU_BAD);
- }
- int productType = product.getSpuType() == null ? 1 : product.getSpuType();
- if (productType != 1 && productType != 2) {
- throw new I18nBizException(ErrorCodes.HARD_PRODUCT_MODE_BAD, product.getName());
- }
- if (product.getStock() == null || entry.getValue() < 0 && product.getStock() < Math.abs(entry.getValue())) {
- throw new I18nBizException(ErrorCodes.HARD_PRODUCT_STOCK_LOW, product.getName());
- }
- inventorySnapshot.put(entry.getKey(), product.getStock());
- }
+
+ HardParentCarEntity parentCar = hardCarService.getParentCarById(order.getParentCarId());
+ boolean dailyMenu = isDailyMenuParentCar(parentCar);
+ LocalDate mealDate = order.getApptUseDate();
+ Integer hourType = parseHourType(order.getHourType());
+ Map inventorySnapshot = new LinkedHashMap<>();
+
+ if (dailyMenu) {
+ if (mealDate == null || hourType == null) {
+ throw new I18nBizException(ErrorCodes.HARD_CANTEEN_SPU_BAD);
+ }
+ R> menuResult = remoteDailyMenuService.getCanteenList(
+ mealDate.toString(), hourType, null, order.getMerchantId());
+ if (menuResult == null || !menuResult.isOk() || menuResult.getData() == null) {
+ throw new I18nBizException(menuResult == null ? ErrorCodes.HARD_CANTEEN_STOCK_NO_RESP
+ : ErrorCodes.HARD_CANTEEN_STOCK_QUERY_FAIL);
+ }
+ Map menuBySpu = menuResult.getData().stream()
+ .filter(Objects::nonNull)
+ .filter(row -> row.getSpuId() != null)
+ .collect(Collectors.toMap(DailyMenuAppVO::getSpuId, Function.identity(), (a, b) -> a));
+ for (Map.Entry entry : stockChanges.entrySet()) {
+ DailyMenuAppVO row = menuBySpu.get(entry.getKey());
+ if (row == null) {
+ throw new I18nBizException(ErrorCodes.HARD_CANTEEN_SPU_BAD);
+ }
+ int offlineRemain = row.getOfflineStock() != null ? row.getOfflineStock()
+ : (row.getStock() == null ? 0 : row.getStock());
+ if (entry.getValue() < 0 && offlineRemain < Math.abs(entry.getValue())) {
+ throw new I18nBizException(ErrorCodes.HARD_PRODUCT_STOCK_LOW, row.getName());
+ }
+ inventorySnapshot.put(entry.getKey(), offlineRemain);
+ }
+ }
+ else {
+ R> productResult = remoteCanteenSpuService
+ .getInnerSpuListByIds(new ArrayList<>(stockChanges.keySet()));
+ if (productResult == null || !productResult.isOk() || productResult.getData() == null) {
+ if (productResult == null) {
+ throw new I18nBizException(ErrorCodes.HARD_CANTEEN_STOCK_NO_RESP);
+ }
+ if (StrUtil.isNotBlank(productResult.getMsg())) {
+ throw new RuntimeException(productResult.getMsg());
+ }
+ throw new I18nBizException(ErrorCodes.HARD_CANTEEN_STOCK_QUERY_FAIL);
+ }
+ Map productMap = productResult.getData().stream()
+ .filter(Objects::nonNull)
+ .filter(product -> product.getId() != null)
+ .collect(Collectors.toMap(CanteenSpuEntity::getId, Function.identity(), (first, second) -> first));
+ for (Map.Entry entry : stockChanges.entrySet()) {
+ CanteenSpuEntity product = productMap.get(entry.getKey());
+ if (product == null || !Objects.equals(product.getMerchantId(), order.getMerchantId())) {
+ throw new I18nBizException(ErrorCodes.HARD_CANTEEN_SPU_BAD);
+ }
+ int productType = product.getSpuType() == null ? 1 : product.getSpuType();
+ if (productType != 1 && productType != 2) {
+ throw new I18nBizException(ErrorCodes.HARD_PRODUCT_MODE_BAD, product.getName());
+ }
+ if (product.getStock() == null || entry.getValue() < 0 && product.getStock() < Math.abs(entry.getValue())) {
+ throw new I18nBizException(ErrorCodes.HARD_PRODUCT_STOCK_LOW, product.getName());
+ }
+ inventorySnapshot.put(entry.getKey(), product.getStock());
+ }
+ }
if (validateOnly) return;
// 保留已上线的 DAILY_MENU 幂等号前缀,避免升级后对历史每日菜谱订单重复扣减。
@@ -4656,8 +4712,16 @@ public class HardOrderServiceImpl extends ServiceImpl stockChanges,
+ Map inventorySnapshot, String inventoryBizId, String operation, Long merchantId,
+ LocalDate mealDate, Integer hourType) {
+ CanteenSpuUpdateStockReqDTO dto = new CanteenSpuUpdateStockReqDTO();
+ dto.setBizId(inventoryBizId);
+ dto.setOperation(operation);
+ dto.setStockChannel("OFFLINE");
+ dto.setMerchantId(merchantId);
+ dto.setItems(stockChanges.entrySet().stream().map(entry -> {
+ CanteenSpuUpdateStockReqDTO.Item item = new CanteenSpuUpdateStockReqDTO.Item();
+ item.setId(entry.getKey());
+ item.setIncrCount(entry.getValue());
+ item.setBeforeStock(inventorySnapshot.get(entry.getKey()));
+ item.setMealDate(mealDate);
+ item.setHourType(hourType);
+ return item;
+ }).toList());
+ R> result = remoteCanteenSpuService.updateSkuStockIdempotent(dto);
+ if (result == null || !result.isOk()) {
+ if (result == null) {
+ throw new I18nBizException(ErrorCodes.HARD_CANTEEN_STOCK_NO_RESP);
+ }
+ if (StrUtil.isNotBlank(result.getMsg())) {
+ throw new RuntimeException(result.getMsg());
+ }
+ throw new I18nBizException(ErrorCodes.HARD_CANTEEN_STOCK_OP_FAIL);
+ }
+ }
+
+ private void registerCanteenDailyOfflineInventoryRollback(Map stockChanges,
+ Map inventorySnapshot, String inventoryBizId, Long merchantId, LocalDate mealDate,
+ Integer hourType) {
+ if (!TransactionSynchronizationManager.isSynchronizationActive()) {
+ throw new I18nBizException(ErrorCodes.HARD_STOCK_TX_STATE_BAD);
+ }
+ Map rollbackChanges = Map.copyOf(stockChanges);
+ Map rollbackSnapshot = Map.copyOf(inventorySnapshot);
+ TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+ @Override
+ public void afterCompletion(int status) {
+ if (status != TransactionSynchronization.STATUS_ROLLED_BACK) {
+ return;
+ }
+ try {
+ applyCanteenDailyOfflineInventoryChange(rollbackChanges, rollbackSnapshot, inventoryBizId,
+ "ROLLBACK", merchantId, mealDate, hourType);
+ }
+ catch (Exception compensationException) {
+ log.error("|{}|每日菜谱线下库存回滚失败,inventoryBizId:|{}|", TAG, inventoryBizId, compensationException);
+ }
+ }
+ });
+ }
+
@Override
@Transactional(rollbackFor = Exception.class)
public void fastenerInventory(HardOrderEntity order, YesNoEnum isReduce) {
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarMapper.xml b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarMapper.xml
index 78fe0bb98..9aceb91db 100644
--- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarMapper.xml
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/resources/mapper/order/HardCarMapper.xml
@@ -1422,4 +1422,68 @@
order by SUM(a.product_count) desc
limit 10;
+
+
+