From 49ba5cea2982a7f0fab92d638cbf36b536f62dac Mon Sep 17 00:00:00 2001 From: zjf <1799650030@qq.com> Date: Thu, 8 Oct 2026 17:11:42 +0800 Subject: [PATCH] =?UTF-8?q?1.=E6=AF=8F=E6=97=A5=E8=8F=9C=E5=93=81=E8=B0=83?= =?UTF-8?q?=E6=95=B4=E4=BF=AE=E6=94=B9=202.=E9=85=92=E5=BA=97=E8=B0=83?= =?UTF-8?q?=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/CanteenDailyMenuImportExcel.java | 53 ++-- .../dto/CanteenSpuUpdateStockReqDTO.java | 4 +- .../canteen/entity/CanteenCategoryEntity.java | 4 +- .../entity/CanteenDailyMenuEntity.java | 14 +- .../canteen/entity/CanteenSpuEntity.java | 2 +- .../vo/CanteenDailyMenuImportPreviewVO.java | 9 +- .../pjcloud/canteen/vo/DailyMenuAdminVO.java | 26 +- .../bsd/pjcloud/canteen/vo/DailyMenuVO.java | 2 +- .../pjcloud/canteen/vo/SpuPrintNameVo.java | 2 +- .../canteen/vo/app/DailyMenuAppVO.java | 14 +- .../controller/CanteenCategoryController.java | 10 +- .../CanteenOnlineCategoryController.java | 5 +- .../CanteenDailyMenuTemplateMergeHandler.java | 39 +-- .../impl/CanteenDailyMenuImportHelper.java | 230 ++++++++++++---- .../impl/CanteenDailyMenuServiceImpl.java | 53 +++- .../impl/CanteenDailyMenuStockSupport.java | 248 +++++++++++++++--- .../service/impl/CanteenSpuServiceImpl.java | 188 +++++++++++-- .../mapper/CanteenDailyMenuMapper.xml | 32 ++- .../SmHotelCallbackTaskController.java | 4 +- .../shangyou/ShangyouCallbackQueueWorker.java | 38 ++- 20 files changed, 776 insertions(+), 201 deletions(-) diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenDailyMenuImportExcel.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenDailyMenuImportExcel.java index 541824a2e..fde920572 100644 --- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenDailyMenuImportExcel.java +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenDailyMenuImportExcel.java @@ -7,11 +7,9 @@ import java.math.BigDecimal; /** * 每日菜谱导入/导出 Excel 行(导出含菜谱 ID,导入时可按 ID 更新) - *

列顺序:日期、餐段、套餐名、菜品名、分类、单价、套餐价、线上库存、线下库存、配料、菜谱ID、菜品SPU ID、子SPU ID、填写说明 - *

线上/线下库存挂在每日菜谱行上,不再写入菜品列表 {@code canteen_spu.stock}。 + *

列顺序:日期、餐段、套餐名/别名、类型、菜品名、分类、单价、套餐价、总库存、移动端可预定数量、配料、菜谱ID、菜品SPU ID、子SPU ID、填写说明 + *

库存:总库存→{@code offline_stock};移动端可预定数量→{@code online_stock};消费机可售=总库存−移动端已预订。 *

菜谱ID / 菜品SPU ID / 子SPU ID 必须用文本导出(带制表符前缀),否则 Excel 对超长雪花 ID 会丢精度。 - *

业务列与 ID 列导出时每行写全,均不合并。 - *

无「菜品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; } diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenSpuUpdateStockReqDTO.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenSpuUpdateStockReqDTO.java index 8e27c48fb..6ef408ff2 100644 --- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenSpuUpdateStockReqDTO.java +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/dto/CanteenSpuUpdateStockReqDTO.java @@ -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; diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenCategoryEntity.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenCategoryEntity.java index 0d520452e..4aef1cfc7 100644 --- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenCategoryEntity.java +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenCategoryEntity.java @@ -54,9 +54,9 @@ public class CanteenCategoryEntity extends Model { private String picUrl; /** - * 分类排序 + * 分类序号(越大越靠前) */ - @Schema(description="分类排序") + @Schema(description="分类序号,越大越靠前") private Integer sort; /** diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenDailyMenuEntity.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenDailyMenuEntity.java index c173cd4c2..4a57aebb5 100644 --- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenDailyMenuEntity.java +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenDailyMenuEntity.java @@ -43,6 +43,12 @@ public class CanteenDailyMenuEntity extends Model { @Schema(description="商品 SPU 编号") private Long spuId; + /** + * 本排菜展示名:套餐名或单品别名(仅挂菜谱行,不写回菜品) + */ + @Schema(description = "套餐名或单品别名") + private String displayName; + /** * 归属时间 1早餐 2午餐 3晚餐 */ @@ -50,15 +56,15 @@ public class CanteenDailyMenuEntity extends Model { private Integer hourType; /** - * 线上可预约库存配额(人工设置;下单不改;剩余=配额−onlineSold) + * 移动端可预订数量上限(人工;下单不改) */ - @Schema(description = "线上可预约库存配额") + @Schema(description = "移动端可预订数量上限") private Integer onlineStock; /** - * 线下销售库存配额(人工设置;下单不改;剩余=配额−offlineSold) + * 总库存(人工;下单不改)。消费机可售 = 总库存 − 移动端已预订。 */ - @Schema(description = "线下销售库存配额") + @Schema(description = "总库存") private Integer offlineStock; /** diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenSpuEntity.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenSpuEntity.java index ac5c94838..3c0c46104 100644 --- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenSpuEntity.java +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/CanteenSpuEntity.java @@ -46,7 +46,7 @@ public class CanteenSpuEntity extends Model { private String nameEn; /** - * 套餐名(仅 spuType=2 时使用) + * 套餐名(仅 spuType=2 套餐本体;每日排菜展示名见 canteen_daily_menu.display_name) */ @Schema(description = "套餐名") private String comboName; diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/CanteenDailyMenuImportPreviewVO.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/CanteenDailyMenuImportPreviewVO.java index 1d4b27102..d861b49c0 100644 --- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/CanteenDailyMenuImportPreviewVO.java +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/CanteenDailyMenuImportPreviewVO.java @@ -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 行数。 */ diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/DailyMenuAdminVO.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/DailyMenuAdminVO.java index 957122a0a..b8069a8d3 100644 --- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/DailyMenuAdminVO.java +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/DailyMenuAdminVO.java @@ -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; diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/DailyMenuVO.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/DailyMenuVO.java index ed20e4797..2576cc0c0 100644 --- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/DailyMenuVO.java +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/DailyMenuVO.java @@ -54,7 +54,7 @@ public class DailyMenuVO { private Integer spuType; /** - * 套餐名(canteen_spu.combo_name) + * 套餐名或单品别名(canteen_spu.combo_name) */ private String comboName; diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/SpuPrintNameVo.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/SpuPrintNameVo.java index e44f2d44b..3ecdc186f 100644 --- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/SpuPrintNameVo.java +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/SpuPrintNameVo.java @@ -27,7 +27,7 @@ public class SpuPrintNameVo { private Long productId; /** - * 套餐名(仅 spuType=2 套餐有值),打印凭证优先展示。 + * 套餐名(canteen_spu.combo_name);每日排菜别名见 display_name,打印若需排菜名需另查菜谱。 */ @Schema(description = "套餐名") private String comboName; diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/app/DailyMenuAppVO.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/app/DailyMenuAppVO.java index 054402af2..fe00ceef7 100644 --- a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/app/DailyMenuAppVO.java +++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/vo/app/DailyMenuAppVO.java @@ -93,26 +93,26 @@ public class DailyMenuAppVO { /** * 当前可售库存。 - *

App/移动端:线上剩余;安卓消费机 AD 接口:线下剩余。字段名保持 stock 兼容旧客户端。 + *

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; /** diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenCategoryController.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenCategoryController.java index 554e76954..84ac42a10 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenCategoryController.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenCategoryController.java @@ -55,6 +55,8 @@ public class CanteenCategoryController { LambdaQueryWrapper 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 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 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 list = canteenCategoryService.listScope(wrapper); return R.ok(list); diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenOnlineCategoryController.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenOnlineCategoryController.java index 5300b5309..66830c4f3 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenOnlineCategoryController.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/CanteenOnlineCategoryController.java @@ -56,6 +56,8 @@ public class CanteenOnlineCategoryController { LambdaQueryWrapper 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 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 list = canteenOnlineCategoryService.list(wrapper); diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/excel/CanteenDailyMenuTemplateMergeHandler.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/excel/CanteenDailyMenuTemplateMergeHandler.java index 9fa844628..03b7b0a7d 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/excel/CanteenDailyMenuTemplateMergeHandler.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/excel/CanteenDailyMenuTemplateMergeHandler.java @@ -12,8 +12,8 @@ import org.apache.poi.ss.usermodel.VerticalAlignment; /** * 每日菜谱导入模板 / 导出写后处理。 - *

列:0日期 1餐段 2套餐名 3菜品名 4分类 5单价 6套餐价 7线上库存 8线下库存 9配料 - * 10菜谱ID 11菜品SPU ID 12子SPU ID 13填写说明 + *

列:0日期 1餐段 2套餐名/别名 3类型 4菜品名 5分类 6单价 7套餐价 8总库存 9移动端可预定数量 10配料 + * 11菜谱ID 12菜品SPU ID 13子SPU ID 14填写说明 *

业务列每行写全、不合并。雪花 ID 三列也每行写全、不合并 * (合并会导致 EasyExcel 读空;Excel 把 19 位 ID 当数字会丢精度并串号)。 *

须配合 {@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); } } diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuImportHelper.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuImportHelper.java index 27e88bcc3..6e28f7bb4 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuImportHelper.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuImportHelper.java @@ -97,26 +97,28 @@ public class CanteenDailyMenuImportHelper { public List templateRows() { List 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 clearedDayHour, ImportCache cache, - List pendingMenus, Integer onlineStock, Integer offlineStock) { + List pendingMenus, Integer onlineStock, Integer offlineStock, String displayName) { String dh = date + "|" + hourType; if (replaceDay && !clearedDayHour.contains(dh)) { canteenDailyMenuService.remove(Wrappers.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 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 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 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; + } + /** * 解析/创建单品。 *

有「菜品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; } diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuServiceImpl.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuServiceImpl.java index f2234db08..d8b411975 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuServiceImpl.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenDailyMenuServiceImpl.java @@ -543,6 +543,54 @@ public class CanteenDailyMenuServiceImpl extends ServiceImpl menuSpuIds = menus.stream().map(CanteenDailyMenuEntity::getSpuId).filter(Objects::nonNull) + .collect(Collectors.toSet()); + Map> comboMap = + canteenSpuService.loadComboItemVos(new ArrayList<>(menuSpuIds)); + Set menuKeys = menus.stream() + .filter(m -> m.getHourType() != null && m.getSpuId() != null) + .map(m -> m.getHourType() + "|" + m.getSpuId()) + .collect(Collectors.toSet()); + Map linkedSold = new HashMap<>(); + for (Map.Entry 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 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 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 toUpdate = new ArrayList<>(); for (CanteenDailyMenuEntity menu : menus) { if (menu.getHourType() == null || menu.getSpuId() == null) { @@ -641,8 +689,8 @@ public class CanteenDailyMenuServiceImpl extends ServiceImpl - * {@code online_stock}/{@code offline_stock} 为人工配额(下单不改); - * {@code online_sold}/{@code offline_sold} 为净销量;剩余 = 配额 − 销量。 - * App 的 {@code stock}=线上剩余,安卓 AD 的 {@code stock}=线下剩余。 + * 库字段约定(不改表结构): + *

+ * 共享总库存:线上未订完可继续线下卖。 + *
+ * 剩余总库存 = max(0, 总库存 − 移动端已预订 − 消费机已销售)
+ * 移动端可预订剩余 = min(移动端上限 − 已预订, 剩余总库存)
+ * AD stock = max(0, 总库存 − 移动端已预订)
+ * 
+ * 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 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 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 rows, Long merchantId) { @@ -87,45 +154,158 @@ public class CanteenDailyMenuStockSupport { } } - /** PC:配额与剩余分列展示 */ + /** PC:总库存 / 移动端上限 / 已订与已售 / 剩余总库存 */ public void fillAdminRemain(List rows, Long merchantId, LocalDate mealDate) { if (CollUtil.isEmpty(rows)) { return; } + Map 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 rows, boolean stockAsOnline) { + if (CollUtil.isEmpty(rows)) { + return; + } + Map 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 items, Map 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 items, Map 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 rows, List 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; diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImpl.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImpl.java index 9a2d6766c..4e42cc7d2 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImpl.java +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/CanteenSpuServiceImpl.java @@ -84,6 +84,8 @@ public class CanteenSpuServiceImpl extends ServiceImpl items = expandDailyMenuLinkedStockItems(dto.getMerchantId(), offline, + dto.getItems(), false); + if (CollUtil.isEmpty(items)) { + return R.failed(MsgUtils.getMessage(ErrorCodes.CANTEEN_STOCK_ITEMS_EMPTY)); + } + Map 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 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 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 items = expandDailyMenuLinkedStockItems(dto.getMerchantId(), offline, + dto.getItems(), true); + Map 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 0 ? null : item.getBeforeStock(); updated = offline ? canteenDailyMenuMapper.updateOfflineStockDelta(dto.getMerchantId(), item.getMealDate(), @@ -341,6 +386,113 @@ public class CanteenSpuServiceImpl extends ServiceImpl expandDailyMenuLinkedStockItems(Long merchantId, boolean offline, + List source, boolean fillMissingBeforeStock) { + if (CollUtil.isEmpty(source)) { + return List.of(); + } + Map 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 spuIds = source.stream().filter(Objects::nonNull).map(CanteenSpuUpdateStockReqDTO.Item::getId) + .filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new)); + Map spuMap = spuIds.isEmpty() ? Map.of() + : listByIds(spuIds).stream().collect(Collectors.toMap(CanteenSpuEntity::getId, s -> s, (a, b) -> a)); + List comboIds = spuMap.values().stream() + .filter(s -> s != null && Objects.equals(s.getSpuType(), 2) && s.getId() != null) + .map(CanteenSpuEntity::getId).distinct().collect(Collectors.toList()); + Map> comboMap = loadComboItems(comboIds); + + List 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 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 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 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.lambdaQuery() diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/mapper/CanteenDailyMenuMapper.xml b/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/mapper/CanteenDailyMenuMapper.xml index f0081ee1b..aeca63884 100644 --- a/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/mapper/CanteenDailyMenuMapper.xml +++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/resources/mapper/CanteenDailyMenuMapper.xml @@ -6,6 +6,7 @@ + @@ -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 等脏数据同样不得阻断; 负增量扣减保留原「不扣成负销量、不超配额」边界,语义不变。 --> + UPDATE canteen_daily_menu SET online_sold = GREATEST(IFNULL(online_sold, 0) - #{delta}, 0) @@ -361,9 +369,13 @@ AND IFNULL(online_sold, 0) - #{delta} >= 0 AND IFNULL(online_sold, 0) - #{delta} <= IFNULL(online_stock, 600) + AND IFNULL(online_sold, 0) + IFNULL(offline_sold, 0) - #{delta} <= IFNULL(offline_stock, 600) - 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} @@ -377,10 +389,10 @@ AND spu_id = #{spuId} AND IFNULL(offline_sold, 0) - #{delta} >= 0 - AND IFNULL(offline_sold, 0) - #{delta} <= IFNULL(offline_stock, 600) + AND IFNULL(online_sold, 0) + IFNULL(offline_sold, 0) - #{delta} <= IFNULL(offline_stock, 600) - AND (IFNULL(offline_stock, 600) - IFNULL(offline_sold, 0)) = #{expected} + AND GREATEST(IFNULL(offline_stock, 600) - IFNULL(online_sold, 0), 0) = #{expected} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelCallbackTaskController.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelCallbackTaskController.java index d87e01cc8..737e4e3cb 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelCallbackTaskController.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelCallbackTaskController.java @@ -61,7 +61,7 @@ public class SmHotelCallbackTaskController { @SysLog("按时间区间删除酒店回调任务") @Operation(summary = "按创建时间区间删除回调任务") - @DeleteMapping("/range") + @PostMapping("/range") @PreAuthorize("@pms.hasPermission('supermarket_smHotelCallback_del')") public R 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 clearAll() { return callbackTaskService.clearAllTasks(); diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/shangyou/ShangyouCallbackQueueWorker.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/shangyou/ShangyouCallbackQueueWorker.java index f10cef582..156399f48 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/shangyou/ShangyouCallbackQueueWorker.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/integration/shangyou/ShangyouCallbackQueueWorker.java @@ -7,10 +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.core.RedisCallback; import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.serializer.RedisSerializer; import org.springframework.stereotype.Component; -import java.time.Duration; import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; @@ -64,16 +65,9 @@ public class ShangyouCallbackQueueWorker implements ApplicationRunner { int consecutiveFail = 0; while (running.get()) { try { - // BRPOP 多 key:先高优(订单状态)再普通;阻塞期间新到的高优也会被优先弹出 - List popped = redisTemplate.opsForList().rightPop( - List.of(ShangyouHotelApi.REDIS_CALLBACK_QUEUE_HIGH, - ShangyouHotelApi.REDIS_CALLBACK_QUEUE), - Duration.ofSeconds(5)); - if (popped == null || popped.size() < 2 || popped.get(1) == null) { - continue; - } - String taskId = popped.get(1).trim(); - if (taskId.isBlank() || "null".equalsIgnoreCase(taskId)) { + // 底层 BRPOP 多 key:先高优再普通(ListOperations 无多 key 重载) + String taskId = brpopPreferHigh(5); + if (taskId == null || taskId.isBlank() || "null".equalsIgnoreCase(taskId)) { continue; } Long id; @@ -125,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 keySer = (RedisSerializer) redisTemplate.getKeySerializer(); + RedisSerializer valSer = (RedisSerializer) 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 popped = redisTemplate.execute((RedisCallback>) 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()) {