This commit is contained in:
dzc committed 2026-10-09 09:28:25 +08:00
commit b4fa60a895
32 files changed
+1186 -241

No files matched your search

@@ -7,11 +7,9 @@ import java.math.BigDecimal;
/**
* 每日菜谱导入/导出 Excel 行(导出含菜谱 ID,导入时可按 ID 更新)
* <p>列顺序:日期、餐段、套餐名、菜品名、分类、单价、套餐价、线上库存、线下库存、配料、菜谱ID、菜品SPU ID、子SPU ID、填写说明
* <p>线上/线下库存挂在每日菜谱行上,不再写入菜品列表 {@code canteen_spu.stock}。
* <p>列顺序:日期、餐段、套餐名/别名、类型、菜品名、分类、单价、套餐价、总库存、移动端可预定数量、配料、菜谱ID、菜品SPU ID、子SPU ID、填写说明
* <p>库存:总库存→{@code offline_stock};移动端可预定数量→{@code online_stock};消费机可售=总库存−移动端已预订。
* <p>菜谱ID / 菜品SPU ID / 子SPU ID 必须用文本导出(带制表符前缀),否则 Excel 对超长雪花 ID 会丢精度。
* <p>业务列与 ID 列导出时每行写全,均不合并。
* <p>无「菜品SPU ID」时一律新建菜品/套餐;无「子SPU ID」时组成菜按菜名+配料回退或新建。
*/
@Data
public class CanteenDailyMenuImportExcel {
@@ -22,63 +20,70 @@ public class CanteenDailyMenuImportExcel {
@ExcelProperty(value = "餐段", index = 1)
private String mealName;
@ExcelProperty(value = "套餐名", index = 2)
/**
* 套餐:同一套餐标签(多行组成菜共用);单品:填「无」或本排菜别名。
*/
@ExcelProperty(value = "套餐名/别名", index = 2)
private String comboName;
@ExcelProperty(value = "菜品名", index = 3)
/**
* 排菜类型:套餐 / 单品。留空则自动识别。
*/
@ExcelProperty(value = "类型", index = 3)
private String menuType;
@ExcelProperty(value = "菜品名", index = 4)
private String dishName;
/** 分类名称;新建菜品时优先按此匹配,匹配不到再用默认分类 */
@ExcelProperty(value = "分类", index = 4)
@ExcelProperty(value = "分类", index = 5)
private String categoryName;
/** 菜品自身单价;不填默认 0 */
@ExcelProperty(value = "单价(元)", index = 5)
@ExcelProperty(value = "单价(元)", index = 6)
private BigDecimal dishPriceYuan;
/** 套餐售价(套餐首行填写);单品可留空 */
@ExcelProperty(value = "套餐价(元)", index = 6)
@ExcelProperty(value = "套餐价(元)", index = 7)
private BigDecimal priceYuan;
/**
* 当日线上可预约库存(挂每日菜谱)。单品填本行;套餐组成行建议每行都填相同值。
* 不填:默认 600。
* 总库存(入库 {@code offline_stock})。单品填本行;套餐组成行建议每行同值。不填默认 600。
*/
@ExcelProperty(value = "线上库存", index = 7)
private Integer onlineStock;
/**
* 当日线下销售库存(挂每日菜谱,安卓消费机读此池)。单品填本行;套餐组成行建议每行都填相同值。
* 不填:默认 600。
*/
@ExcelProperty(value = "线下库存", index = 8)
@ExcelProperty(value = "总库存", index = 8)
private Integer offlineStock;
@ExcelProperty(value = "配料", index = 9)
/**
* 移动端可预定数量上限(入库 {@code online_stock})。不填默认 600。
*/
@ExcelProperty(value = "移动端可预定数量", index = 9)
private Integer onlineStock;
@ExcelProperty(value = "配料", index = 10)
private String ingredients;
/**
* 每日菜谱记录 ID(文本)。导入时有则更新,无则新增。
* 勿改成数字格式,否则 Excel 会丢精度。
*/
@ExcelProperty(value = "菜谱ID", index = 10, converter = com.bsd.pjcloud.canteen.excel.ExcelSnowflakeIdConverter.class)
@ExcelProperty(value = "菜谱ID", index = 11, converter = com.bsd.pjcloud.canteen.excel.ExcelSnowflakeIdConverter.class)
private String id;
/**
* 关联菜品 SPU ID(文本)。套餐块各行填同一套餐本体 ID(导出可合并单元格)。
* 单品行=该单品 ID。导入:有则按 ID 更新;无则新建。
*/
@ExcelProperty(value = "菜品SPU ID", index = 11, converter = com.bsd.pjcloud.canteen.excel.ExcelSnowflakeIdConverter.class)
@ExcelProperty(value = "菜品SPU ID", index = 12, converter = com.bsd.pjcloud.canteen.excel.ExcelSnowflakeIdConverter.class)
private String spuId;
/**
* 套餐组成菜 SPU ID(文本,每行一个,不合并)。
* 有则按 ID 更新/复用组成菜;无则按「菜名+配料」回退或新建。单品行可留空。
*/
@ExcelProperty(value = "子SPU ID", index = 12, converter = com.bsd.pjcloud.canteen.excel.ExcelSnowflakeIdConverter.class)
@ExcelProperty(value = "子SPU ID", index = 13, converter = com.bsd.pjcloud.canteen.excel.ExcelSnowflakeIdConverter.class)
private String childSpuId;
/** 仅示例说明用,导入逻辑忽略 */
@ExcelProperty(value = "填写说明", index = 13)
@ExcelProperty(value = "填写说明", index = 14)
private String tip;
}
@@ -34,13 +34,13 @@ public class CanteenSpuUpdateStockReqDTO {
/**
* 每日菜谱分池:ONLINE=线上可预约;OFFLINE=线下消费机。
* 空则仍扣 {@code canteen_spu.stock}(兼容非每日菜谱)。
* 空则默认 OFFLINE(兼容未传通道的安卓消费机),扣每日菜谱线下销量。
*/
@Pattern(regexp = "ONLINE|OFFLINE", message = "库存通道错误")
private String stockChannel;
/**
* 每日菜谱所属商户;stockChannel 非空时必填。
* 每日菜谱所属商户;分池扣减时建议传,空则从 SPU 回填。
*/
private Long merchantId;
@@ -54,9 +54,9 @@ public class CanteenCategoryEntity extends Model<CanteenCategoryEntity> {
private String picUrl;
/**
* 分类排序
* 分类序号(越大越靠前)
*/
@Schema(description="分类排序")
@Schema(description="分类序号,越大越靠前")
private Integer sort;
/**
@@ -43,6 +43,12 @@ public class CanteenDailyMenuEntity extends Model<CanteenDailyMenuEntity> {
@Schema(description="商品 SPU 编号")
private Long spuId;
/**
* 本排菜展示名:套餐名或单品别名(仅挂菜谱行,不写回菜品)
*/
@Schema(description = "套餐名或单品别名")
private String displayName;
/**
* 归属时间 1早餐 2午餐 3晚餐
*/
@@ -50,15 +56,15 @@ public class CanteenDailyMenuEntity extends Model<CanteenDailyMenuEntity> {
private Integer hourType;
/**
* 线上可预约库存配额(人工设置;下单不改;剩余=配额−onlineSold)
* 移动端可预订数量上限(人工;下单不改)
*/
@Schema(description = "线上可预约库存配额")
@Schema(description = "移动端可预订数量上限")
private Integer onlineStock;
/**
* 线下销售库存配额(人工设置;下单不改;剩余=配额−offlineSold)
* 总库存(人工;下单不改)。消费机可售 = 总库存 − 移动端已预订。
*/
@Schema(description = "线下销售库存配额")
@Schema(description = "总库存")
private Integer offlineStock;
/**
@@ -46,7 +46,7 @@ public class CanteenSpuEntity extends Model<CanteenSpuEntity> {
private String nameEn;
/**
* 套餐名(仅 spuType=2 时使用)
* 套餐名(仅 spuType=2 套餐本体;每日排菜展示名见 canteen_daily_menu.display_name)
*/
@Schema(description = "套餐名")
private String comboName;
@@ -44,7 +44,7 @@ public class CanteenDailyMenuImportPreviewVO {
@Schema(description = "餐段")
private String mealName;
@Schema(description = "套餐名")
@Schema(description = "套餐名/别名")
private String comboName;
@Schema(description = "菜品名")
@@ -59,10 +59,10 @@ public class CanteenDailyMenuImportPreviewVO {
@Schema(description = "套餐价(元)")
private BigDecimal priceYuan;
@Schema(description = "线上库存(每日)")
@Schema(description = "移动端可预定数量")
private Integer onlineStock;
@Schema(description = "线下库存(每日)")
@Schema(description = "总库存")
private Integer offlineStock;
@Schema(description = "配料")
@@ -77,6 +77,9 @@ public class CanteenDailyMenuImportPreviewVO {
@Schema(description = "子SPU ID(套餐组成菜)")
private String childSpuId;
@Schema(description = "类型:套餐/单品,空则自动识别")
private String menuType;
/**
* 排菜分组键:同一套餐多行组成菜共用,前端按此统计「一道菜」而不是 Excel 行数。
*/
@@ -27,9 +27,14 @@ public class DailyMenuAdminVO {
private String name;
/** 套餐名(canteen_spu.combo_name),导出 Excel「套餐名」列优先 */
/**
* 展示名:优先排菜 display_name,空则回落菜品 combo_name(列表/App 展示)
*/
private String comboName;
/** 排菜 display_name 原值(导出单品「套餐名/别名」列用,不含菜品回落) */
private String displayName;
/** 套餐标签兜底(canteen_spu.introduction),combo_name 为空时导出用 */
private String introduction;
@@ -44,31 +49,36 @@ public class DailyMenuAdminVO {
private Integer sort;
/** 线下库存配额(人工) */
/**
* 编辑提交时传「总库存」写入库 {@code offline_stock}(列表不再单独展示消费机可销售数量)。
*/
private Integer offlineStock;
/** 线上可预约库存配额(人工) */
/** 移动端可预订数量上限(库 {@code online_stock}) */
private Integer onlineStock;
/** 总库存(库 {@code offline_stock}) */
private Integer totalStock;
/**
* 兼容旧字段:对安卓/列表默认展示「线下剩余」。
* 管理端同时看 remainOnlineStock / remainOfflineStock。
*/
private Integer stock;
/** 线上净销量(仅服务端填剩余用,可不展示) */
/** 移动端已订数量(管理端列表展示) */
private Integer onlineSold;
/** 线下净销量(仅服务端填剩余用,可不展示) */
/** 消费机已售净销量(管理端列表展示) */
private Integer offlineSold;
/** 线上剩余 = onlineStock − 预约净销量 */
/** 移动端可预订剩余(服务端计算用,管理端列表改展示 onlineSold) */
private Integer remainOnlineStock;
/** 线下剩余 = offlineStock − 线下净销量 */
/** 剩余总库存(与 remainStock 同义,兼容字段) */
private Integer remainOfflineStock;
/** 剩余合计(线上剩余+线下剩余),兼容旧列 remainStock */
/** 剩余总库存 */
private Integer remainStock;
private Integer price;
@@ -54,7 +54,7 @@ public class DailyMenuVO {
private Integer spuType;
/**
* 套餐名(canteen_spu.combo_name)
* 套餐名或单品别名(canteen_spu.combo_name)
*/
private String comboName;
@@ -27,7 +27,7 @@ public class SpuPrintNameVo {
private Long productId;
/**
* 套餐名(仅 spuType=2 套餐有值),打印凭证优先展示。
* 套餐名(canteen_spu.combo_name);每日排菜别名见 display_name,打印若需排菜名需另查菜谱。
*/
@Schema(description = "套餐名")
private String comboName;
@@ -93,26 +93,26 @@ public class DailyMenuAppVO {
/**
* 当前可售库存。
* <p>App/移动端:线上剩余;安卓消费机 AD 接口:线下剩余。字段名保持 stock 兼容旧客户端。
* <p>App/移动端:移动端可预订剩余;AD:总库存 − 移动端已预订。字段名保持 stock 兼容旧客户端。
*/
private Integer stock;
/** 线上配额(人工;下单不改) */
/** 移动端可预订上限(库 online_stock) */
private Integer onlineStock;
/** 线下配额(人工;下单不改) */
/** 总库存(库 offline_stock) */
private Integer offlineStock;
/** 线上净销量(服务端计算剩余用) */
/** 移动端已预订净销量 */
private Integer onlineSold;
/** 线下净销量(服务端计算剩余用) */
/** 消费机已售净销量 */
private Integer offlineSold;
/** 线上剩余 = onlineStock − onlineSold */
/** 移动端可预订剩余 */
private Integer remainOnlineStock;
/** 线下剩余 = offlineStock − offlineSold */
/** App:剩余总库存;AD:与 stock 同为 总库存−移动端已预订 */
private Integer remainOfflineStock;
/**
@@ -55,6 +55,8 @@ public class CanteenCategoryController {
LambdaQueryWrapper<CanteenCategoryEntity> wrapper = Wrappers.lambdaQuery();
wrapper.like(StrUtil.isNotBlank(canteenCategory.getName()), CanteenCategoryEntity::getName, canteenCategory.getName());
merchantScopeHelper.applyMerchantIdScope(wrapper, canteenCategory.getMerchantId(), CanteenCategoryEntity::getMerchantId);
// 分类序号越大越靠前
wrapper.orderByDesc(CanteenCategoryEntity::getSort).orderByDesc(CanteenCategoryEntity::getId);
return R.ok(canteenCategoryService.selectPageByScope(page, wrapper));
}
@@ -136,7 +138,10 @@ public class CanteenCategoryController {
@GetMapping("/export")
@PreAuthorize("@pms.hasPermission('canteen_canteenCategory_export')")
public List<CanteenCategoryEntity> export(CanteenCategoryEntity canteenCategory, Long[] ids) {
return canteenCategoryService.list(Wrappers.lambdaQuery(canteenCategory).in(ArrayUtil.isNotEmpty(ids), CanteenCategoryEntity::getId, ids));
return canteenCategoryService.list(Wrappers.lambdaQuery(canteenCategory)
.in(ArrayUtil.isNotEmpty(ids), CanteenCategoryEntity::getId, ids)
.orderByDesc(CanteenCategoryEntity::getSort)
.orderByDesc(CanteenCategoryEntity::getId));
}
@@ -159,7 +164,8 @@ public class CanteenCategoryController {
LambdaQueryWrapper<CanteenCategoryEntity> wrapper = Wrappers.lambdaQuery();
wrapper.like(StringUtils.isNotBlank(name), CanteenCategoryEntity::getName, name);
merchantScopeHelper.applyMerchantIdScope(wrapper, merchantId, CanteenCategoryEntity::getMerchantId);
wrapper.orderByAsc(CanteenCategoryEntity::getSort);
// 分类序号越大越靠前
wrapper.orderByDesc(CanteenCategoryEntity::getSort).orderByDesc(CanteenCategoryEntity::getId);
wrapper.apply(StringUtils.isNotEmpty(categoryType), "FIND_IN_SET({0}, category_type)", categoryType);
List<CanteenCategoryEntity> list = canteenCategoryService.listScope(wrapper);
return R.ok(list);
@@ -56,6 +56,8 @@ public class CanteenOnlineCategoryController {
LambdaQueryWrapper<CanteenOnlineCategoryEntity> wrapper = Wrappers.lambdaQuery();
wrapper.like(StrUtil.isNotBlank(canteenOnlineCategory.getName()),CanteenOnlineCategoryEntity::getName,canteenOnlineCategory.getName());
merchantScopeHelper.applyMerchantIdScope(wrapper, canteenOnlineCategory.getMerchantId(), CanteenOnlineCategoryEntity::getMerchantId);
// 分类序号越大越靠前
wrapper.orderByDesc(CanteenOnlineCategoryEntity::getSort).orderByDesc(CanteenOnlineCategoryEntity::getId);
return R.ok(canteenOnlineCategoryService.pageScope(page, wrapper));
}
@@ -153,7 +155,8 @@ public class CanteenOnlineCategoryController {
@GetMapping("/app/getAllSimple")
public R getAllSimple(@RequestParam(value = "merchantId")Long merchantId) {
LambdaQueryWrapper<CanteenOnlineCategoryEntity> wrapper = Wrappers.lambdaQuery();
wrapper.orderByAsc(CanteenOnlineCategoryEntity::getSort);
// 分类序号越大越靠前
wrapper.orderByDesc(CanteenOnlineCategoryEntity::getSort).orderByDesc(CanteenOnlineCategoryEntity::getId);
wrapper.eq(CanteenOnlineCategoryEntity::getMerchantId,merchantId);
// wrapper.apply(StringUtils.isNotEmpty(categoryType),"FIND_IN_SET({0}, your_column_name)",categoryType);
List<CanteenOnlineCategoryEntity> list = canteenOnlineCategoryService.list(wrapper);
@@ -12,8 +12,8 @@ import org.apache.poi.ss.usermodel.VerticalAlignment;
/**
* 每日菜谱导入模板 / 导出写后处理。
* <p>列:0日期 1餐段 2套餐名 3菜品名 4分类 5单价 6套餐价 7线上库存 8线下库存 9配料
* 10菜谱ID 11菜品SPU ID 12子SPU ID 13填写说明
* <p>列:0日期 1餐段 2套餐名/别名 3类型 4菜品名 5分类 6单价 7套餐价 8总库存 9移动端可预定数量 10配料
* 11菜谱ID 12菜品SPU ID 13子SPU ID 14填写说明
* <p>业务列每行写全、不合并。雪花 ID 三列也<strong>每行写全、不合并</strong>
* (合并会导致 EasyExcel 读空;Excel 把 19 位 ID 当数字会丢精度并串号)。
* <p>须配合 {@code @ResponseExcel(inMemory = true)},否则 SXSSF 刷盘后改单元格会失败。
@@ -23,12 +23,14 @@ public class CanteenDailyMenuTemplateMergeHandler implements WorkbookWriteHandle
private static final int COL_DATE = 0;
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_ONLINE_STOCK = 7;
private static final int COL_OFFLINE_STOCK = 8;
private static final int COL_ID = 10;
private static final int COL_SPU = 11;
private static final int COL_CHILD_SPU = 12;
private static final int COL_COMBO_PRICE = 7;
/** 总库存(Excel index 8 → offlineStock 字段) */
private static final int COL_TOTAL_STOCK = 8;
/** 移动端可预定数量(Excel index 9 → onlineStock 字段) */
private static final int COL_MOBILE_QUOTA = 9;
private static final int COL_ID = 11;
private static final int COL_SPU = 12;
private static final int COL_CHILD_SPU = 13;
/** 写入 ID 前缀,迫使 Excel/WPS 保持文本,避免 19 位雪花被当数字丢精度 */
public static final String ID_TEXT_PREFIX = "\t";
@@ -82,8 +84,8 @@ public class CanteenDailyMenuTemplateMergeHandler implements WorkbookWriteHandle
String spuId = cellText(sheet, r, COL_SPU);
String childSpuId = cellText(sheet, r, COL_CHILD_SPU);
String comboPrice = cellText(sheet, r, COL_COMBO_PRICE);
String online = cellText(sheet, r, COL_ONLINE_STOCK);
String offline = cellText(sheet, r, COL_OFFLINE_STOCK);
String online = cellText(sheet, r, COL_TOTAL_STOCK);
String offline = cellText(sheet, r, COL_MOBILE_QUOTA);
if (isBlank(date)) {
date = lastDate;
@@ -194,8 +196,8 @@ public class CanteenDailyMenuTemplateMergeHandler implements WorkbookWriteHandle
writeText(sheet, r, COL_COMBO, combo, center);
if (inCombo && !isSingleCombo(combo)) {
writeText(sheet, r, COL_COMBO_PRICE, comboPrice, center);
writeText(sheet, r, COL_ONLINE_STOCK, online, center);
writeText(sheet, r, COL_OFFLINE_STOCK, offline, center);
writeText(sheet, r, COL_TOTAL_STOCK, online, center);
writeText(sheet, r, COL_MOBILE_QUOTA, offline, center);
}
writeIdText(sheet, r, COL_ID, menuId, idTextStyle);
writeIdText(sheet, r, COL_SPU, spuId, idTextStyle);
@@ -332,17 +334,18 @@ public class CanteenDailyMenuTemplateMergeHandler implements WorkbookWriteHandle
private static void setColumnWidths(Sheet sheet) {
sheet.setColumnWidth(0, 12 * 256);
sheet.setColumnWidth(1, 8 * 256);
sheet.setColumnWidth(2, 10 * 256);
sheet.setColumnWidth(3, 16 * 256);
sheet.setColumnWidth(4, 12 * 256);
sheet.setColumnWidth(2, 12 * 256);
sheet.setColumnWidth(3, 8 * 256);
sheet.setColumnWidth(4, 16 * 256);
sheet.setColumnWidth(5, 12 * 256);
sheet.setColumnWidth(6, 12 * 256);
sheet.setColumnWidth(7, 10 * 256);
sheet.setColumnWidth(7, 12 * 256);
sheet.setColumnWidth(8, 10 * 256);
sheet.setColumnWidth(9, 22 * 256);
sheet.setColumnWidth(9, 10 * 256);
sheet.setColumnWidth(10, 22 * 256);
sheet.setColumnWidth(11, 22 * 256);
sheet.setColumnWidth(12, 22 * 256);
sheet.setColumnWidth(13, 48 * 256);
sheet.setColumnWidth(13, 22 * 256);
sheet.setColumnWidth(14, 48 * 256);
}
}
@@ -97,26 +97,28 @@ public class CanteenDailyMenuImportHelper {
public List<CanteenDailyMenuImportExcel> templateRows() {
List<CanteenDailyMenuImportExcel> list = new ArrayList<>();
// 业务列与雪花 ID 均每行写全(文本),避免 Excel 数字精度串号
list.add(row("2026-08-17", "午餐", "A套餐", "糖醋排骨", "荤菜", "青莲仔排", "12", "21", "80", "100",
"套餐多行:日期/餐段/套餐名/套餐价/库存/菜谱ID 每行都填;菜品SPU ID=套餐本体;子SPU ID=本行组成菜;ID列必须为文本"));
list.add(row("2026-08-17", "午餐", "A套餐", "蒜苔炒腊肉", "荤菜", null, "8", "21", "80", "100", null));
list.add(row("2026-08-17", "午餐", "A套餐", "清炒时蔬", "素菜", null, "3", "21", "80", "100", null));
list.add(row("2026-08-17", "午餐", "B套餐", "红烧肉", "荤菜", "五花肉、土豆", "10", "18", "60", "80",
"换套餐名即另一套;无菜谱ID时新建;有菜谱ID/套餐SPU则更新;打开Excel后勿把ID列改成数字"));
list.add(row("2026-08-17", "午餐", "B套餐", "土豆丝", "素菜", null, "4", "18", "60", "80", null));
list.add(row("2026-08-17", "午餐", "无", "千张大骨汤", "汤类", null, "0", null, "50", "50",
"单品:套餐名填「无」;菜品SPU ID=本行单品;子SPU ID可空"));
list.add(row("2026-08-17", "早餐", "单品-1", "榨菜肉丝面", "面食", "榨菜,肉丝", "6.4", null, "30", "40",
"单品-xxx / 单点-xxx 一律按单品,套餐价可留空;不填库存则默认600"));
list.add(row("2026-08-17", "早餐", "单点-2", "豆浆", "饮品", null, "2", null, "100", "100", null));
list.add(row("2026-08-18", "午餐,晚餐", "无", "番茄蛋汤", "汤类", null, "2", null, "80", "80",
"多餐拆行:无菜品SPU ID时各餐段各自新建菜品;每日线上/线下库存各自独立"));
// 库存列:总库存、移动端可预定数量(消费机可售=总库存−移动端已预订,不单独导入)
list.add(row("2026-08-17", "午餐", "A套餐", "糖醋排骨", "荤菜", "青莲仔排", "12", "21", "100", "80", "套餐",
"总库存+移动端可预定;类型填套餐/单品;菜品SPU ID=套餐本体;子SPU ID=组成菜"));
list.add(row("2026-08-17", "午餐", "A套餐", "蒜苔炒腊肉", "荤菜", null, "8", "21", "100", "80", "套餐", null));
list.add(row("2026-08-17", "午餐", "A套餐", "清炒时蔬", "素菜", null, "3", "21", "100", "80", "套餐", null));
list.add(row("2026-08-17", "午餐", "B套餐", "红烧肉", "荤菜", "五花肉、土豆", "10", "18", "80", "60", "套餐",
"换套餐名即另一套;无菜谱ID时新建;有菜谱ID/套餐SPU则更新"));
list.add(row("2026-08-17", "午餐", "B套餐", "土豆丝", "素菜", null, "4", "18", "80", "60", "套餐", null));
list.add(row("2026-08-17", "午餐", "招牌汤", "千张大骨汤", "汤类", null, "0", null, "50", "50", "单品",
"单品:类型=单品;套餐名/别名可填别名或「无」;勿填套餐价"));
list.add(row("2026-08-17", "早餐", "单品-1", "榨菜肉丝面", "面食", "榨菜,肉丝", "6.4", null, "40", "30", "单品",
"不填库存则总库存/移动端可预定默认各600"));
list.add(row("2026-08-17", "早餐", "单点-2", "豆浆", "饮品", null, "2", null, "100", "100", "单品", null));
list.add(row("2026-08-18", "午餐,晚餐", "无", "番茄蛋汤", "汤类", null, "2", null, "80", "80", "单品",
"多餐拆行:无菜品SPU ID时各餐段各自新建;线上未订完可线下继续卖"));
return list;
}
/** @param totalStock 总库存;@param mobileQuota 移动端可预定数量 */
private static CanteenDailyMenuImportExcel row(String date, String meal, String combo, String dish,
String categoryName, String ingredients, String dishPrice, String comboPrice, String onlineStock,
String offlineStock, String tip) {
String categoryName, String ingredients, String dishPrice, String comboPrice, String totalStock,
String mobileQuota, String menuType, String tip) {
CanteenDailyMenuImportExcel r = new CanteenDailyMenuImportExcel();
r.setBizDate(date);
r.setMealName(meal);
@@ -130,12 +132,13 @@ public class CanteenDailyMenuImportHelper {
if (StrUtil.isNotBlank(comboPrice)) {
r.setPriceYuan(new BigDecimal(comboPrice));
}
if (StrUtil.isNotBlank(onlineStock)) {
r.setOnlineStock(Integer.valueOf(onlineStock));
if (StrUtil.isNotBlank(totalStock)) {
r.setOfflineStock(Integer.valueOf(totalStock));
}
if (StrUtil.isNotBlank(offlineStock)) {
r.setOfflineStock(Integer.valueOf(offlineStock));
if (StrUtil.isNotBlank(mobileQuota)) {
r.setOnlineStock(Integer.valueOf(mobileQuota));
}
r.setMenuType(menuType);
r.setTip(tip);
return r;
}
@@ -212,8 +215,9 @@ public class CanteenDailyMenuImportHelper {
row.setCategoryName(menu.getCategoryName());
row.setDishName(StrUtil.blankToDefault(item.getName(), ""));
row.setDishPriceYuan(fenToYuan(item.getPrice()));
row.setMenuType("套餐");
if (written == 0) {
row.setTip("套餐块:同套餐名多行=一道排菜;菜谱ID/菜品SPU ID 每行都填(文本),勿改成数字");
row.setTip("套餐块:类型=套餐;同套餐名多行=一道排菜;菜谱ID/菜品SPU ID 每行都填(文本)");
}
rows.add(row);
written++;
@@ -225,13 +229,15 @@ public class CanteenDailyMenuImportHelper {
row.setSpuId(menu.getSpuId() == null ? null : String.valueOf(menu.getSpuId()));
row.setBizDate(dateStr);
row.setMealName(meal);
row.setComboName("无");
// 单品:有排菜别名则写入本列,否则「无」
row.setComboName(StrUtil.blankToDefault(StrUtil.trim(menu.getDisplayName()), "无"));
row.setDishName(menu.getName());
row.setCategoryName(menu.getCategoryName());
row.setDishPriceYuan(fenToYuan(menu.getPrice()));
row.setOnlineStock(menu.getOnlineStock());
row.setOfflineStock(menu.getOfflineStock());
row.setIngredients(toExcelIngredients(menu.getIngredients()));
row.setMenuType("单品");
rows.add(row);
}
return rows;
@@ -611,16 +617,7 @@ public class CanteenDailyMenuImportHelper {
if (menuId != null) {
excelMenuIds.add(menuId);
}
String groupKey;
if (menuId != null) {
groupKey = "id:" + menuId + "|" + date + "|" + hourType;
}
else if (StrUtil.isNotBlank(comboKey) && !isSingleComboMarker(comboKey)) {
groupKey = "new:" + date + "|" + hourType + "|" + comboKey;
}
else {
groupKey = "new:" + date + "|" + hourType + "|" + dish;
}
String groupKey = resolveImportGroupKey(menuId, date, hourType, comboKey, dish, row);
groups.computeIfAbsent(groupKey, k -> new ArrayList<>()).add(row);
}
if (CollUtil.isNotEmpty(errors)) {
@@ -727,6 +724,7 @@ public class CanteenDailyMenuImportHelper {
previewRow.setId(StrUtil.trim(item.getId()));
previewRow.setSpuId(StrUtil.trim(item.getSpuId()));
previewRow.setChildSpuId(StrUtil.trim(item.getChildSpuId()));
previewRow.setMenuType(resolvePreviewMenuType(items, item, cache));
previewRow.setGroupKey(groupKey);
previewRow.setAction(action);
previewRow.setActionLabel(actionLabel);
@@ -784,16 +782,7 @@ public class CanteenDailyMenuImportHelper {
if (menuId != null) {
excelMenuIds.add(menuId);
}
String groupKey;
if (menuId != null) {
groupKey = "id:" + menuId + "|" + date + "|" + hourType;
}
else if (StrUtil.isNotBlank(comboKey) && !isSingleComboMarker(comboKey)) {
groupKey = "new:" + date + "|" + hourType + "|" + comboKey;
}
else {
groupKey = "new:" + date + "|" + hourType + "|" + dish;
}
String groupKey = resolveImportGroupKey(menuId, date, hourType, comboKey, dish, row);
groups.computeIfAbsent(groupKey, k -> new ArrayList<>()).add(row);
}
if (CollUtil.isNotEmpty(errors)) {
@@ -887,7 +876,7 @@ public class CanteenDailyMenuImportHelper {
Integer hourType = groupHour;
String comboName = StrUtil.trim(first.getComboName());
if (StrUtil.isBlank(comboName) || isSingleComboMarker(comboName)) {
if (!isComboImportGroup(items, comboName, cache)) {
for (CanteenDailyMenuImportExcel item : items) {
// 每日线上/线下库存挂菜谱行,不再写入 canteen_spu.stock
FindOrCreate r = findOrCreateDish(merchantId, deptId,
@@ -898,7 +887,8 @@ public class CanteenDailyMenuImportHelper {
createdDish++;
}
if (schedule(merchantId, deptId, date, hourType, r.spu.getId(), replaceDay, clearedDayHour,
cache, pendingMenus, item.getOnlineStock(), item.getOfflineStock())) {
cache, pendingMenus, item.getOnlineStock(), item.getOfflineStock(),
resolveSingleDisplayName(item))) {
scheduled++;
}
appendDish(st, item.getDishName(), hourType);
@@ -955,7 +945,7 @@ public class CanteenDailyMenuImportHelper {
createdCombo++;
}
if (schedule(merchantId, deptId, date, hourType, combo.spu.getId(), replaceDay, clearedDayHour,
cache, pendingMenus, onlineStock, offlineStock)) {
cache, pendingMenus, onlineStock, offlineStock, sanitizeMenuDisplayName(comboName))) {
scheduled++;
}
}
@@ -1510,7 +1500,8 @@ public class CanteenDailyMenuImportHelper {
}
CanteenDailyMenuImportExcel first = items.get(0);
String comboName = StrUtil.trim(first.getComboName());
boolean wantsCombo = StrUtil.isNotBlank(comboName) && !isSingleComboMarker(comboName);
// 与导入一致:单行别名≠套餐;多行/套餐价/子SPU/套餐SPU 才算套餐
boolean wantsCombo = isComboImportGroup(items, comboName, cache);
CanteenSpuEntity menuSpu = menu.getSpuId() == null ? null : resolveSpuById(menu.getSpuId(), cache);
int menuSpuType = menuSpu == null || menuSpu.getSpuType() == null ? 1 : menuSpu.getSpuType();
boolean menuIsCombo = menuSpuType == 2;
@@ -1593,7 +1584,7 @@ public class CanteenDailyMenuImportHelper {
}
String comboName = StrUtil.trim(first.getComboName());
try {
if (StrUtil.isBlank(comboName) || isSingleComboMarker(comboName)) {
if (!isComboImportGroup(items, comboName, cache)) {
return upsertSingleById(menu, first, merchantId, deptId, defaultCategoryId, categoryMap, date, hourType,
cache);
}
@@ -1618,6 +1609,7 @@ public class CanteenDailyMenuImportHelper {
menu.setStartTime(date);
menu.setHourType(hourType);
menu.setSpuId(created.spu.getId());
menu.setDisplayName(resolveSingleDisplayName(row));
menu.setOnlineStock(CanteenDailyMenuStockSupport.quotaOrDefault(row.getOnlineStock()));
menu.setOfflineStock(CanteenDailyMenuStockSupport.quotaOrDefault(row.getOfflineStock()));
canteenDailyMenuService.updateById(menu);
@@ -1710,6 +1702,8 @@ public class CanteenDailyMenuImportHelper {
targetMenu.setStartTime(date);
targetMenu.setHourType(hourType);
targetMenu.setSpuId(combo.spu.getId());
// 套餐展示名挂排菜;Excel「套餐名」列即短标签
targetMenu.setDisplayName(sanitizeMenuDisplayName(comboName));
targetMenu.setOnlineStock(CanteenDailyMenuStockSupport.quotaOrDefault(onlineStock));
targetMenu.setOfflineStock(CanteenDailyMenuStockSupport.quotaOrDefault(offlineStock));
canteenDailyMenuService.updateById(targetMenu);
@@ -1829,7 +1823,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, Integer onlineStock, Integer offlineStock) {
List<CanteenDailyMenuEntity> pendingMenus, Integer onlineStock, Integer offlineStock, String displayName) {
String dh = date + "|" + hourType;
if (replaceDay && !clearedDayHour.contains(dh)) {
canteenDailyMenuService.remove(Wrappers.<CanteenDailyMenuEntity>lambdaQuery()
@@ -1849,6 +1843,7 @@ public class CanteenDailyMenuImportHelper {
menu.setStartTime(date);
menu.setHourType(hourType);
menu.setSpuId(spuId);
menu.setDisplayName(displayName);
menu.setOnlineStock(CanteenDailyMenuStockSupport.quotaOrDefault(onlineStock));
menu.setOfflineStock(CanteenDailyMenuStockSupport.quotaOrDefault(offlineStock));
pendingMenus.add(menu);
@@ -1856,6 +1851,144 @@ public class CanteenDailyMenuImportHelper {
return true;
}
/**
* 导入分组键:显式「单品」按菜名分组;显式「套餐」或非标记套餐名按套餐名分组。
*/
private static String resolveImportGroupKey(Long menuId, LocalDate date, Integer hourType, String comboKey,
String dish, CanteenDailyMenuImportExcel row) {
if (menuId != null) {
return "id:" + menuId + "|" + date + "|" + hourType;
}
Boolean explicit = parseMenuType(row == null ? null : row.getMenuType());
if (Boolean.FALSE.equals(explicit)) {
return "new:" + date + "|" + hourType + "|dish:" + dish;
}
if (Boolean.TRUE.equals(explicit)
|| (StrUtil.isNotBlank(comboKey) && !isSingleComboMarker(comboKey))) {
String key = StrUtil.blankToDefault(comboKey, dish);
return "new:" + date + "|" + hourType + "|combo:" + key;
}
return "new:" + date + "|" + hourType + "|dish:" + dish;
}
/**
* 是否按套餐导入:类型列优先;否则多行同名 / 子SPU / 套餐价 / 菜品SPU为套餐 → 套餐。
*/
private boolean isComboImportGroup(List<CanteenDailyMenuImportExcel> items, String comboName, ImportCache cache) {
if (CollUtil.isEmpty(items)) {
return false;
}
Boolean explicit = resolveGroupMenuType(items);
if (Boolean.FALSE.equals(explicit)) {
return false;
}
if (Boolean.TRUE.equals(explicit)) {
return true;
}
if (StrUtil.isBlank(comboName) || isSingleComboMarker(comboName)) {
return false;
}
if (items.size() >= 2) {
return true;
}
CanteenDailyMenuImportExcel first = items.get(0);
if (first == null) {
return false;
}
if (StrUtil.isNotBlank(first.getChildSpuId())) {
return true;
}
if (first.getPriceYuan() != null) {
return true;
}
Long spuId = parseExcelLong(first.getSpuId());
if (spuId != null) {
CanteenSpuEntity spu = resolveSpuById(spuId, cache);
if (spu != null && spu.getSpuType() != null && spu.getSpuType() == 2) {
return true;
}
}
return false;
}
/** 组内类型列:有明确值时以首个非空为准 */
private static Boolean resolveGroupMenuType(List<CanteenDailyMenuImportExcel> items) {
if (CollUtil.isEmpty(items)) {
return null;
}
for (CanteenDailyMenuImportExcel item : items) {
Boolean t = parseMenuType(item == null ? null : item.getMenuType());
if (t != null) {
return t;
}
}
return null;
}
/**
* 解析类型列:true=套餐,false=单品,null=未填/无法识别(走自动规则)。
* 旧模板把「填写说明」读到本列时按未填处理。
*/
private static Boolean parseMenuType(String raw) {
String s = StrUtil.trim(raw);
if (StrUtil.isBlank(s)) {
return null;
}
if (s.length() > 16 || StrUtil.contains(s, "填写") || StrUtil.contains(s, "导入")
|| StrUtil.contains(s, "Excel") || StrUtil.contains(s, "菜谱ID") || StrUtil.contains(s, "类型")) {
return null;
}
String n = s.toLowerCase();
if ("套餐".equals(s) || "combo".equals(n) || "2".equals(s) || "是".equals(s) || "y".equals(n)
|| "yes".equals(n)) {
return Boolean.TRUE;
}
if ("单品".equals(s) || "单点".equals(s) || "single".equals(n) || "1".equals(s) || "否".equals(s)
|| "n".equals(n) || "no".equals(n)) {
return Boolean.FALSE;
}
return null;
}
/** 预览展示用类型文案 */
private String resolvePreviewMenuType(List<CanteenDailyMenuImportExcel> items, CanteenDailyMenuImportExcel item,
ImportCache cache) {
String raw = item == null ? null : StrUtil.trim(item.getMenuType());
Boolean explicit = parseMenuType(raw);
if (Boolean.TRUE.equals(explicit)) {
return "套餐";
}
if (Boolean.FALSE.equals(explicit)) {
return "单品";
}
String comboName = StrUtil.trim(item == null ? null : item.getComboName());
return isComboImportGroup(items, comboName, cache) ? "套餐" : "单品";
}
/** 单品排菜展示名:取「套餐名/别名」列(「无」等标记视为无别名) */
private static String resolveSingleDisplayName(CanteenDailyMenuImportExcel row) {
if (row == null) {
return null;
}
return sanitizeMenuDisplayName(row.getComboName());
}
/** 排菜展示名清洗:过滤说明文案与单品标记 */
private static String sanitizeMenuDisplayName(String raw) {
String s = StrUtil.trim(raw);
if (StrUtil.isBlank(s)) {
return null;
}
if (s.length() > 64 || StrUtil.contains(s, "填写") || StrUtil.contains(s, "导入")
|| StrUtil.contains(s, "Excel") || StrUtil.contains(s, "菜谱ID")) {
return null;
}
if (isSingleComboMarker(s)) {
return null;
}
return s;
}
/**
* 解析/创建单品。
* <p>有「菜品SPU ID」/「子SPU ID」→ 按 ID 复用并按需更新名称、配料、价格、分类;无 ID → 一律新建,不再按菜名复用。
@@ -2550,6 +2683,7 @@ public class CanteenDailyMenuImportHelper {
copy.setId(src.getId());
copy.setSpuId(src.getSpuId());
copy.setChildSpuId(src.getChildSpuId());
copy.setMenuType(src.getMenuType());
copy.setTip(src.getTip());
return copy;
}
@@ -543,6 +543,54 @@ public class CanteenDailyMenuServiceImpl extends ServiceImpl<CanteenDailyMenuMap
}
}
// 套餐订单销量摊到同日同餐段已排菜的组成单品(与实时扣减联动一致)
Set<Long> menuSpuIds = menus.stream().map(CanteenDailyMenuEntity::getSpuId).filter(Objects::nonNull)
.collect(Collectors.toSet());
Map<Long, List<com.bsd.pjcloud.canteen.vo.app.OnlineSpuComboItemVO>> comboMap =
canteenSpuService.loadComboItemVos(new ArrayList<>(menuSpuIds));
Set<String> menuKeys = menus.stream()
.filter(m -> m.getHourType() != null && m.getSpuId() != null)
.map(m -> m.getHourType() + "|" + m.getSpuId())
.collect(Collectors.toSet());
Map<String, int[]> linkedSold = new HashMap<>();
for (Map.Entry<String, int[]> e : soldByHourProduct.entrySet()) {
String[] parts = e.getKey().split("\\|", 2);
if (parts.length != 2) {
continue;
}
Long comboSpuId;
try {
comboSpuId = Long.valueOf(parts[1]);
}
catch (Exception ignored) {
continue;
}
List<com.bsd.pjcloud.canteen.vo.app.OnlineSpuComboItemVO> lines = comboMap.get(comboSpuId);
if (CollUtil.isEmpty(lines)) {
continue;
}
String hour = parts[0];
int[] comboPair = e.getValue();
for (com.bsd.pjcloud.canteen.vo.app.OnlineSpuComboItemVO line : lines) {
if (line == null || line.getChildSpuId() == null) {
continue;
}
String childKey = hour + "|" + line.getChildSpuId();
if (!menuKeys.contains(childKey)) {
continue;
}
int qty = line.getQuantity() == null || line.getQuantity() < 1 ? 1 : line.getQuantity();
int[] childPair = linkedSold.computeIfAbsent(childKey, ignored -> new int[] { 0, 0 });
childPair[0] = Math.addExact(childPair[0], Math.multiplyExact(comboPair[0], qty));
childPair[1] = Math.addExact(childPair[1], Math.multiplyExact(comboPair[1], qty));
}
}
for (Map.Entry<String, int[]> e : linkedSold.entrySet()) {
int[] pair = soldByHourProduct.computeIfAbsent(e.getKey(), ignored -> new int[] { 0, 0 });
pair[0] = Math.addExact(pair[0], e.getValue()[0]);
pair[1] = Math.addExact(pair[1], e.getValue()[1]);
}
List<CanteenDailyMenuEntity> toUpdate = new ArrayList<>();
for (CanteenDailyMenuEntity menu : menus) {
if (menu.getHourType() == null || menu.getSpuId() == null) {
@@ -641,8 +689,8 @@ public class CanteenDailyMenuServiceImpl extends ServiceImpl<CanteenDailyMenuMap
for (DailyMenuAppVO vo : list) {
int type = vo.getSpuType() == null ? 1 : vo.getSpuType();
vo.setSpuType(type);
// 旧数据仅写了 introduction:App/收银展示套餐名时回落
if (type == 2 && StrUtil.isBlank(vo.getComboName()) && StrUtil.isNotBlank(vo.getIntroduction())) {
// 旧数据仅写了 introduction:展示名回落(SQL 已优先排菜 display_name)
if (StrUtil.isBlank(vo.getComboName()) && StrUtil.isNotBlank(vo.getIntroduction())) {
vo.setComboName(vo.getIntroduction().trim());
}
if (type == 2) {
@@ -650,7 +698,6 @@ public class CanteenDailyMenuServiceImpl extends ServiceImpl<CanteenDailyMenuMap
}
else {
vo.setComboItems(Collections.emptyList());
vo.setComboName(null);
}
}
}
@@ -6,6 +6,7 @@ import com.bsd.pjcloud.canteen.entity.CanteenDailyMenuEntity;
import com.bsd.pjcloud.canteen.vo.ADCanteenSpuListVO;
import com.bsd.pjcloud.canteen.vo.DailyMenuAdminVO;
import com.bsd.pjcloud.canteen.vo.app.DailyMenuAppVO;
import com.bsd.pjcloud.canteen.vo.app.OnlineSpuComboItemVO;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
@@ -17,11 +18,22 @@ import java.util.Objects;
import java.util.stream.Collectors;
/**
* 每日菜谱分池库存展示。
* 每日菜谱库存展示与剩余计算。
* <p>
* {@code online_stock}/{@code offline_stock} 为人工配额(下单不改);
* {@code online_sold}/{@code offline_sold} 为净销量;剩余 = 配额 − 销量。
* App 的 {@code stock}=线上剩余,安卓 AD 的 {@code stock}=线下剩余。
* 库字段约定(不改表结构):
* <ul>
* <li>{@code offline_stock} = <b>总库存</b>(人工)</li>
* <li>{@code online_stock} = <b>移动端可预订数量</b>上限(人工)</li>
* <li>{@code online_sold}/{@code offline_sold} = 移动端已预订 / 消费机已销售净销量</li>
* </ul>
* 共享总库存:线上未订完可继续线下卖。
* <pre>
* 剩余总库存 = max(0, 总库存 − 移动端已预订 − 消费机已销售)
* 移动端可预订剩余 = min(移动端上限 − 已预订, 剩余总库存)
* AD stock = max(0, 总库存 − 移动端已预订)
* </pre>
* PC 管理端只展示总库存、移动端上限、移动端已订、消费机已售、剩余总库存。
* 扣减仍受「剩余总库存」边界约束,防止超卖。
*/
@Component
@RequiredArgsConstructor
@@ -37,20 +49,73 @@ public class CanteenDailyMenuStockSupport {
return sold == null || sold < 0 ? 0 : sold;
}
/** 剩余 = max(0, 配额 − 销量) */
/** @deprecated 独立分池已废弃,请用 {@link #sharedRemain} / {@link #onlineRemain} */
public static int remainOf(Integer quota, Integer sold) {
return Math.max(0, quotaOrDefault(quota) - soldOrDefault(sold));
}
/** 总库存(库字段 offline_stock) */
public static int totalStockOf(Integer offlineStockAsTotal) {
return quotaOrDefault(offlineStockAsTotal);
}
public static int totalStockOf(CanteenDailyMenuEntity menu) {
return menu == null ? 0 : totalStockOf(menu.getOfflineStock());
}
/** 移动端可预订上限(库字段 online_stock) */
public static int mobileQuotaOf(CanteenDailyMenuEntity menu) {
return menu == null ? 0 : quotaOrDefault(menu.getOnlineStock());
}
/** 剩余总库存 = 总库存 − 线上已订 − 线下已售 */
public static int sharedRemain(Integer totalStock, Integer onlineSold, Integer offlineSold) {
return Math.max(0, totalStockOf(totalStock) - soldOrDefault(onlineSold) - soldOrDefault(offlineSold));
}
public static int sharedRemain(CanteenDailyMenuEntity menu) {
if (menu == null) {
return 0;
}
return sharedRemain(menu.getOfflineStock(), menu.getOnlineSold(), menu.getOfflineSold());
}
/** 移动端可预订剩余 */
public static int onlineRemain(Integer totalStock, Integer mobileQuota, Integer onlineSold, Integer offlineSold) {
int shared = sharedRemain(totalStock, onlineSold, offlineSold);
int mobileLeft = Math.max(0, quotaOrDefault(mobileQuota) - soldOrDefault(onlineSold));
return Math.min(mobileLeft, shared);
}
public static int onlineRemain(CanteenDailyMenuEntity menu) {
if (menu == null) {
return 0;
}
return onlineRemain(menu.getOfflineStock(), menu.getOnlineStock(), menu.getOnlineSold(), menu.getOfflineSold());
}
/**
* AD 返回的 stock:总库存 − 移动端已预订(不含消费机已售;已售由 offline_sold 单独体现)。
*/
public static int adStockOf(Integer totalStock, Integer onlineSold) {
return Math.max(0, totalStockOf(totalStock) - soldOrDefault(onlineSold));
}
public static int adStockOf(CanteenDailyMenuEntity menu) {
if (menu == null) {
return 0;
}
return adStockOf(menu.getOfflineStock(), menu.getOnlineSold());
}
public static int channelRemain(CanteenDailyMenuEntity menu, boolean offline) {
if (menu == null) {
return 0;
}
return offline ? remainOf(menu.getOfflineStock(), menu.getOfflineSold())
: remainOf(menu.getOnlineStock(), menu.getOnlineSold());
return offline ? adStockOf(menu) : onlineRemain(menu);
}
/** App:stock = 线上剩余;onlineStock/offlineStock 仍透出配额 */
/** App:stock = 线上剩余 */
public void fillAppOnlineRemain(List<DailyMenuAppVO> rows, Long merchantId, LocalDate mealDate) {
if (CollUtil.isEmpty(rows)) {
return;
@@ -58,9 +123,10 @@ public class CanteenDailyMenuStockSupport {
for (DailyMenuAppVO row : rows) {
applyAppQuotasAndRemains(row, true);
}
applyAppComboLinkedRemain(rows, true);
}
/** 安卓消费机:stock = 线下剩余(字段名不变) */
/** 安卓消费机:stock = 总库存 − 移动端已预订 */
public void fillAdOfflineRemain(List<DailyMenuAppVO> rows, Long merchantId, LocalDate mealDate) {
if (CollUtil.isEmpty(rows)) {
return;
@@ -68,6 +134,7 @@ public class CanteenDailyMenuStockSupport {
for (DailyMenuAppVO row : rows) {
applyAppQuotasAndRemains(row, false);
}
applyAppComboLinkedRemain(rows, false);
}
public void fillAdOfflineRemainByRowDate(List<DailyMenuAppVO> rows, Long merchantId) {
@@ -87,45 +154,158 @@ public class CanteenDailyMenuStockSupport {
}
}
/** PC:配额与剩余分列展示 */
/** PC:总库存 / 移动端上限 / 已订与已售 / 剩余总库存 */
public void fillAdminRemain(List<DailyMenuAdminVO> rows, Long merchantId, LocalDate mealDate) {
if (CollUtil.isEmpty(rows)) {
return;
}
Map<String, DailyMenuAdminVO> byMealSpu = new HashMap<>();
for (DailyMenuAdminVO row : rows) {
int onlineQuota = quotaOrDefault(row.getOnlineStock());
int offlineQuota = quotaOrDefault(row.getOfflineStock());
int onlineRemain = remainOf(onlineQuota, row.getOnlineSold());
int offlineRemain = remainOf(offlineQuota, row.getOfflineSold());
row.setOnlineStock(onlineQuota);
row.setOfflineStock(offlineQuota);
// 入库:offlineStock=总库存,onlineStock=移动端上限
int total = totalStockOf(row.getOfflineStock());
int mobile = quotaOrDefault(row.getOnlineStock());
int onlineSold = soldOrDefault(row.getOnlineSold());
int offlineSold = soldOrDefault(row.getOfflineSold());
int shared = sharedRemain(total, onlineSold, offlineSold);
int onlineRemain = onlineRemain(total, mobile, onlineSold, offlineSold);
row.setTotalStock(total);
row.setOnlineStock(mobile);
// offlineStock 保持总库存原值,便于编辑回传;列表展示用 totalStock / offlineSold
row.setOfflineStock(total);
row.setOfflineSold(offlineSold);
row.setOnlineSold(onlineSold);
row.setRemainOnlineStock(onlineRemain);
row.setRemainOfflineStock(offlineRemain);
row.setStock(offlineRemain);
row.setRemainStock(onlineRemain + offlineRemain);
row.setRemainOfflineStock(shared);
row.setStock(shared);
row.setRemainStock(shared);
if (row.getSpuId() != null) {
byMealSpu.put(mealSpuKey(row.getHourType(), row.getSpuId()), row);
}
}
for (DailyMenuAdminVO row : rows) {
if (!Objects.equals(row.getSpuType(), 2) || CollUtil.isEmpty(row.getComboItems())) {
continue;
}
int formableOnline = formableFromChildren(row.getComboItems(), byMealSpu, row.getHourType(), true);
int formableOffline = formableFromChildren(row.getComboItems(), byMealSpu, row.getHourType(), false);
if (formableOnline >= 0) {
row.setRemainOnlineStock(Math.min(row.getRemainOnlineStock(), formableOnline));
}
if (formableOffline >= 0) {
row.setRemainOfflineStock(Math.min(row.getRemainOfflineStock(), formableOffline));
}
row.setStock(row.getRemainOfflineStock());
row.setRemainStock(row.getRemainOfflineStock());
}
}
private static void applyAppQuotasAndRemains(DailyMenuAppVO row, boolean stockAsOnline) {
int onlineQuota = quotaOrDefault(row.getOnlineStock());
int offlineQuota = quotaOrDefault(row.getOfflineStock());
int onlineRemain = remainOf(onlineQuota, row.getOnlineSold());
int offlineRemain = remainOf(offlineQuota, row.getOfflineSold());
row.setOnlineStock(onlineQuota);
row.setOfflineStock(offlineQuota);
int total = totalStockOf(row.getOfflineStock());
int mobile = quotaOrDefault(row.getOnlineStock());
int onlineSold = soldOrDefault(row.getOnlineSold());
int offlineSold = soldOrDefault(row.getOfflineSold());
int shared = sharedRemain(total, onlineSold, offlineSold);
int onlineRemain = onlineRemain(total, mobile, onlineSold, offlineSold);
int adStock = adStockOf(total, onlineSold);
row.setOnlineStock(mobile);
row.setOfflineStock(total);
row.setOnlineSold(onlineSold);
row.setOfflineSold(offlineSold);
row.setRemainOnlineStock(onlineRemain);
row.setRemainOfflineStock(offlineRemain);
row.setStock(stockAsOnline ? onlineRemain : offlineRemain);
// AD:remainOfflineStock / stock = 总库存 − 线上已订;App 侧 remainOffline 仍给剩余总库存供参考
row.setRemainOfflineStock(stockAsOnline ? shared : adStock);
row.setStock(stockAsOnline ? onlineRemain : adStock);
}
/**
* 安卓 SPU 查看接口:若当日菜谱存在对应行,则 {@code stock} 覆盖为线下剩余;无菜谱行则保留 canteen_spu.stock。
*
* @param rows 已组装的 AD VO(含 merchantId、categoryType)
* @param menus 当日(或指定日)相关每日菜谱行
* @param hourTypeOverride 请求显式餐段;为空时从 VO.categoryType 解析
* @param offline true=线下剩余(AD),false=线上剩余(App 详情复用)
* 套餐有效剩余 = min(套餐行剩余, 各组成菜在当日同餐段可组成份数)。
*/
private static void applyAppComboLinkedRemain(List<DailyMenuAppVO> rows, boolean stockAsOnline) {
if (CollUtil.isEmpty(rows)) {
return;
}
Map<String, DailyMenuAppVO> byMealSpu = new HashMap<>();
for (DailyMenuAppVO row : rows) {
if (row != null && row.getSpuId() != null) {
byMealSpu.put(mealSpuKey(row.getHourType(), row.getSpuId()), row);
}
}
for (DailyMenuAppVO row : rows) {
if (row == null || !Objects.equals(row.getSpuType(), 2) || CollUtil.isEmpty(row.getComboItems())) {
continue;
}
int formable = formableFromAppChildren(row.getComboItems(), byMealSpu, row.getHourType(), stockAsOnline);
if (formable < 0) {
continue;
}
int own = stockAsOnline ? nullToZero(row.getRemainOnlineStock()) : nullToZero(row.getRemainOfflineStock());
int effective = Math.min(own, formable);
if (stockAsOnline) {
row.setRemainOnlineStock(effective);
}
else {
row.setRemainOfflineStock(effective);
}
row.setStock(effective);
}
}
private static int formableFromAppChildren(List<OnlineSpuComboItemVO> items, Map<String, DailyMenuAppVO> byMealSpu,
Integer hourType, boolean online) {
if (CollUtil.isEmpty(items) || byMealSpu == null || byMealSpu.isEmpty()) {
return -1;
}
int formable = Integer.MAX_VALUE;
boolean any = false;
for (OnlineSpuComboItemVO item : items) {
if (item == null || item.getChildSpuId() == null) {
continue;
}
DailyMenuAppVO child = byMealSpu.get(mealSpuKey(hourType, item.getChildSpuId()));
if (child == null) {
continue;
}
any = true;
int qty = item.getQuantity() == null || item.getQuantity() < 1 ? 1 : item.getQuantity();
int childRemain = online ? nullToZero(child.getRemainOnlineStock())
: nullToZero(child.getRemainOfflineStock());
formable = Math.min(formable, childRemain / qty);
}
return any ? formable : -1;
}
private static int formableFromChildren(List<OnlineSpuComboItemVO> items, Map<String, DailyMenuAdminVO> byMealSpu,
Integer hourType, boolean online) {
if (CollUtil.isEmpty(items) || byMealSpu == null || byMealSpu.isEmpty()) {
return -1;
}
int formable = Integer.MAX_VALUE;
boolean any = false;
for (OnlineSpuComboItemVO item : items) {
if (item == null || item.getChildSpuId() == null) {
continue;
}
DailyMenuAdminVO child = byMealSpu.get(mealSpuKey(hourType, item.getChildSpuId()));
if (child == null) {
continue;
}
any = true;
int qty = item.getQuantity() == null || item.getQuantity() < 1 ? 1 : item.getQuantity();
int childRemain = online ? nullToZero(child.getRemainOnlineStock())
: nullToZero(child.getRemainOfflineStock());
formable = Math.min(formable, childRemain / qty);
}
return any ? formable : -1;
}
private static String mealSpuKey(Integer hourType, Long spuId) {
return (hourType == null ? "_" : hourType) + "|" + spuId;
}
private static int nullToZero(Integer v) {
return v == null ? 0 : v;
}
public void overlaySpuViewStock(List<ADCanteenSpuListVO> rows, List<CanteenDailyMenuEntity> menus,
Integer hourTypeOverride, boolean offline) {
if (CollUtil.isEmpty(rows) || CollUtil.isEmpty(menus)) {
@@ -144,7 +324,6 @@ public class CanteenDailyMenuStockSupport {
}
Integer hourType = hourTypeOverride != null ? hourTypeOverride : parseHourType(row.getCategoryType());
if (hourType == null) {
// 分类未标餐段时:同一商户+SPU 当日若只有一行菜谱,仍可覆盖
CanteenDailyMenuEntity only = findUniqueMenu(menus, row.getMerchantId(), row.getId());
if (only != null) {
row.setStock(channelRemain(only, offline));
@@ -178,7 +357,6 @@ public class CanteenDailyMenuStockSupport {
return merchantId + "|" + hourType + "|" + spuId;
}
/** categoryType 支持 "2" 或 "2,3";取第一个有效餐段。 */
public static Integer parseHourType(String categoryType) {
if (StrUtil.isBlank(categoryType)) {
return null;
@@ -84,6 +84,8 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
if (updateStockReqDTO == null || CollUtil.isEmpty(updateStockReqDTO.getItems())) {
return R.failed(MsgUtils.getMessage(ErrorCodes.CANTEEN_STOCK_ITEMS_EMPTY));
}
// AD 旧机可不传 stockChannel:默认 OFFLINE,走每日菜谱线下池(不再扣 canteen_spu.stock)
normalizeDailyStockChannel(updateStockReqDTO);
if (isDailyChannel(updateStockReqDTO.getStockChannel())) {
return updateDailyMenuChannelStock(updateStockReqDTO);
}
@@ -133,6 +135,7 @@ 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));
}
normalizeDailyStockChannel(dto);
if (isDailyChannel(dto.getStockChannel())) {
return updateDailyMenuChannelStockIdempotent(dto, applyOperation, rollbackOperation);
}
@@ -218,13 +221,50 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
return "ONLINE".equals(ch) || "OFFLINE".equals(ch);
}
/** 非幂等分池扣减:改写 daily_menu 销量字段(安卓仍调 /ad/updateSkuStock,由硬件补 stockChannel)。 */
/** 空通道默认 OFFLINE,兼容未升级的安卓消费机。 */
private static void normalizeDailyStockChannel(CanteenSpuUpdateStockReqDTO dto) {
if (dto != null && StrUtil.isBlank(dto.getStockChannel())) {
dto.setStockChannel("OFFLINE");
}
}
/** 分池扣减缺 merchantId 时,从首个 SPU 回填(AD 旧请求常不传)。 */
private Long resolveDailyStockMerchantId(CanteenSpuUpdateStockReqDTO dto) {
if (dto == null) {
return null;
}
if (dto.getMerchantId() != null) {
return dto.getMerchantId();
}
if (CollUtil.isEmpty(dto.getItems())) {
return null;
}
for (CanteenSpuUpdateStockReqDTO.Item item : dto.getItems()) {
if (item == null || item.getId() == null) {
continue;
}
CanteenSpuEntity spu = baseMapper.selectById(item.getId());
if (spu != null && spu.getMerchantId() != null) {
dto.setMerchantId(spu.getMerchantId());
return spu.getMerchantId();
}
}
return null;
}
/** 非幂等分池扣减:改写 daily_menu 销量字段(AD 空 stockChannel 已默认 OFFLINE)。 */
private R updateDailyMenuChannelStock(CanteenSpuUpdateStockReqDTO dto) {
if (dto.getMerchantId() == null) {
if (resolveDailyStockMerchantId(dto) == 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()) {
boolean offline = !"ONLINE".equals(StrUtil.trim(dto.getStockChannel()));
List<CanteenSpuUpdateStockReqDTO.Item> items = expandDailyMenuLinkedStockItems(dto.getMerchantId(), offline,
dto.getItems(), false);
if (CollUtil.isEmpty(items)) {
return R.failed(MsgUtils.getMessage(ErrorCodes.CANTEEN_STOCK_ITEMS_EMPTY));
}
Map<String, Integer> remainCursor = new HashMap<>();
for (CanteenSpuUpdateStockReqDTO.Item item : items) {
if (item == null || item.getId() == null || item.getIncrCount() == null
|| item.getIncrCount() == 0 || item.getIncrCount() == Integer.MIN_VALUE
|| item.getMealDate() == null || item.getHourType() == null) {
@@ -233,17 +273,19 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
CanteenDailyMenuEntity menu = findDailyMenuRow(dto.getMerchantId(), item.getMealDate(), item.getHourType(),
item.getId());
if (menu == null) {
// 分池扣减按「商户+日期+餐段+SPU」查每日菜谱;查不到不是 canteen_spu 不存在
return R.failed(MsgUtils.getMessage(ErrorCodes.HARD_DAILY_MENU_PRODUCT_BAD));
}
int current = CanteenDailyMenuStockSupport.channelRemain(menu, offline);
String key = dailyStockItemKey(item);
int current = remainCursor.computeIfAbsent(key,
k -> CanteenDailyMenuStockSupport.channelRemain(menu, offline));
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(), "商品")));
}
remainCursor.put(key, Math.addExact(current, item.getIncrCount()));
}
for (CanteenSpuUpdateStockReqDTO.Item item : dto.getItems()) {
for (CanteenSpuUpdateStockReqDTO.Item item : items) {
int updated = offline
? canteenDailyMenuMapper.updateOfflineStockDelta(dto.getMerchantId(), item.getMealDate(),
item.getHourType(), item.getId(), item.getIncrCount(), null)
@@ -259,11 +301,11 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
/** 幂等分池扣减:改写 daily_menu.online_sold / offline_sold(配额字段不动)。 */
private R updateDailyMenuChannelStockIdempotent(CanteenSpuUpdateStockReqDTO dto, boolean applyOperation,
boolean rollbackOperation) {
if (dto.getMerchantId() == null
if (resolveDailyStockMerchantId(dto) == 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()));
boolean offline = !"ONLINE".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))) {
@@ -272,13 +314,19 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
if (rollbackOperation && ("ROLLED_BACK".equals(state) || ORDER_STOCK_BIZ_STATE_COMPENSATED.equals(state))) {
return R.ok();
}
for (CanteenSpuUpdateStockReqDTO.Item item : dto.getItems()) {
// 套餐行展开为组成单品同日同餐段销量联动;ROLLBACK 对同一 items 再展开一次,保证对称回补
List<CanteenSpuUpdateStockReqDTO.Item> items = expandDailyMenuLinkedStockItems(dto.getMerchantId(), offline,
dto.getItems(), true);
Map<String, Integer> remainCursor = new HashMap<>();
for (CanteenSpuUpdateStockReqDTO.Item item : items) {
CanteenDailyMenuEntity menu = findDailyMenuRow(dto.getMerchantId(), item.getMealDate(), item.getHourType(),
item.getId());
if (menu == null) {
return R.failed(MsgUtils.getMessage(ErrorCodes.HARD_DAILY_MENU_PRODUCT_BAD));
}
int current = CanteenDailyMenuStockSupport.channelRemain(menu, offline);
String key = dailyStockItemKey(item);
int current = remainCursor.computeIfAbsent(key,
k -> CanteenDailyMenuStockSupport.channelRemain(menu, offline));
int expectedStock = item.getBeforeStock();
if (expectedStock < 0) {
return R.failed(MsgUtils.getMessage(ErrorCodes.CANTEEN_STOCK_SNAPSHOT_BAD));
@@ -288,8 +336,6 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
return R.failed(MsgUtils.getMessage(ErrorCodes.HARD_PRODUCT_STOCK_LOW,
StrUtil.blankToDefault(spu == null ? null : spu.getName(), "商品")));
}
// 正增量恢复是可交换操作,跳过剩余量快照校验,避免退款恢复被并发下单等无关变更误拒;
// 负增量扣减保留快照校验,防止不同业务号基于同一剩余量重复判断导致超卖。
if (applyOperation && item.getIncrCount() < 0 && current != expectedStock) {
CanteenSpuEntity spu = baseMapper.selectById(item.getId());
return R.failed(MsgUtils.getMessage(ErrorCodes.CANTEEN_STOCK_CHANGED,
@@ -303,8 +349,11 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
StrUtil.blankToDefault(spu == null ? null : spu.getName(), "商品")));
}
}
int simulateDelta = applyOperation ? item.getIncrCount()
: (rollbackOperation && "APPLIED".equals(state) ? -item.getIncrCount() : 0);
remainCursor.put(key, Math.addExact(current, simulateDelta));
}
for (CanteenSpuUpdateStockReqDTO.Item item : dto.getItems()) {
for (CanteenSpuUpdateStockReqDTO.Item item : items) {
int delta = applyOperation ? item.getIncrCount() : -item.getIncrCount();
int updated;
if (rollbackOperation && "APPLIED".equals(state)) {
@@ -319,10 +368,6 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
continue;
}
if (applyOperation) {
// 正增量恢复直接原子累加:expected 传 null 跳过剩余量匹配条件,SQL 也不带任何边界条件,
// 销量回退由 GREATEST 钳制到 0 为止——菜谱重导、按订单重算销量、配额为 0 等统计口径
// 都不得阻断退款/补偿;幂等由 bizId 锁和 Redis 状态保证。
// 负增量扣减保留快照匹配条件更新,防止不同业务号基于同一剩余量重复判断导致超卖。
Integer expected = delta > 0 ? null : item.getBeforeStock();
updated = offline
? canteenDailyMenuMapper.updateOfflineStockDelta(dto.getMerchantId(), item.getMealDate(),
@@ -341,6 +386,113 @@ public class CanteenSpuServiceImpl extends ServiceImpl<CanteenSpuMapper, Canteen
return R.ok();
}
/**
* 套餐销量联动:同日同餐段若组成单品也在菜谱中,套餐扣/补时按组成份数同步改写单品销量。
* 单品售出会通过展示/校验侧的「可组成套餐数」压低套餐可售,此处不反向改写套餐 sold,避免与订单销量口径打架。
*/
private List<CanteenSpuUpdateStockReqDTO.Item> expandDailyMenuLinkedStockItems(Long merchantId, boolean offline,
List<CanteenSpuUpdateStockReqDTO.Item> source, boolean fillMissingBeforeStock) {
if (CollUtil.isEmpty(source)) {
return List.of();
}
Map<String, Integer> callerBefore = new HashMap<>();
for (CanteenSpuUpdateStockReqDTO.Item item : source) {
if (item != null && item.getId() != null && item.getMealDate() != null && item.getHourType() != null
&& item.getBeforeStock() != null) {
callerBefore.put(dailyStockItemKey(item), item.getBeforeStock());
}
}
Set<Long> spuIds = source.stream().filter(Objects::nonNull).map(CanteenSpuUpdateStockReqDTO.Item::getId)
.filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new));
Map<Long, CanteenSpuEntity> spuMap = spuIds.isEmpty() ? Map.of()
: listByIds(spuIds).stream().collect(Collectors.toMap(CanteenSpuEntity::getId, s -> s, (a, b) -> a));
List<Long> comboIds = spuMap.values().stream()
.filter(s -> s != null && Objects.equals(s.getSpuType(), 2) && s.getId() != null)
.map(CanteenSpuEntity::getId).distinct().collect(Collectors.toList());
Map<Long, List<CanteenSpuComboItemEntity>> comboMap = loadComboItems(comboIds);
List<CanteenSpuUpdateStockReqDTO.Item> expanded = new ArrayList<>();
for (CanteenSpuUpdateStockReqDTO.Item item : source) {
if (item == null) {
continue;
}
expanded.add(copyStockItem(item));
CanteenSpuEntity spu = spuMap.get(item.getId());
if (spu == null || !Objects.equals(spu.getSpuType(), 2)) {
continue;
}
List<CanteenSpuComboItemEntity> lines = comboMap.get(item.getId());
if (CollUtil.isEmpty(lines)) {
continue;
}
for (CanteenSpuComboItemEntity line : lines) {
if (line == null || line.getChildSpuId() == null) {
continue;
}
// 组成菜未排进当日同餐段则不联动
if (findDailyMenuRow(merchantId, item.getMealDate(), item.getHourType(),
line.getChildSpuId()) == null) {
continue;
}
int qty = line.getQuantity() == null || line.getQuantity() < 1 ? 1 : line.getQuantity();
CanteenSpuUpdateStockReqDTO.Item child = new CanteenSpuUpdateStockReqDTO.Item();
child.setId(line.getChildSpuId());
child.setIncrCount(Math.multiplyExact(item.getIncrCount(), qty));
child.setMealDate(item.getMealDate());
child.setHourType(item.getHourType());
expanded.add(child);
}
}
Map<String, CanteenSpuUpdateStockReqDTO.Item> merged = new LinkedHashMap<>();
for (CanteenSpuUpdateStockReqDTO.Item item : expanded) {
if (item.getId() == null || item.getMealDate() == null || item.getHourType() == null
|| item.getIncrCount() == null || item.getIncrCount() == 0) {
continue;
}
String key = dailyStockItemKey(item);
CanteenSpuUpdateStockReqDTO.Item exist = merged.get(key);
if (exist == null) {
merged.put(key, copyStockItem(item));
}
else {
exist.setIncrCount(Math.addExact(exist.getIncrCount(), item.getIncrCount()));
}
}
List<CanteenSpuUpdateStockReqDTO.Item> result = new ArrayList<>(merged.size());
for (CanteenSpuUpdateStockReqDTO.Item item : merged.values()) {
if (item.getIncrCount() == null || item.getIncrCount() == 0) {
continue;
}
Integer before = callerBefore.get(dailyStockItemKey(item));
if (before == null && fillMissingBeforeStock) {
CanteenDailyMenuEntity menu = findDailyMenuRow(merchantId, item.getMealDate(), item.getHourType(),
item.getId());
before = menu == null ? 0 : CanteenDailyMenuStockSupport.channelRemain(menu, offline);
}
if (before != null) {
item.setBeforeStock(before);
}
result.add(item);
}
return result;
}
private static CanteenSpuUpdateStockReqDTO.Item copyStockItem(CanteenSpuUpdateStockReqDTO.Item src) {
CanteenSpuUpdateStockReqDTO.Item copy = new CanteenSpuUpdateStockReqDTO.Item();
copy.setId(src.getId());
copy.setIncrCount(src.getIncrCount());
copy.setBeforeStock(src.getBeforeStock());
copy.setMealDate(src.getMealDate());
copy.setHourType(src.getHourType());
return copy;
}
private static String dailyStockItemKey(CanteenSpuUpdateStockReqDTO.Item item) {
return item.getMealDate() + "|" + item.getHourType() + "|" + item.getId();
}
private CanteenDailyMenuEntity findDailyMenuRow(Long merchantId, java.time.LocalDate mealDate, Integer hourType,
Long spuId) {
return canteenDailyMenuMapper.selectOne(Wrappers.<CanteenDailyMenuEntity>lambdaQuery()
@@ -6,6 +6,7 @@
<id property="id" column="id"/>
<result property="startTime" column="start_time"/>
<result property="spuId" column="spu_id"/>
<result property="displayName" column="display_name"/>
<result property="hourType" column="hour_type"/>
<result property="onlineStock" column="online_stock"/>
<result property="offlineStock" column="offline_stock"/>
@@ -62,7 +63,7 @@
sp.dept_id,
t.create_by,
IFNULL(sp.spu_type, 1) as spu_type,
sp.combo_name,
COALESCE(NULLIF(TRIM(t.display_name), ''), NULLIF(TRIM(sp.combo_name), '')) AS combo_name,
sp.introduction
FROM canteen_daily_menu t
LEFT JOIN canteen_spu sp
@@ -86,7 +87,7 @@
sp.id AS spu_id,
sp.name,
sp.name_en,
sp.combo_name,
COALESCE(NULLIF(TRIM(t.display_name), ''), NULLIF(TRIM(sp.combo_name), '')) AS combo_name,
sp.price,
sp.market_price,
sp.pic_url,
@@ -141,7 +142,7 @@
sp.id AS spu_id,
sp.name,
sp.name_en,
sp.combo_name,
COALESCE(NULLIF(TRIM(t.display_name), ''), NULLIF(TRIM(sp.combo_name), '')) AS combo_name,
sp.price,
sp.market_price,
sp.pic_url,
@@ -184,7 +185,7 @@
sp.id AS spu_id,
sp.name,
sp.name_en,
sp.combo_name,
COALESCE(NULLIF(TRIM(t.display_name), ''), NULLIF(TRIM(sp.combo_name), '')) AS combo_name,
sp.price,
sp.market_price,
sp.pic_url,
@@ -232,7 +233,7 @@
s.id as spu_id,
s.`name`,
s.name_en,
s.combo_name,
COALESCE(NULLIF(TRIM(m.display_name), ''), NULLIF(TRIM(s.combo_name), '')) AS combo_name,
s.price,
IFNULL(m.online_stock, 600) as stock,
IFNULL(m.online_stock, 600) as online_stock,
@@ -274,7 +275,7 @@
s.id AS spu_id,
s.name,
s.name_en,
s.combo_name,
COALESCE(NULLIF(TRIM(m.display_name), ''), NULLIF(TRIM(s.combo_name), '')) AS combo_name,
s.price,
s.market_price,
IFNULL(m.online_stock, 600) AS stock,
@@ -309,7 +310,8 @@
t.merchant_id,
sp.id AS spu_id,
sp.name,
sp.combo_name,
COALESCE(NULLIF(TRIM(t.display_name), ''), NULLIF(TRIM(sp.combo_name), '')) AS combo_name,
t.display_name AS display_name,
sp.introduction,
sp.price,
sp.market_price,
@@ -350,6 +352,12 @@
sold 可能小于恢复量,退款/补偿不能被统计口径阻断),配额为 0 等脏数据同样不得阻断;
负增量扣减保留原「不扣成负销量、不超配额」边界,语义不变。
-->
<!--
共享总库存:offline_stock=总库存,online_stock=移动端上限。
delta 为库存协议增量(扣减为负):sold = sold - delta。
移动端剩余 = LEAST(上限−已订, 总库存−已订−已售);
AD stock / expected = 总库存−已订;扣减边界仍为 已订+已售 ≤ 总库存。
-->
<update id="updateOnlineStockDelta">
UPDATE canteen_daily_menu
SET online_sold = GREATEST(IFNULL(online_sold, 0) - #{delta}, 0)
@@ -361,9 +369,13 @@
<if test="delta &lt;= 0">
AND IFNULL(online_sold, 0) - #{delta} &gt;= 0
AND IFNULL(online_sold, 0) - #{delta} &lt;= IFNULL(online_stock, 600)
AND IFNULL(online_sold, 0) + IFNULL(offline_sold, 0) - #{delta} &lt;= IFNULL(offline_stock, 600)
</if>
<if test="expected != null">
AND (IFNULL(online_stock, 600) - IFNULL(online_sold, 0)) = #{expected}
AND LEAST(
GREATEST(IFNULL(online_stock, 600) - IFNULL(online_sold, 0), 0),
GREATEST(IFNULL(offline_stock, 600) - IFNULL(online_sold, 0) - IFNULL(offline_sold, 0), 0)
) = #{expected}
</if>
</update>
@@ -377,10 +389,10 @@
AND spu_id = #{spuId}
<if test="delta &lt;= 0">
AND IFNULL(offline_sold, 0) - #{delta} &gt;= 0
AND IFNULL(offline_sold, 0) - #{delta} &lt;= IFNULL(offline_stock, 600)
AND IFNULL(online_sold, 0) + IFNULL(offline_sold, 0) - #{delta} &lt;= IFNULL(offline_stock, 600)
</if>
<if test="expected != null">
AND (IFNULL(offline_stock, 600) - IFNULL(offline_sold, 0)) = #{expected}
AND GREATEST(IFNULL(offline_stock, 600) - IFNULL(online_sold, 0), 0) = #{expected}
</if>
</update>
</mapper>
@@ -0,0 +1,26 @@
package com.bsd.pjcloud.supermarket.constant;
/**
* 酒店数据来源(存 canteen_hotel.sheet_name,字典类型 {@link #DICT_TYPE})。
*/
public final class SmHotelDataSource {
/** 字典类型 */
public static final String DICT_TYPE = "sm_hotel_data_source";
/** 本地手工新增 */
public static final String LOCAL = "local";
/** 商游上游同步/导入 */
public static final String SHANGYOU = "shangyou";
/** 同步导入时暂存所选来源(租户 Redis 自动加前缀) */
public static final String REDIS_IMPORT_DATA_SOURCE = "supermarket:shangyou:hotel:import:dataSource";
private SmHotelDataSource() {
}
public static boolean isLocal(String dataSource) {
return LOCAL.equalsIgnoreCase(dataSource == null ? null : dataSource.trim());
}
}
@@ -7,6 +7,7 @@ import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@@ -42,7 +43,11 @@ public class SmHotelEntity extends Model<SmHotelEntity> {
@Schema(description = "酒店名称")
private String hotelName;
@Schema(description = "导入来源Sheet名")
/**
* 数据来源(库字段 sheet_name)。存字典 {@code sm_hotel_data_source} 的 item_value,
* 如 local=本地、shangyou=商游上游;历史 Excel Sheet 名仍可能原样存于此列。
*/
@Schema(description = "数据来源(字典 sm_hotel_data_source)")
private String sheetName;
@Schema(description = "ACTIVE启用 / OFFLINE上游下线停用")
@@ -51,6 +56,27 @@ public class SmHotelEntity extends Model<SmHotelEntity> {
@Schema(description = "备注")
private String remark;
@Schema(description = "酒店主图")
private String coverImage;
@Schema(description = "轮播图URL,逗号分隔")
private String carouselImages;
@Schema(description = "详细地址")
private String address;
@Schema(description = "经度")
private BigDecimal longitude;
@Schema(description = "纬度")
private BigDecimal latitude;
@Schema(description = "联系电话")
private String phone;
@Schema(description = "酒店简介")
private String intro;
@TableField(fill = FieldFill.INSERT)
private String createBy;
@@ -133,6 +133,12 @@ public class ShangyouHotelProperties {
/** 列表同步后是否自动把「上游有本地无」写入本地主数据(会按批循环导完差集) */
private boolean autoImportNewHotels = true;
/**
* 定时/XXL 列表同步自动导入时的默认数据来源(字典 sm_hotel_data_source)。
* 人工同步须在前端显式选择;定时任务用此配置。
*/
private String defaultImportDataSource = "shangyou";
/**
* 自动导入时每批条数(控内存/事务体积)。同步任务会循环多批直到差集清空,不是「总共只导这么多」。
*/
@@ -61,7 +61,7 @@ public class SmHotelCallbackTaskController {
@SysLog("按时间区间删除酒店回调任务")
@Operation(summary = "按创建时间区间删除回调任务")
@DeleteMapping("/range")
@PostMapping("/range")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelCallback_del')")
public R<Integer> removeRange(@RequestParam(required = false) String createTimeBegin,
@RequestParam(required = false) String createTimeEnd) {
@@ -70,7 +70,7 @@ public class SmHotelCallbackTaskController {
@SysLog("清空酒店回调任务")
@Operation(summary = "清空全部回调任务并清理 Redis 队列")
@DeleteMapping("/clear")
@PostMapping("/clear")
@PreAuthorize("@pms.hasPermission('supermarket_smHotelCallback_del')")
public R<Integer> clearAll() {
return callbackTaskService.clearAllTasks();
@@ -75,10 +75,10 @@ public class SmHotelController {
}
@SysLog("新增酒店")
@Operation(summary = "新增")
@Operation(summary = "新增(返回已落库实体,便于继续维护房型等)")
@PostMapping
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_add')")
public R save(@RequestBody SmHotelEntity entity) {
public R<SmHotelEntity> save(@RequestBody SmHotelEntity entity) {
return R.ok(hotelService.saveHotel(entity));
}
@@ -3,6 +3,8 @@ package com.bsd.pjcloud.supermarket.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.supermarket.entity.SmHotelDailyEntity;
import com.bsd.pjcloud.supermarket.entity.SmHotelPriceConfigEntity;
import com.bsd.pjcloud.supermarket.entity.SmHotelProductEntity;
import com.bsd.pjcloud.supermarket.entity.SmHotelRoomEntity;
import com.bsd.pjcloud.supermarket.entity.SmHotelSyncLogEntity;
import com.bsd.pjcloud.supermarket.service.SmHotelMidService;
import com.bsd.pjcloud.supermarket.service.SmHotelPriceConfigService;
@@ -135,20 +137,23 @@ public class SmHotelMidController {
}
@SysLog("导入上游授权酒店到本地")
@Operation(summary = "将上游授权酒店写入本地")
@Operation(summary = "将上游授权酒店写入本地(须选数据来源)")
@PostMapping("/mid/authorizedHotels/import")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_add')")
public R<Map<String, Object>> importAuthorizedHotels(@RequestBody List<Map<String, Object>> hotels) {
return midService.importAuthorizedHotels(hotels);
public R<Map<String, Object>> importAuthorizedHotels(
@RequestParam("dataSource") String dataSource,
@RequestBody List<Map<String, Object>> hotels) {
return midService.importAuthorizedHotels(hotels, dataSource);
}
@SysLog("同步上游酒店列表并差集")
@Operation(summary = "异步同步 getHotelList + Redis 差集(立刻返回 logId,轮询进度)")
@Operation(summary = "异步同步 getHotelList + Redis 差集(立刻返回 logId;须选数据来源)")
@PostMapping("/mid/syncHotelList")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_edit')")
public R<Map<String, Object>> syncHotelList(
@RequestParam(required = false, defaultValue = "true") boolean autoImport) {
return syncService.syncHotelListAndDiff(autoImport);
@RequestParam(required = false, defaultValue = "true") boolean autoImport,
@RequestParam("dataSource") String dataSource) {
return syncService.syncHotelListAndDiff(autoImport, dataSource);
}
@Operation(summary = "同步日志分页")
@@ -199,11 +204,55 @@ public class SmHotelMidController {
}
@SysLog("导入差集新增酒店")
@Operation(summary = "将差集中的新增酒店导入本地")
@Operation(summary = "将差集中的新增酒店导入本地(dataSource 空则用同步时暂存的来源)")
@PostMapping("/mid/hotelDiff/importNew")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_add')")
public R<Map<String, Object>> importDiffNew(@RequestParam(required = false) Integer limit) {
return syncService.importDiffNewHotels(limit);
public R<Map<String, Object>> importDiffNew(@RequestParam(required = false) Integer limit,
@RequestParam(required = false) String dataSource) {
return syncService.importDiffNewHotels(limit, dataSource);
}
@SysLog("本地酒店保存房型")
@PostMapping("/local/{hotelId}/room")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_edit')")
public R<SmHotelRoomEntity> saveLocalRoom(@PathVariable Long hotelId, @RequestBody SmHotelRoomEntity room) {
return midService.saveLocalRoom(hotelId, room);
}
@SysLog("本地酒店删除房型")
@DeleteMapping("/local/{hotelId}/room/{roomId}")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_edit')")
public R<Boolean> removeLocalRoom(@PathVariable Long hotelId, @PathVariable Long roomId) {
return midService.removeLocalRoom(hotelId, roomId);
}
@SysLog("本地酒店保存产品")
@PostMapping("/local/{hotelId}/product")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_edit')")
public R<SmHotelProductEntity> saveLocalProduct(@PathVariable Long hotelId,
@RequestBody SmHotelProductEntity product) {
return midService.saveLocalProduct(hotelId, product);
}
@SysLog("本地酒店删除产品")
@DeleteMapping("/local/{hotelId}/product/{productId}")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_edit')")
public R<Boolean> removeLocalProduct(@PathVariable Long hotelId, @PathVariable Long productId) {
return midService.removeLocalProduct(hotelId, productId);
}
@SysLog("本地酒店保存日历")
@PostMapping("/local/{hotelId}/daily")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_edit')")
public R<SmHotelDailyEntity> saveLocalDaily(@PathVariable Long hotelId, @RequestBody SmHotelDailyEntity daily) {
return midService.saveLocalDaily(hotelId, daily);
}
@SysLog("本地酒店删除日历")
@DeleteMapping("/local/{hotelId}/daily/{dailyId}")
@PreAuthorize("@pms.hasPermission('supermarket_smHotel_edit')")
public R<Boolean> removeLocalDaily(@PathVariable Long hotelId, @PathVariable Long dailyId) {
return midService.removeLocalDaily(hotelId, dailyId);
}
@SysLog("手动执行滚一天房价")
@@ -7,11 +7,11 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
@@ -65,19 +65,8 @@ public class ShangyouCallbackQueueWorker implements ApplicationRunner {
int consecutiveFail = 0;
while (running.get()) {
try {
// BRPOP 多 key:先高优(订单状态)再普通;阻塞期间新到的高优也会被优先弹出
// ListOperations 没有多 key 阻塞弹出,走底层连接命令 bRPop(秒, keys...),返回 [key, value]
// lambda 参数必须显式声明 RedisConnection,否则 execute 在 RedisCallback/SessionCallback 两个重载间产生歧义
String taskId = redisTemplate.execute((RedisConnection connection) -> {
List<byte[]> popped = connection.listCommands().bRPop(5,
ShangyouHotelApi.REDIS_CALLBACK_QUEUE_HIGH.getBytes(StandardCharsets.UTF_8),
ShangyouHotelApi.REDIS_CALLBACK_QUEUE.getBytes(StandardCharsets.UTF_8));
return popped == null || popped.size() < 2 ? null
: new String(popped.get(1), StandardCharsets.UTF_8);
});
if (taskId != null) {
taskId = taskId.trim();
}
// 底层 BRPOP 多 key:先高优再普通(ListOperations 无多 key 重载)
String taskId = brpopPreferHigh(5);
if (taskId == null || taskId.isBlank() || "null".equalsIgnoreCase(taskId)) {
continue;
}
@@ -130,6 +119,28 @@ public class ShangyouCallbackQueueWorker implements ApplicationRunner {
}
}
/**
* Redis BRPOP:按 key 顺序优先弹出高优队列。
* 当前 Spring Data Redis 的 {@code ListOperations#rightPop} 不支持多 key,走 connection 原生命令。
*/
@SuppressWarnings("unchecked")
private String brpopPreferHigh(int timeoutSeconds) {
RedisSerializer<String> keySer = (RedisSerializer<String>) redisTemplate.getKeySerializer();
RedisSerializer<String> valSer = (RedisSerializer<String>) redisTemplate.getValueSerializer();
byte[] highKey = keySer.serialize(ShangyouHotelApi.REDIS_CALLBACK_QUEUE_HIGH);
byte[] normalKey = keySer.serialize(ShangyouHotelApi.REDIS_CALLBACK_QUEUE);
if (highKey == null || normalKey == null) {
return null;
}
List<byte[]> popped = redisTemplate.execute((RedisCallback<List<byte[]>>) connection ->
connection.listCommands().bRPop(timeoutSeconds, highKey, normalKey));
if (popped == null || popped.size() < 2 || popped.get(1) == null) {
return null;
}
String taskId = valSer.deserialize(popped.get(1));
return taskId == null ? null : taskId.trim();
}
/** 不依赖 Worker 空闲:定时扫卡死的 RUNNING */
private void watchdogLoop() {
while (running.get()) {
@@ -59,6 +59,9 @@ public final class ShangyouHotelApi {
/** 差集:本地有上游无(疑似下线) */
public static final String REDIS_DIFF_OFFLINE_SET = "supermarket:shangyou:hotel:diff:offline:v1";
/** 列表同步/差集导入所选数据来源(字典值) */
public static final String REDIS_IMPORT_DATA_SOURCE = "supermarket:shangyou:hotel:import:dataSource";
/** 今日全量房价同步次数 */
public static final String REDIS_FULL_PRICE_SYNC_COUNT = "supermarket:shangyou:hotel:fullprice:count";
@@ -2,6 +2,8 @@ package com.bsd.pjcloud.supermarket.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.supermarket.entity.SmHotelDailyEntity;
import com.bsd.pjcloud.supermarket.entity.SmHotelProductEntity;
import com.bsd.pjcloud.supermarket.entity.SmHotelRoomEntity;
import com.bsd.pjcloud.supermarket.vo.SmHotelDetailVO;
import com.bsd.pjcloud.common.core.util.R;
@@ -56,6 +58,27 @@ public interface SmHotelMidService {
/** 重建本地 hotel_code Redis SET(分批,防 OOM);供差集与列表标记复用 */
long rebuildLocalHotelCodeSet();
/** 将选中的上游 hotelId 写入本地(不存在则新增) */
R<Map<String, Object>> importAuthorizedHotels(List<Map<String, Object>> hotels);
/**
* 将选中的上游 hotelId 写入本地(不存在则新增)。
* @param dataSource 数据来源字典值(必填,不可为 local)
*/
R<Map<String, Object>> importAuthorizedHotels(List<Map<String, Object>> hotels, String dataSource);
/** 本地数据来源:保存房型 */
R<SmHotelRoomEntity> saveLocalRoom(Long hotelId, SmHotelRoomEntity room);
/** 本地数据来源:删除房型 */
R<Boolean> removeLocalRoom(Long hotelId, Long roomId);
/** 本地数据来源:保存产品 */
R<SmHotelProductEntity> saveLocalProduct(Long hotelId, SmHotelProductEntity product);
/** 本地数据来源:删除产品 */
R<Boolean> removeLocalProduct(Long hotelId, Long productId);
/** 本地数据来源:保存日历行 */
R<SmHotelDailyEntity> saveLocalDaily(Long hotelId, SmHotelDailyEntity daily);
/** 本地数据来源:删除日历行 */
R<Boolean> removeLocalDaily(Long hotelId, Long dailyId);
}
@@ -12,7 +12,7 @@ public interface SmHotelService extends IService<SmHotelEntity> {
Page<SmHotelEntity> pageScope(Page<SmHotelEntity> page, LambdaQueryWrapper<SmHotelEntity> wrapper);
Boolean saveHotel(SmHotelEntity entity);
SmHotelEntity saveHotel(SmHotelEntity entity);
Boolean updateHotel(SmHotelEntity entity);
@@ -14,9 +14,10 @@ public interface SmHotelSyncService {
/**
* 提交列表同步(立刻返回 logId,后台异步执行)。
* @param autoImport 是否自动导入新增
* @param dataSource 自动导入时写入的数据来源(字典值);空则用配置 defaultImportDataSource
* @param triggerType MANUAL / XXL / CRON
*/
R<SmHotelSyncLogEntity> startListSyncAsync(boolean autoImport, String triggerType);
R<SmHotelSyncLogEntity> startListSyncAsync(boolean autoImport, String dataSource, String triggerType);
/** 查询同步任务进度/结果 */
R<SmHotelSyncLogEntity> getSyncLog(Long id);
@@ -27,8 +28,11 @@ public interface SmHotelSyncService {
/** 分页查看差集:type=new|offline */
R<Map<String, Object>> pageHotelDiff(String type, long current, long size);
/** 把差集「新增」全部/限额导入本地主数据 */
R<Map<String, Object>> importDiffNewHotels(Integer limit);
/**
* 把差集「新增」全部/限额导入本地主数据。
* @param dataSource 数据来源字典值;空则读 Redis 中同步时暂存的来源
*/
R<Map<String, Object>> importDiffNewHotels(Integer limit, String dataSource);
/** 提交滚价异步任务 */
R<SmHotelSyncLogEntity> startPriceRollAsync(String triggerType);
@@ -53,8 +57,8 @@ public interface SmHotelSyncService {
*/
int abortStaleRunningSyncLogs();
/** 兼容旧同步入口(内部转异步) */
R<Map<String, Object>> syncHotelListAndDiff(boolean autoImport);
/** 兼容旧同步入口(内部转异步);人工同步须传 dataSource */
R<Map<String, Object>> syncHotelListAndDiff(boolean autoImport, String dataSource);
/** 当前日更时间配置(给前端展示) */
Map<String, Object> getSyncSchedule();
@@ -10,6 +10,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.toolkit.Db;
import com.bsd.pjcloud.supermarket.config.HotelDownstreamProperties;
import com.bsd.pjcloud.supermarket.config.ShangyouHotelProperties;
import com.bsd.pjcloud.supermarket.constant.SmHotelDataSource;
import com.bsd.pjcloud.supermarket.entity.*;
import com.bsd.pjcloud.supermarket.integration.downstream.DownstreamHotelClient;
import com.bsd.pjcloud.supermarket.integration.shangyou.ShangyouHotelApi;
@@ -603,10 +604,17 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
@Override
@Transactional(rollbackFor = Exception.class)
public R<Map<String, Object>> importAuthorizedHotels(List<Map<String, Object>> hotels) {
public R<Map<String, Object>> importAuthorizedHotels(List<Map<String, Object>> hotels, String dataSource) {
if (CollUtil.isEmpty(hotels)) {
return R.failed("请选择要导入的酒店");
}
String source = StrUtil.trim(dataSource);
if (StrUtil.isBlank(source)) {
return R.failed("请选择数据来源");
}
if (SmHotelDataSource.isLocal(source)) {
return R.failed("上游同步不能使用「本地」数据来源,请选择上游类来源");
}
int insert = 0;
int skip = 0;
for (Map<String, Object> row : hotels) {
@@ -625,7 +633,7 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
SmHotelEntity hotel = new SmHotelEntity();
hotel.setHotelCode(hotelId);
hotel.setHotelName(StrUtil.blankToDefault(hotelName, hotelId));
hotel.setSheetName("上游授权列表");
hotel.setSheetName(source);
hotel.setUpstreamStatus("ACTIVE");
hotelMapper.insert(hotel);
stringRedisTemplate.opsForSet().add(ShangyouHotelApi.REDIS_LOCAL_CODE_SET, hotelId);
@@ -634,9 +642,186 @@ public class SmHotelMidServiceImpl implements SmHotelMidService {
Map<String, Object> result = new HashMap<>(4);
result.put("insertCount", insert);
result.put("skipCount", skip);
result.put("dataSource", source);
return R.ok(result);
}
@Override
@Transactional(rollbackFor = Exception.class)
public R<SmHotelRoomEntity> saveLocalRoom(Long hotelId, SmHotelRoomEntity room) {
SmHotelEntity hotel = requireLocalHotel(hotelId);
if (room == null || StrUtil.isBlank(room.getRoomTypeCode())) {
return R.failed("房型编码不能为空");
}
room.setHotelId(hotel.getId());
room.setHotelCode(hotel.getHotelCode());
room.setGroupCode(hotel.getGroupCode());
if (room.getId() == null) {
Long exists = roomMapper.selectCount(Wrappers.<SmHotelRoomEntity>lambdaQuery()
.eq(SmHotelRoomEntity::getHotelId, hotelId)
.eq(SmHotelRoomEntity::getRoomTypeCode, room.getRoomTypeCode().trim()));
if (exists != null && exists > 0) {
return R.failed("房型编码已存在:" + room.getRoomTypeCode());
}
room.setRoomTypeCode(room.getRoomTypeCode().trim());
roomMapper.insert(room);
}
else {
SmHotelRoomEntity old = roomMapper.selectById(room.getId());
if (old == null || !Objects.equals(old.getHotelId(), hotelId)) {
return R.failed("房型不存在");
}
room.setRoomTypeCode(room.getRoomTypeCode().trim());
roomMapper.updateById(room);
}
return R.ok(roomMapper.selectById(room.getId()));
}
@Override
@Transactional(rollbackFor = Exception.class)
public R<Boolean> removeLocalRoom(Long hotelId, Long roomId) {
requireLocalHotel(hotelId);
SmHotelRoomEntity old = roomMapper.selectById(roomId);
if (old == null || !Objects.equals(old.getHotelId(), hotelId)) {
return R.failed("房型不存在");
}
Long productCnt = productMapper.selectCount(Wrappers.<SmHotelProductEntity>lambdaQuery()
.eq(SmHotelProductEntity::getHotelId, hotelId)
.eq(SmHotelProductEntity::getRoomTypeCode, old.getRoomTypeCode()));
if (productCnt != null && productCnt > 0) {
return R.failed("请先删除该房型下的产品");
}
return R.ok(roomMapper.deleteById(roomId) > 0);
}
@Override
@Transactional(rollbackFor = Exception.class)
public R<SmHotelProductEntity> saveLocalProduct(Long hotelId, SmHotelProductEntity product) {
SmHotelEntity hotel = requireLocalHotel(hotelId);
if (product == null || StrUtil.isBlank(product.getProductCode())) {
return R.failed("产品编码不能为空");
}
if (StrUtil.isBlank(product.getRoomTypeCode())) {
return R.failed("所属房型编码不能为空");
}
Long roomCnt = roomMapper.selectCount(Wrappers.<SmHotelRoomEntity>lambdaQuery()
.eq(SmHotelRoomEntity::getHotelId, hotelId)
.eq(SmHotelRoomEntity::getRoomTypeCode, product.getRoomTypeCode().trim()));
if (roomCnt == null || roomCnt <= 0) {
return R.failed("所属房型不存在,请先维护房型");
}
product.setHotelId(hotel.getId());
product.setHotelCode(hotel.getHotelCode());
product.setGroupCode(hotel.getGroupCode());
product.setProductCode(product.getProductCode().trim());
product.setRoomTypeCode(product.getRoomTypeCode().trim());
SmHotelRoomEntity room = roomMapper.selectOne(Wrappers.<SmHotelRoomEntity>lambdaQuery()
.eq(SmHotelRoomEntity::getHotelId, hotelId)
.eq(SmHotelRoomEntity::getRoomTypeCode, product.getRoomTypeCode())
.last("LIMIT 1"));
if (room != null) {
product.setRoomId(room.getId());
}
if (product.getId() == null) {
Long exists = productMapper.selectCount(Wrappers.<SmHotelProductEntity>lambdaQuery()
.eq(SmHotelProductEntity::getHotelId, hotelId)
.eq(SmHotelProductEntity::getProductCode, product.getProductCode()));
if (exists != null && exists > 0) {
return R.failed("产品编码已存在:" + product.getProductCode());
}
productMapper.insert(product);
}
else {
SmHotelProductEntity old = productMapper.selectById(product.getId());
if (old == null || !Objects.equals(old.getHotelId(), hotelId)) {
return R.failed("产品不存在");
}
productMapper.updateById(product);
}
return R.ok(productMapper.selectById(product.getId()));
}
@Override
@Transactional(rollbackFor = Exception.class)
public R<Boolean> removeLocalProduct(Long hotelId, Long productId) {
requireLocalHotel(hotelId);
SmHotelProductEntity old = productMapper.selectById(productId);
if (old == null || !Objects.equals(old.getHotelId(), hotelId)) {
return R.failed("产品不存在");
}
dailyMapper.delete(Wrappers.<SmHotelDailyEntity>lambdaQuery()
.eq(SmHotelDailyEntity::getHotelId, hotelId)
.eq(SmHotelDailyEntity::getProductId, productId));
return R.ok(productMapper.deleteById(productId) > 0);
}
@Override
@Transactional(rollbackFor = Exception.class)
public R<SmHotelDailyEntity> saveLocalDaily(Long hotelId, SmHotelDailyEntity daily) {
SmHotelEntity hotel = requireLocalHotel(hotelId);
if (daily == null || daily.getStayDate() == null) {
return R.failed("入住日期不能为空");
}
if (StrUtil.isBlank(daily.getProductCode())) {
return R.failed("产品编码不能为空");
}
SmHotelProductEntity product = productMapper.selectOne(Wrappers.<SmHotelProductEntity>lambdaQuery()
.eq(SmHotelProductEntity::getHotelId, hotelId)
.eq(SmHotelProductEntity::getProductCode, daily.getProductCode().trim())
.last("LIMIT 1"));
if (product == null) {
return R.failed("产品不存在,请先维护产品");
}
daily.setHotelId(hotel.getId());
daily.setProductId(product.getId());
daily.setProductCode(product.getProductCode());
daily.setRoomTypeCode(product.getRoomTypeCode());
if (daily.getDailyStatus() == null) {
daily.setDailyStatus(1);
}
if (daily.getId() == null) {
SmHotelDailyEntity exist = dailyMapper.selectOne(Wrappers.<SmHotelDailyEntity>lambdaQuery()
.eq(SmHotelDailyEntity::getHotelId, hotelId)
.eq(SmHotelDailyEntity::getProductCode, daily.getProductCode())
.eq(SmHotelDailyEntity::getStayDate, daily.getStayDate())
.last("LIMIT 1"));
if (exist != null) {
daily.setId(exist.getId());
dailyMapper.updateById(daily);
}
else {
dailyMapper.insert(daily);
}
}
else {
SmHotelDailyEntity old = dailyMapper.selectById(daily.getId());
if (old == null || !Objects.equals(old.getHotelId(), hotelId)) {
return R.failed("日历行不存在");
}
dailyMapper.updateById(daily);
}
return R.ok(dailyMapper.selectById(daily.getId()));
}
@Override
@Transactional(rollbackFor = Exception.class)
public R<Boolean> removeLocalDaily(Long hotelId, Long dailyId) {
requireLocalHotel(hotelId);
SmHotelDailyEntity old = dailyMapper.selectById(dailyId);
if (old == null || !Objects.equals(old.getHotelId(), hotelId)) {
return R.failed("日历行不存在");
}
return R.ok(dailyMapper.deleteById(dailyId) > 0);
}
private SmHotelEntity requireLocalHotel(Long hotelId) {
SmHotelEntity hotel = requireHotel(hotelId);
if (!SmHotelDataSource.isLocal(hotel.getSheetName())) {
throw new IllegalArgumentException("仅「本地」数据来源的酒店可手工维护房型/产品/日历");
}
return hotel;
}
private SmHotelEntity requireHotel(Long hotelId) {
if (hotelId == null) {
throw new IllegalArgumentException("酒店ID不能为空");
@@ -14,6 +14,7 @@ import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.bsd.pjcloud.supermarket.constant.SmHotelDataSource;
import com.bsd.pjcloud.supermarket.entity.SmHotelEntity;
import com.bsd.pjcloud.supermarket.mapper.SmHotelDailyMapper;
import com.bsd.pjcloud.supermarket.mapper.SmHotelMapper;
@@ -111,7 +112,7 @@ public class SmHotelServiceImpl extends ServiceImpl<SmHotelMapper, SmHotelEntity
@Override
@Transactional(rollbackFor = Exception.class)
public Boolean saveHotel(SmHotelEntity entity) {
public SmHotelEntity saveHotel(SmHotelEntity entity) {
normalize(entity);
if (StrUtil.isBlank(entity.getHotelName()) && StrUtil.isBlank(entity.getHotelCode())) {
throw new IllegalArgumentException("酒店名称与酒店编码不能同时为空");
@@ -119,10 +120,18 @@ public class SmHotelServiceImpl extends ServiceImpl<SmHotelMapper, SmHotelEntity
if (StrUtil.isNotBlank(entity.getHotelCode()) && existsHotelCode(entity.getHotelCode(), null)) {
throw new IllegalArgumentException("酒店编码已存在:" + entity.getHotelCode());
}
// 手工新增默认数据来源=本地(字典 sm_hotel_data_source.local)
if (StrUtil.isBlank(entity.getSheetName())) {
entity.setSheetName(SmHotelDataSource.LOCAL);
}
if (StrUtil.isBlank(entity.getUpstreamStatus())) {
entity.setUpstreamStatus("ACTIVE");
}
if (entity.getDeptId() == null) {
entity.setDeptId(SecurityUtils.getUser().getDeptId());
}
return baseMapper.insert(entity) > 0;
baseMapper.insert(entity);
return entity;
}
@Override
@@ -142,6 +151,10 @@ public class SmHotelServiceImpl extends ServiceImpl<SmHotelMapper, SmHotelEntity
if (StrUtil.isNotBlank(entity.getHotelCode()) && existsHotelCode(entity.getHotelCode(), entity.getId())) {
throw new IllegalArgumentException("酒店编码已存在:" + entity.getHotelCode());
}
// 未传数据来源时保留原值,避免覆盖
if (StrUtil.isBlank(entity.getSheetName())) {
entity.setSheetName(old.getSheetName());
}
return baseMapper.updateById(entity) > 0;
}
@@ -378,6 +391,11 @@ public class SmHotelServiceImpl extends ServiceImpl<SmHotelMapper, SmHotelEntity
entity.setHotelName(trimToNull(entity.getHotelName()));
entity.setSheetName(trimToNull(entity.getSheetName()));
entity.setRemark(trimToNull(entity.getRemark()));
entity.setCoverImage(trimToNull(entity.getCoverImage()));
entity.setCarouselImages(trimToNull(entity.getCarouselImages()));
entity.setAddress(trimToNull(entity.getAddress()));
entity.setPhone(trimToNull(entity.getPhone()));
entity.setIntro(trimToNull(entity.getIntro()));
}
private static String trimToNull(String v) {
@@ -5,6 +5,7 @@ import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.bsd.pjcloud.supermarket.config.ShangyouHotelProperties;
import com.bsd.pjcloud.supermarket.constant.SmHotelDataSource;
import com.bsd.pjcloud.supermarket.entity.SmHotelEntity;
import com.bsd.pjcloud.supermarket.entity.SmHotelSyncLogEntity;
import com.bsd.pjcloud.supermarket.integration.shangyou.ShangyouHotelApi;
@@ -76,7 +77,7 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
private final StringRedisTemplate redisTemplate;
@Override
public R<SmHotelSyncLogEntity> startListSyncAsync(boolean autoImport, String triggerType) {
public R<SmHotelSyncLogEntity> startListSyncAsync(boolean autoImport, String dataSource, String triggerType) {
abortStaleRunningSyncLogs();
Long running = syncLogMapper.selectCount(Wrappers.<SmHotelSyncLogEntity>lambdaQuery()
.eq(SmHotelSyncLogEntity::getSyncType, TYPE_LIST)
@@ -84,9 +85,19 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
if (running != null && running > 0) {
return R.failed("已有列表同步任务进行中,请稍候查看进度(若长时间卡住请在同步日志点「终止」)");
}
String source = resolveImportDataSource(dataSource, triggerType);
if (StrUtil.isBlank(source)) {
return R.failed("请选择数据来源(字典 sm_hotel_data_source)");
}
if (SmHotelDataSource.isLocal(source)) {
return R.failed("上游同步不能使用「本地」数据来源");
}
// 供差集「补导剩余新增」复用本次选择的来源
redisTemplate.opsForValue().set(ShangyouHotelApi.REDIS_IMPORT_DATA_SOURCE, source, 2, TimeUnit.DAYS);
SmHotelSyncLogEntity logEntity = newLog(TYPE_LIST, triggerType);
logEntity.setStage("QUEUED");
logEntity.setStageMsg("已提交,等待执行…");
logEntity.setStageMsg("已提交,数据来源=" + source + ",等待执行…");
logEntity.setProgressPct(1);
syncLogMapper.insert(logEntity);
@@ -97,7 +108,8 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
final Long tid = tenantId;
final Long logId = logEntity.getId();
final boolean doImport = autoImport;
EXECUTOR.execute(() -> TenantBroker.runAs(tid, id -> runListSync(logId, doImport)));
final String importSource = source;
EXECUTOR.execute(() -> TenantBroker.runAs(tid, id -> runListSync(logId, doImport, importSource)));
return R.ok(logEntity, "已提交异步同步");
}
@@ -274,8 +286,8 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
}
@Override
public R<Map<String, Object>> syncHotelListAndDiff(boolean autoImport) {
R<SmHotelSyncLogEntity> started = startListSyncAsync(autoImport, "MANUAL");
public R<Map<String, Object>> syncHotelListAndDiff(boolean autoImport, String dataSource) {
R<SmHotelSyncLogEntity> started = startListSyncAsync(autoImport, dataSource, "MANUAL");
if (started.getCode() != 0) {
return R.failed(started.getMsg());
}
@@ -283,11 +295,12 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
data.put("async", true);
data.put("logId", started.getData().getId());
data.put("status", started.getData().getStatus());
data.put("dataSource", StrUtil.trim(dataSource));
data.put("message", "已异步执行,请轮询 /mid/syncLog/{id} 或打开同步日志");
return R.ok(data, started.getMsg());
}
private void runListSync(Long logId, boolean autoImport) {
private void runListSync(Long logId, boolean autoImport, String dataSource) {
long t0 = System.currentTimeMillis();
try {
progress(logId, 5, "REFRESH_LIST", "正在拉取上游授权酒店列表…");
@@ -318,9 +331,9 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
}
int pct = Math.min(78, 55 + round);
progress(logId, pct, "IMPORT_NEW",
"正在自动导入新增酒店…已导入 " + imported + ",剩余约 " + remain
+ "(每批 " + batchSize + ")");
R<Map<String, Object>> imp = importDiffNewHotels(batchSize);
"正在自动导入新增酒店(来源 " + dataSource + ")…已导入 " + imported + ",剩余约 "
+ remain + "(每批 " + batchSize + ")");
R<Map<String, Object>> imp = importDiffNewHotels(batchSize, dataSource);
int n = 0;
if (imp != null && imp.getData() != null && imp.getData().get("insertCount") != null) {
n = Integer.parseInt(String.valueOf(imp.getData().get("insertCount")));
@@ -947,7 +960,17 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
}
@Override
public R<Map<String, Object>> importDiffNewHotels(Integer limit) {
public R<Map<String, Object>> importDiffNewHotels(Integer limit, String dataSource) {
String source = StrUtil.trim(dataSource);
if (StrUtil.isBlank(source)) {
source = redisTemplate.opsForValue().get(ShangyouHotelApi.REDIS_IMPORT_DATA_SOURCE);
}
if (StrUtil.isBlank(source)) {
source = StrUtil.trim(properties.getDefaultImportDataSource());
}
if (StrUtil.isBlank(source)) {
return R.failed("请选择数据来源后再导入");
}
int max = limit == null || limit <= 0 ? properties.getAutoImportBatchSize() : limit;
max = Math.min(Math.max(max, 1), 5000);
List<Map<String, Object>> batch = new ArrayList<>();
@@ -973,7 +996,7 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
empty.put("skipCount", 0);
return R.ok(empty, "无待导入酒店");
}
R<Map<String, Object>> r = midService.importAuthorizedHotels(batch);
R<Map<String, Object>> r = midService.importAuthorizedHotels(batch, source);
if (r != null && r.getCode() == 0) {
for (Map<String, Object> row : batch) {
Object id = row.get("hotelId");
@@ -986,6 +1009,18 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
return r;
}
/** 人工同步必须显式传;定时任务用配置 defaultImportDataSource */
private String resolveImportDataSource(String dataSource, String triggerType) {
String source = StrUtil.trim(dataSource);
if (StrUtil.isNotBlank(source)) {
return source;
}
if (!"MANUAL".equalsIgnoreCase(StrUtil.trim(triggerType))) {
return StrUtil.trim(properties.getDefaultImportDataSource());
}
return null;
}
@Override
public Map<String, Object> getSyncSchedule() {
Map<String, Object> m = new LinkedHashMap<>();
@@ -1014,7 +1049,7 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService {
return Map.of("skipped", true, "reason", "hotel-list-daily-sync-enabled=false");
}
R<SmHotelSyncLogEntity> r = startListSyncAsync(properties.isAutoImportNewHotels(),
StrUtil.blankToDefault(triggerType, "XXL"));
properties.getDefaultImportDataSource(), StrUtil.blankToDefault(triggerType, "XXL"));
if (r.getCode() != 0) {
return Map.of("ok", false, "msg", r.getMsg());
}