每日库存
This commit is contained in:
1 parent
68d78d4b82
commit
26d3ec48a6
20 files changed
+951
-187
No files matched your search
+20
-12
@@ -7,15 +7,15 @@ import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 每日菜谱导入/导出 Excel 行(导出含菜谱 ID,导入时可按 ID 更新)
|
||||
* <p>列顺序:日期、餐段、套餐名、菜品名、分类、单价、套餐价、库存、配料、菜谱ID、菜品SPU ID、填写说明
|
||||
* <p>配料:单品写单品 SPU;套餐仅在首行填写,写入套餐 SPU
|
||||
* <p>列顺序:日期、餐段、套餐名、菜品名、分类、单价、套餐价、线上库存、线下库存、配料、菜谱ID、菜品SPU ID、填写说明
|
||||
* <p>线上/线下库存挂在每日菜谱行上,不再写入菜品列表 {@code canteen_spu.stock}。
|
||||
* <p>菜谱ID / 菜品SPU ID 必须用文本导出,否则 Excel 对超长雪花 ID 会丢精度导致再导入报「不存在」。
|
||||
* <p>无「菜品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;
|
||||
}
|
||||
+23
@@ -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;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
+12
@@ -49,6 +49,18 @@ public class CanteenDailyMenuEntity extends Model<CanteenDailyMenuEntity> {
|
||||
@Schema(description="归属时间 1早餐 2午餐 3晚餐")
|
||||
private Integer hourType;
|
||||
|
||||
/**
|
||||
* 线上可预约库存配额(人工设置;剩余=配额−线上净销量)
|
||||
*/
|
||||
@Schema(description = "线上可预约库存配额")
|
||||
private Integer onlineStock;
|
||||
|
||||
/**
|
||||
* 线下销售库存配额(人工设置;剩余对外字段名仍用 stock)
|
||||
*/
|
||||
@Schema(description = "线下销售库存配额")
|
||||
private Integer offlineStock;
|
||||
|
||||
/**
|
||||
* 商户id
|
||||
*/
|
||||
|
||||
+5
-2
@@ -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;
|
||||
|
||||
+17
-1
@@ -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;
|
||||
|
||||
+7
@@ -78,9 +78,16 @@ public class DailyMenuAppVO {
|
||||
|
||||
/**
|
||||
* 当前可售库存。
|
||||
* <p>App/移动端:线上剩余;安卓消费机 AD 接口:线下剩余。字段名保持 stock 兼容旧客户端。
|
||||
*/
|
||||
private Integer stock;
|
||||
|
||||
/** 线上配额(可选透出) */
|
||||
private Integer onlineStock;
|
||||
|
||||
/** 线下配额(可选透出) */
|
||||
private Integer offlineStock;
|
||||
|
||||
/**
|
||||
* 商品销量,仅用于详情页展示。
|
||||
*/
|
||||
|
||||
+13
-11
@@ -13,7 +13,7 @@ import org.apache.poi.ss.util.CellRangeAddress;
|
||||
|
||||
/**
|
||||
* 每日菜谱导入模板 / 导出:按数据动态合并日期、餐段、套餐名、套餐价、菜谱ID、说明。
|
||||
* <p>列:0日期 1餐段 2套餐名 3菜品名 4分类 5单价 6套餐价 7库存 8配料 9菜谱ID 10SPU ID 11填写说明
|
||||
* <p>列: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);
|
||||
}
|
||||
}
|
||||
+14
@@ -62,4 +62,18 @@ public interface CanteenDailyMenuMapper extends PjcloudBaseMapper<CanteenDailyMe
|
||||
*/
|
||||
List<DailyMenuAdminVO> 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);
|
||||
}
|
||||
+293
-51
@@ -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<CanteenDailyMenuImportExcel> templateRows() {
|
||||
List<CanteenDailyMenuImportExcel> 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时未改日的行会更新原日,已改日的行会在新日新增,极易只改到部分库存。
|
||||
* <ul>
|
||||
* <li>部分行仍是原日、部分行已改日 → 直接失败</li>
|
||||
* <li>全部改到新日但仍带着旧日菜谱ID → 直接失败(避免误以为在更新)</li>
|
||||
* </ul>
|
||||
* @return 失败时的 R;通过返回 null
|
||||
*/
|
||||
private R validateMenuIdDateConsistency(List<CanteenDailyMenuImportExcel> dataRows, Long merchantId) {
|
||||
Set<Long> menuIds = new HashSet<>();
|
||||
for (CanteenDailyMenuImportExcel row : dataRows) {
|
||||
Long id = parseExcelLong(row.getId());
|
||||
if (id != null) {
|
||||
menuIds.add(id);
|
||||
}
|
||||
}
|
||||
if (menuIds.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
List<CanteenDailyMenuEntity> menus = canteenDailyMenuService.listByIds(menuIds);
|
||||
Map<Long, CanteenDailyMenuEntity> 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<LocalDate> excelDates = new TreeSet<>();
|
||||
Set<LocalDate> originDates = new TreeSet<>();
|
||||
List<String> 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<LocalDate> 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<CanteenSpuEntity> children = new ArrayList<>();
|
||||
Integer comboPrice = null;
|
||||
Integer comboStock = first.getStock();
|
||||
Integer onlineStock = first.getOnlineStock();
|
||||
Integer offlineStock = first.getOfflineStock();
|
||||
Set<String> 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。
|
||||
* <p>先用 POI 把第 0 列日期强制写成 yyyy-MM-dd 文本(处理 WPS 日期序列/合并单元格),
|
||||
* 再交给 EasyExcel,避免同一天被解析成两个日期 Tab。
|
||||
*/
|
||||
private static List<CanteenDailyMenuImportExcel> readRowsStreaming(MultipartFile file) throws IOException {
|
||||
byte[] normalized;
|
||||
try (InputStream in = file.getInputStream()) {
|
||||
normalized = preprocessDateColumnToText(in);
|
||||
}
|
||||
List<CanteenDailyMenuImportExcel> rows = new ArrayList<>();
|
||||
EasyExcel.read(file.getInputStream(), CanteenDailyMenuImportExcel.class,
|
||||
EasyExcel.read(new ByteArrayInputStream(normalized), CanteenDailyMenuImportExcel.class,
|
||||
new ReadListener<CanteenDailyMenuImportExcel>() {
|
||||
@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<CellRangeAddress> merges = new ArrayList<>(sheet.getMergedRegions());
|
||||
List<Integer> 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<LocalDate> dates, Set<Long> 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<CanteenSpuEntity> 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<String> 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<String> clearedDayHour, ImportCache cache,
|
||||
List<CanteenDailyMenuEntity> pendingMenus) {
|
||||
List<CanteenDailyMenuEntity> pendingMenus, Integer onlineStock, Integer offlineStock) {
|
||||
String dh = date + "|" + hourType;
|
||||
if (replaceDay && !clearedDayHour.contains(dh)) {
|
||||
canteenDailyMenuService.remove(Wrappers.<CanteenDailyMenuEntity>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 {
|
||||
|
||||
/**
|
||||
* 解析/创建单品。
|
||||
* <p>有「菜品SPU ID」→ 按 ID 复用并按需更新;无 ID → 一律新建,不再按菜名复用(避免多日导入互相覆盖库存)。
|
||||
* <p>有「菜品SPU ID」→ 按 ID 复用并按需更新;无 ID → 一律新建,不再按菜名复用。
|
||||
* <p>{@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<String, Long> categoryMap, String name, String ingredients, Integer priceFen, Integer stock,
|
||||
@@ -1594,6 +1834,7 @@ public class CanteenDailyMenuImportHelper {
|
||||
*/
|
||||
private static void normalizeMergedCells(List<CanteenDailyMenuImportExcel> 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) {
|
||||
|
||||
+30
-1
@@ -68,6 +68,8 @@ public class CanteenDailyMenuServiceImpl extends ServiceImpl<CanteenDailyMenuMap
|
||||
|
||||
private final CanteenDailyMenuLockLogService dailyMenuLockLogService;
|
||||
|
||||
private final CanteenDailyMenuStockSupport dailyMenuStockSupport;
|
||||
|
||||
/**
|
||||
* 按逻辑删除后的当前数据批量校验日期、餐段和 SPU 组合,避免 App 页面缓存仍包含已下架菜品。
|
||||
* 基础 SPU 的全局上下架、归属和库存由 Hardware 下单服务继续按现有逻辑独立复核。
|
||||
@@ -218,6 +220,12 @@ public class CanteenDailyMenuServiceImpl extends ServiceImpl<CanteenDailyMenuMap
|
||||
entity.setHourType(hourType);
|
||||
entity.setHourTypes(null);
|
||||
entity.setSpuIds(null);
|
||||
if (entity.getOnlineStock() == null) {
|
||||
entity.setOnlineStock(CanteenDailyMenuStockSupport.DEFAULT_QUOTA);
|
||||
}
|
||||
if (entity.getOfflineStock() == null) {
|
||||
entity.setOfflineStock(CanteenDailyMenuStockSupport.DEFAULT_QUOTA);
|
||||
}
|
||||
insertList.add(entity);
|
||||
}
|
||||
}
|
||||
@@ -358,6 +366,15 @@ public class CanteenDailyMenuServiceImpl extends ServiceImpl<CanteenDailyMenuMap
|
||||
public List<DailyMenuAppVO> getCanteenMenuListScope(String day, Integer hourType, Long categoryId, Long merchantId) {
|
||||
List<DailyMenuAppVO> 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<CanteenDailyMenuMap
|
||||
}
|
||||
List<DailyMenuAppVO> list = baseMapper.getCanteenMenuListByDateRange(request, DataScope.of());
|
||||
fillDailyMenuAppCombo(list);
|
||||
dailyMenuStockSupport.fillAdOfflineRemainByRowDate(list, request.getMerchantId());
|
||||
return list;
|
||||
}
|
||||
|
||||
@@ -387,6 +405,8 @@ public class CanteenDailyMenuServiceImpl extends ServiceImpl<CanteenDailyMenuMap
|
||||
public Page selectTimeFoodPage(Page page, DailyMenuDTO dailyMenuDTO) {
|
||||
Page<DailyMenuAppVO> 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<CanteenDailyMenuMap
|
||||
DailyMenuAppVO detail = baseMapper.selectAppDetail(id);
|
||||
if (detail != null) {
|
||||
fillDailyMenuAppCombo(Collections.singletonList(detail));
|
||||
dailyMenuStockSupport.fillAppOnlineRemain(Collections.singletonList(detail), detail.getMerchantId(),
|
||||
detail.getStartTime());
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
@@ -416,8 +438,15 @@ public class CanteenDailyMenuServiceImpl extends ServiceImpl<CanteenDailyMenuMap
|
||||
if (row.getStartTime() != null) {
|
||||
row.setWeekdayLabel(weekdayLabel(row.getStartTime().getDayOfWeek()));
|
||||
}
|
||||
row.setRemainStock(row.getStock());
|
||||
}
|
||||
LocalDate mealDate = null;
|
||||
try {
|
||||
mealDate = time == null ? null : LocalDate.parse(time);
|
||||
}
|
||||
catch (Exception ignored) {
|
||||
mealDate = null;
|
||||
}
|
||||
dailyMenuStockSupport.fillAdminRemain(rows, merchantId, mealDate);
|
||||
return rows;
|
||||
}
|
||||
|
||||
|
||||
+139
@@ -82,6 +82,9 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
|
||||
if (updateStockReqDTO == null || CollUtil.isEmpty(updateStockReqDTO.getItems())) {
|
||||
return R.failed(MsgUtils.getMessage(ErrorCodes.CANTEEN_STOCK_ITEMS_EMPTY));
|
||||
}
|
||||
if (isDailyChannel(updateStockReqDTO.getStockChannel())) {
|
||||
return updateDailyMenuChannelStock(updateStockReqDTO);
|
||||
}
|
||||
// 必须先校验完整批次再更新,后一个商品失败时由本地事务保证前面的商品不会单独提交。
|
||||
for (CanteenSpuUpdateStockReqDTO.Item item : updateStockReqDTO.getItems()) {
|
||||
if (item == null || item.getId() == null || item.getIncrCount() == null
|
||||
@@ -128,6 +131,9 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
|
||||
|| item.getIncrCount() == null || item.getIncrCount() == 0 || item.getBeforeStock() == null)) {
|
||||
return R.failed(MsgUtils.getMessage(ErrorCodes.CANTEEN_STOCK_BIZ_PARAM_BAD));
|
||||
}
|
||||
if (isDailyChannel(dto.getStockChannel())) {
|
||||
return updateDailyMenuChannelStockIdempotent(dto, applyOperation, rollbackOperation);
|
||||
}
|
||||
if (dto.getItems().stream().map(CanteenSpuUpdateStockReqDTO.Item::getId).distinct().count() != dto.getItems().size()) {
|
||||
return R.failed(MsgUtils.getMessage(ErrorCodes.CANTEEN_STOCK_ITEM_DUP));
|
||||
}
|
||||
@@ -195,6 +201,139 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
private static boolean isDailyChannel(String stockChannel) {
|
||||
String ch = StrUtil.trim(stockChannel);
|
||||
return "ONLINE".equals(ch) || "OFFLINE".equals(ch);
|
||||
}
|
||||
|
||||
/** 非幂等分池扣减(安卓仍调 /ad/updateSkuStock,由硬件补 stockChannel)。 */
|
||||
private R updateDailyMenuChannelStock(CanteenSpuUpdateStockReqDTO dto) {
|
||||
if (dto.getMerchantId() == null) {
|
||||
return R.failed(MsgUtils.getMessage(ErrorCodes.CANTEEN_STOCK_BIZ_PARAM_BAD));
|
||||
}
|
||||
boolean offline = "OFFLINE".equals(StrUtil.trim(dto.getStockChannel()));
|
||||
for (CanteenSpuUpdateStockReqDTO.Item item : dto.getItems()) {
|
||||
if (item == null || item.getId() == null || item.getIncrCount() == null
|
||||
|| item.getIncrCount() == 0 || item.getIncrCount() == Integer.MIN_VALUE
|
||||
|| item.getMealDate() == null || item.getHourType() == null) {
|
||||
return R.failed(MsgUtils.getMessage(ErrorCodes.CANTEEN_STOCK_ITEM_PARAM_BAD));
|
||||
}
|
||||
CanteenDailyMenuEntity menu = findDailyMenuRow(dto.getMerchantId(), item.getMealDate(), item.getHourType(),
|
||||
item.getId());
|
||||
if (menu == null) {
|
||||
return R.failed(MsgUtils.getMessage(ErrorCodes.COMMON_PRODUCT_NOT_FOUND));
|
||||
}
|
||||
int current = offline
|
||||
? CanteenDailyMenuStockSupport.quotaOrDefault(menu.getOfflineStock())
|
||||
: CanteenDailyMenuStockSupport.quotaOrDefault(menu.getOnlineStock());
|
||||
if (item.getIncrCount() < 0 && Math.abs(item.getIncrCount()) > 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.<CanteenDailyMenuEntity>lambdaQuery()
|
||||
.eq(CanteenDailyMenuEntity::getMerchantId, merchantId)
|
||||
.eq(CanteenDailyMenuEntity::getStartTime, mealDate)
|
||||
.eq(CanteenDailyMenuEntity::getHourType, hourType)
|
||||
.eq(CanteenDailyMenuEntity::getSpuId, spuId)
|
||||
.last("limit 1"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入库存幂等状态,并在食堂本地数据库事务回滚时恢复原状态,防止出现“状态成功、库存未提交”。
|
||||
*
|
||||
|
||||
+47
-5
@@ -7,6 +7,8 @@
|
||||
<result property="startTime" column="start_time"/>
|
||||
<result property="spuId" column="spu_id"/>
|
||||
<result property="hourType" column="hour_type"/>
|
||||
<result property="onlineStock" column="online_stock"/>
|
||||
<result property="offlineStock" column="offline_stock"/>
|
||||
<result property="merchantId" column="merchant_id"/>
|
||||
<result property="deptId" column="dept_id"/>
|
||||
<result property="createBy" column="create_by"/>
|
||||
@@ -32,6 +34,8 @@
|
||||
<result property="introduction" column="introduction"/>
|
||||
<result property="description" column="description"/>
|
||||
<result property="stock" column="stock"/>
|
||||
<result property="onlineStock" column="online_stock"/>
|
||||
<result property="offlineStock" column="offline_stock"/>
|
||||
<result property="salesCount" column="sales_count"/>
|
||||
<result property="hourType" column="hour_type"/>
|
||||
<result property="sort" column="sort"/>
|
||||
@@ -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 @@
|
||||
</where>
|
||||
ORDER BY t.hour_type ASC, sp.sort ASC, t.id ASC
|
||||
</select>
|
||||
|
||||
<update id="updateOnlineStockDelta">
|
||||
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
|
||||
<if test="expected != null">
|
||||
AND online_stock = #{expected}
|
||||
</if>
|
||||
</update>
|
||||
|
||||
<update id="updateOfflineStockDelta">
|
||||
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
|
||||
<if test="expected != null">
|
||||
AND offline_stock = #{expected}
|
||||
</if>
|
||||
</update>
|
||||
</mapper>
|
||||
+8
@@ -59,4 +59,12 @@ public interface RemoteHardOrderService {
|
||||
@PostMapping("/hard-order/inner/pending-reservation-count")
|
||||
R<List<HardPendingReservationCountVo>> listPendingReservationCounts(@RequestBody List<Long> userIds);
|
||||
|
||||
/**
|
||||
* 每日菜谱净销量(已支付/已完成 − 完成售后),按用餐日+餐段+SPU+订单类型。
|
||||
*/
|
||||
@NoToken
|
||||
@PostMapping("/hard-order/inner/daily-menu-net-sold")
|
||||
R<List<com.bsd.pjcloud.hard.api.vo.order.DailyMenuNetSoldVO>> listDailyMenuNetSold(
|
||||
@RequestBody com.bsd.pjcloud.hard.api.dto.order.remote.DailyMenuNetSoldQueryDTO dto);
|
||||
|
||||
}
|
||||
+12
@@ -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<List<HardPendingReservationCountVo>> listPendingReservationCounts(@RequestBody List<Long> userIds) {
|
||||
return R.ok(hardwareOrderService.listPendingReservationCounts(userIds));
|
||||
}
|
||||
|
||||
/**
|
||||
* 食堂服务计算每日菜谱线上/线下剩余库存时调用。
|
||||
*/
|
||||
@Inner
|
||||
@Operation(summary = "每日菜谱净销量", description = "按用餐日+餐段+SPU+订单类型汇总净销量(已排除完成售后)")
|
||||
@PostMapping("/inner/daily-menu-net-sold")
|
||||
public R<List<DailyMenuNetSoldVO>> listDailyMenuNetSold(@RequestBody DailyMenuNetSoldQueryDTO dto) {
|
||||
return R.ok(hardwareOrderService.listDailyMenuNetSold(dto));
|
||||
}
|
||||
/* ************************************* end 远程调用 ************************************* */
|
||||
|
||||
/* ************************************* start app start ************************************* */
|
||||
|
||||
+6
@@ -167,4 +167,10 @@ public interface HardCarMapper extends PjcloudBaseMapper<HardCarEntity> {
|
||||
* 个人菜品喜好排行
|
||||
*/
|
||||
List<HardAppProductLikeTopVo> productLikeTop(@Param("dto") HardAppProductLikeTopDto dto);
|
||||
|
||||
/**
|
||||
* 每日菜谱:按用餐日+餐段+SPU+订单类型汇总净销量(已支付/已完成 − 完成售后)。
|
||||
*/
|
||||
List<com.bsd.pjcloud.hard.api.vo.order.DailyMenuNetSoldVO> listDailyMenuNetSold(
|
||||
@Param("dto") com.bsd.pjcloud.hard.api.dto.order.remote.DailyMenuNetSoldQueryDTO dto);
|
||||
}
|
||||
+6
@@ -60,6 +60,12 @@ public interface HardCarService extends IService<HardCarEntity> {
|
||||
*/
|
||||
List<Long> batchSaveDailyMenu(HardCarBatchSaveOrUpdateDto dto);
|
||||
|
||||
/**
|
||||
* 每日菜谱按日净销量(购买 − 完成售后),供食堂计算线上/线下剩余库存。
|
||||
*/
|
||||
List<com.bsd.pjcloud.hard.api.vo.order.DailyMenuNetSoldVO> listDailyMenuNetSold(
|
||||
com.bsd.pjcloud.hard.api.dto.order.remote.DailyMenuNetSoldQueryDTO dto);
|
||||
|
||||
/**
|
||||
* 创建购物车
|
||||
*/
|
||||
|
||||
+6
@@ -74,6 +74,12 @@ public interface HardOrderService extends IService<HardOrderEntity> {
|
||||
*/
|
||||
List<HardPendingReservationCountVo> listPendingReservationCounts(List<Long> userIds);
|
||||
|
||||
/**
|
||||
* 每日菜谱净销量(按用餐日),供食堂计算分池剩余库存。
|
||||
*/
|
||||
List<com.bsd.pjcloud.hard.api.vo.order.DailyMenuNetSoldVO> listDailyMenuNetSold(
|
||||
com.bsd.pjcloud.hard.api.dto.order.remote.DailyMenuNetSoldQueryDTO dto);
|
||||
|
||||
HardOrderVo join(Long id);
|
||||
|
||||
/**
|
||||
|
||||
+6
@@ -94,6 +94,12 @@ public class HardCarServiceImpl extends ServiceImpl<HardCarMapper, HardCarEntity
|
||||
return baseMapper.pageJoin(page, dto);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<com.bsd.pjcloud.hard.api.vo.order.DailyMenuNetSoldVO> listDailyMenuNetSold(
|
||||
com.bsd.pjcloud.hard.api.dto.order.remote.DailyMenuNetSoldQueryDTO dto) {
|
||||
return baseMapper.listDailyMenuNetSold(dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询当前用户在指定商户的普通购物车,并按服务端商品来源补充原有超市展示字段。
|
||||
*
|
||||
|
||||
+223
-104
@@ -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<HardOrderMapper, HardOrder
|
||||
}).toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<com.bsd.pjcloud.hard.api.vo.order.DailyMenuNetSoldVO> listDailyMenuNetSold(
|
||||
com.bsd.pjcloud.hard.api.dto.order.remote.DailyMenuNetSoldQueryDTO dto) {
|
||||
if (dto == null || dto.getMerchantId() == null || dto.getMealDate() == null) {
|
||||
return List.of();
|
||||
}
|
||||
List<com.bsd.pjcloud.hard.api.vo.order.DailyMenuNetSoldVO> rows = hardCarService.listDailyMenuNetSold(dto);
|
||||
return rows == null ? List.of() : rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 商户查询订单列表
|
||||
*/
|
||||
@@ -1566,7 +1577,7 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
|
||||
throw exception;
|
||||
}
|
||||
validateDailyMenuAvailability(merchantId, contexts);
|
||||
Map<Long, Integer> inventorySnapshot = validateDailyMenuCartProducts(merchantId, contexts, dinerUserIds.size());
|
||||
Map<String, Integer> inventorySnapshot = validateDailyMenuCartProducts(merchantId, contexts, dinerUserIds.size());
|
||||
validateDailyMenuSingleProductLimit(merchant, contexts);
|
||||
|
||||
List<SysUser> dinerUsers = Optional.ofNullable(userService.getInnerUserById(dinerUserIds))
|
||||
@@ -1660,14 +1671,14 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
|
||||
private void registerDailyMenuRollbackCompensation(List<HardOrderEntity> createdOrders,
|
||||
List<DailyMenuReserveGroupContext> contexts,
|
||||
int dinerCount,
|
||||
Map<Long, Integer> inventorySnapshot,
|
||||
Map<String, Integer> inventorySnapshot,
|
||||
String inventoryBizId) {
|
||||
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
throw new I18nBizException(ErrorCodes.HARD_RESERVE_TX_BAD);
|
||||
}
|
||||
List<HardOrderEntity> rollbackOrders = List.copyOf(createdOrders);
|
||||
List<DailyMenuReserveGroupContext> rollbackContexts = List.copyOf(contexts);
|
||||
Map<Long, Integer> rollbackInventory = Map.copyOf(inventorySnapshot);
|
||||
Map<String, Integer> rollbackInventory = Map.copyOf(inventorySnapshot);
|
||||
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||
/**
|
||||
* 本地事务明确回滚时补偿远程库存和钱包;状态未知时不主动反向操作,避免数据库实际已提交却误恢复。
|
||||
@@ -2166,67 +2177,79 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 {@code canteen_spu} 当前数据复核购物车商品归属、单品/套餐模式、价格和整批库存。
|
||||
* 套餐组成只用于展示和核销,库存按套餐 SPU 本身扣减,与单品使用同一聚合规则。
|
||||
* 使用每日菜谱线上库存复核整批预约:按「日期+餐段+SPU」独立校验,不再共用 canteen_spu.stock。
|
||||
*
|
||||
* @param merchantId 每日菜谱所属商户 ID
|
||||
* @param contexts 已加载的日期餐段购物车上下文
|
||||
* @param dinerCount 实际用餐人数,每个日期餐段商品数量都需要乘以该人数
|
||||
* @return 当前批次涉及的基础 SPU 操作前库存快照
|
||||
* @return key=date|hourType|spuId → 操作前线上库存快照
|
||||
*/
|
||||
private Map<Long, Integer> validateDailyMenuCartProducts(Long merchantId,
|
||||
private Map<String, Integer> validateDailyMenuCartProducts(Long merchantId,
|
||||
List<DailyMenuReserveGroupContext> contexts,
|
||||
int dinerCount) {
|
||||
List<Long> productIds = contexts.stream()
|
||||
.flatMap(context -> context.carList().stream())
|
||||
.map(HardCarEntity::getProductId)
|
||||
.distinct()
|
||||
.toList();
|
||||
R<List<CanteenSpuEntity>> 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<Long, CanteenSpuEntity> productMap = result.getData().stream()
|
||||
.filter(Objects::nonNull)
|
||||
.filter(product -> product.getId() != null)
|
||||
.collect(Collectors.toMap(CanteenSpuEntity::getId, Function.identity(), (first, second) -> first));
|
||||
Map<Long, Integer> requiredStock = contexts.stream()
|
||||
.flatMap(context -> context.carList().stream())
|
||||
.collect(Collectors.toMap(HardCarEntity::getProductId,
|
||||
car -> Math.multiplyExact(car.getProductCount(), dinerCount),
|
||||
Math::addExact,
|
||||
LinkedHashMap::new));
|
||||
Map<String, Integer> 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<List<DailyMenuAppVO>> 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<Long, DailyMenuAppVO> menuBySpu = menuResult.getData().stream()
|
||||
.filter(Objects::nonNull)
|
||||
.filter(row -> row.getSpuId() != null)
|
||||
.collect(Collectors.toMap(DailyMenuAppVO::getSpuId, Function.identity(), (a, b) -> a));
|
||||
Map<Long, Integer> 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<Long, Integer> 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<Long, Integer> 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<HardOrderMapper, HardOrder
|
||||
}
|
||||
|
||||
/**
|
||||
* 按每日菜谱批次聚合基础 SPU 库存增量,并调用食堂服务的幂等接口执行扣减或回滚。
|
||||
* APPLY 的负数增量表示下单扣库存,ROLLBACK 使用同一业务号把该批次反向恢复;单品和套餐不拆分组成明细。
|
||||
*
|
||||
* @param contexts 每日菜谱日期餐段上下文
|
||||
* @param dinerCount 实际用餐人数
|
||||
* @param reduce {@code true} 表示扣减,{@code false} 表示回滚
|
||||
* @param inventorySnapshot 本批次每个 SPU 的操作前库存快照
|
||||
* @param inventoryBizId 扣减与回滚共用的库存业务号
|
||||
* 按每日菜谱批次扣减/回滚「线上」库存(canteen_daily_menu.online_stock)。
|
||||
* 安卓机线下通道走 fastenerInventory → OFFLINE,互不影响。
|
||||
*/
|
||||
private void updateDailyMenuReserveInventory(List<DailyMenuReserveGroupContext> contexts, int dinerCount,
|
||||
boolean reduce, Map<Long, Integer> inventorySnapshot,
|
||||
boolean reduce, Map<String, Integer> inventorySnapshot,
|
||||
String inventoryBizId) {
|
||||
// ROLLBACK 必须原样携带 APPLY 的负增量,由食堂服务负责取反,不能在调用端提前改成正数。
|
||||
int factor = -dinerCount;
|
||||
Map<Long, Integer> 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<CanteenSpuUpdateStockReqDTO.Item> 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<HardOrderMapper, HardOrder
|
||||
private void compensateDailyMenuReserveBatch(List<HardOrderEntity> createdOrders,
|
||||
List<DailyMenuReserveGroupContext> contexts,
|
||||
int dinerCount,
|
||||
Map<Long, Integer> inventorySnapshot,
|
||||
Map<String, Integer> inventorySnapshot,
|
||||
String inventoryBizId) {
|
||||
if (StrUtil.isNotBlank(inventoryBizId)) {
|
||||
try {
|
||||
@@ -4619,36 +4640,71 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
|
||||
car -> Math.multiplyExact(car.getProductCount(), factor),
|
||||
Math::addExact,
|
||||
LinkedHashMap::new));
|
||||
R<List<CanteenSpuEntity>> 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<Long, CanteenSpuEntity> productMap = productResult.getData().stream()
|
||||
.filter(Objects::nonNull)
|
||||
.filter(product -> product.getId() != null)
|
||||
.collect(Collectors.toMap(CanteenSpuEntity::getId, Function.identity(), (first, second) -> first));
|
||||
Map<Long, Integer> inventorySnapshot = new LinkedHashMap<>();
|
||||
for (Map.Entry<Long, Integer> 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<Long, Integer> inventorySnapshot = new LinkedHashMap<>();
|
||||
|
||||
if (dailyMenu) {
|
||||
if (mealDate == null || hourType == null) {
|
||||
throw new I18nBizException(ErrorCodes.HARD_CANTEEN_SPU_BAD);
|
||||
}
|
||||
R<List<DailyMenuAppVO>> 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<Long, DailyMenuAppVO> 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<Long, Integer> 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<List<CanteenSpuEntity>> 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<Long, CanteenSpuEntity> 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<Long, Integer> 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<HardOrderMapper, HardOrder
|
||||
String resolvedInventoryBizId = StrUtil.isNotBlank(inventoryBizId) ? inventoryBizId : afterSaleId == null
|
||||
? "HARD_DAILY_MENU_ORDER:" + order.getId()
|
||||
: "HARD_DAILY_MENU_AFTER_SALE:" + afterSaleId;
|
||||
registerCanteenSpuInventoryRollback(stockChanges, inventorySnapshot, resolvedInventoryBizId);
|
||||
applyCanteenSpuInventoryChange(stockChanges, inventorySnapshot, resolvedInventoryBizId, "APPLY");
|
||||
if (dailyMenu) {
|
||||
registerCanteenDailyOfflineInventoryRollback(stockChanges, inventorySnapshot, resolvedInventoryBizId,
|
||||
order.getMerchantId(), mealDate, hourType);
|
||||
applyCanteenDailyOfflineInventoryChange(stockChanges, inventorySnapshot, resolvedInventoryBizId, "APPLY",
|
||||
order.getMerchantId(), mealDate, hourType);
|
||||
}
|
||||
else {
|
||||
registerCanteenSpuInventoryRollback(stockChanges, inventorySnapshot, resolvedInventoryBizId);
|
||||
applyCanteenSpuInventoryChange(stockChanges, inventorySnapshot, resolvedInventoryBizId, "APPLY");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -4730,6 +4794,61 @@ public class HardOrderServiceImpl extends ServiceImpl<HardOrderMapper, HardOrder
|
||||
}
|
||||
}
|
||||
|
||||
/** 安卓机/线下每日菜谱:仍调原 updateSkuStockIdempotent,补 OFFLINE 通道参数。 */
|
||||
private void applyCanteenDailyOfflineInventoryChange(Map<Long, Integer> stockChanges,
|
||||
Map<Long, Integer> 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<Long, Integer> stockChanges,
|
||||
Map<Long, Integer> inventorySnapshot, String inventoryBizId, Long merchantId, LocalDate mealDate,
|
||||
Integer hourType) {
|
||||
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
throw new I18nBizException(ErrorCodes.HARD_STOCK_TX_STATE_BAD);
|
||||
}
|
||||
Map<Long, Integer> rollbackChanges = Map.copyOf(stockChanges);
|
||||
Map<Long, Integer> 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) {
|
||||
|
||||
+64
@@ -1422,4 +1422,68 @@
|
||||
order by SUM(a.product_count) desc
|
||||
limit 10;
|
||||
</select>
|
||||
|
||||
<!--
|
||||
每日菜谱库存剩余:按用餐日 appt_use_date 统计净销量。
|
||||
线上=order_type 2,线下=order_type 1;扣硬售后 status=50 的份数;不含 XFJ。
|
||||
先按父购物车+商品汇总购买量,再扣退款,避免多行购物车重复扣退。
|
||||
-->
|
||||
<select id="listDailyMenuNetSold" resultType="com.bsd.pjcloud.hard.api.vo.order.DailyMenuNetSoldVO">
|
||||
select o.appt_use_date as mealDate,
|
||||
o.hour_type as hourType,
|
||||
car.product_id as productId,
|
||||
max(car.source_product_id) as dailyMenuId,
|
||||
o.order_type as orderType,
|
||||
greatest(sum(car.product_count) - ifnull(sum(refund_car.product_count), 0), 0) as netSold
|
||||
from hard_order o
|
||||
inner join hard_parent_car p on p.id = o.parent_car_id and p.del_flag = '0'
|
||||
inner join (
|
||||
select c.parent_car_id,
|
||||
c.product_id,
|
||||
max(c.source_product_id) as source_product_id,
|
||||
sum(ifnull(c.product_count, 0)) as product_count
|
||||
from hard_car c
|
||||
where c.del_flag = '0'
|
||||
group by c.parent_car_id, c.product_id
|
||||
) car on car.parent_car_id = p.id
|
||||
left join (
|
||||
select refund_item.parent_car_id,
|
||||
refund_item.product_id,
|
||||
sum(ifnull(refund_item.product_count, 0)) as product_count
|
||||
from hard_after_sale_car refund_item
|
||||
inner join hard_after_sale refund_order on refund_order.id = refund_item.refund_id
|
||||
and refund_order.del_flag = '0' and refund_order.status = '50'
|
||||
where refund_item.del_flag = '0'
|
||||
group by refund_item.parent_car_id, refund_item.product_id
|
||||
) refund_car on refund_car.parent_car_id = car.parent_car_id
|
||||
and refund_car.product_id = car.product_id
|
||||
where o.del_flag = '0'
|
||||
and o.merchant_type = '1'
|
||||
and o.merchant_id = #{dto.merchantId}
|
||||
and o.appt_use_date = #{dto.mealDate}
|
||||
and o.hour_type is not null
|
||||
and o.hour_type not in ('-1')
|
||||
and o.order_status in ('5', '6', '20')
|
||||
and o.order_type in ('1', '2')
|
||||
and p.product_source = 'CANTEEN_DAILY_MENU'
|
||||
<if test="dto.hourTypes != null and dto.hourTypes.size() != 0">
|
||||
and o.hour_type in
|
||||
<foreach collection="dto.hourTypes" item="v" open="(" close=")" separator=",">
|
||||
#{v}
|
||||
</foreach>
|
||||
</if>
|
||||
<if test="dto.productIds != null and dto.productIds.size() != 0">
|
||||
and car.product_id in
|
||||
<foreach collection="dto.productIds" item="v" open="(" close=")" separator=",">
|
||||
#{v}
|
||||
</foreach>
|
||||
</if>
|
||||
<if test="dto.orderTypes != null and dto.orderTypes.size() != 0">
|
||||
and o.order_type in
|
||||
<foreach collection="dto.orderTypes" item="v" open="(" close=")" separator=",">
|
||||
#{v}
|
||||
</foreach>
|
||||
</if>
|
||||
group by o.appt_use_date, o.hour_type, car.product_id, o.order_type
|
||||
</select>
|
||||
</mapper>
|
||||
Reference in new issue
Block a user