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时未改日的行会更新原日,已改日的行会在新日新增,极易只改到部分库存。 + *

+ * @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; + + +