diff --git a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelEntity.java b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelEntity.java index f9d17a11..b710cbf6 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelEntity.java +++ b/pjcloud-supermarket/pjcloud-supermarket-api/src/main/java/com/bsd/pjcloud/supermarket/entity/SmHotelEntity.java @@ -50,9 +50,15 @@ public class SmHotelEntity extends Model { @Schema(description = "数据来源(字典 sm_hotel_data_source)") private String sheetName; - @Schema(description = "ACTIVE启用 / OFFLINE上游下线停用") + @Schema(description = "上架状态:ACTIVE上架 / OFFLINE下架(本地可改;第三方由上游差集写入)") private String upstreamStatus; + @Schema(description = "本地自动滚加:0关 1开(仅数据来源=本地)") + private Integer localAutoRoll; + + @Schema(description = "本地滚加维持天数(今天起共 N 天,1~366)") + private Integer localRollHorizonDays; + @Schema(description = "备注") private String remark; diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/config/ShangyouHotelProperties.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/config/ShangyouHotelProperties.java index 4f4ffaa7..2a252c81 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/config/ShangyouHotelProperties.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/config/ShangyouHotelProperties.java @@ -156,6 +156,15 @@ public class ShangyouHotelProperties { /** 滚价每家间隔毫秒,减轻上游限流/繁忙(默认 30) */ private int priceRollIntervalMs = 30; + /** + * 本地酒店自动滚加日历 Cron(仅 spring-schedule-enabled=true 时生效)。 + * 默认每天 03:30:0 30 3 * * ? + */ + private String localDailyRollCron = "0 30 3 * * ?"; + + /** 是否启用本地酒店自动滚加(按酒店 local_auto_roll 配置补齐缺失日历) */ + private boolean localDailyRollEnabled = true; + /** 文档:全量房价房量每天最多次数 */ private int fullPriceSyncMaxPerDay = 3; diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelController.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelController.java index d72365d1..a21c99ab 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelController.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelController.java @@ -40,7 +40,7 @@ public class SmHotelController { .eq(StrUtil.isNotBlank(query.getSheetName()), SmHotelEntity::getSheetName, query.getSheetName()) .orderByDesc(SmHotelEntity::getCreateTime) .orderByDesc(SmHotelEntity::getId); - // 启用:含未写状态的老数据;停用:仅 OFFLINE;空=全部 + // 上架(ACTIVE):含未写状态的老数据;下架:仅 OFFLINE;空=全部 if ("OFFLINE".equalsIgnoreCase(query.getUpstreamStatus())) { wrapper.eq(SmHotelEntity::getUpstreamStatus, "OFFLINE"); } @@ -55,14 +55,14 @@ public class SmHotelController { /** 上传文件会立刻 transferTo 临时目录,勿加 @SysLog */ @Operation(summary = "提交Excel异步导入(多Sheet流式)") @PostMapping("/import") - @PreAuthorize("@pms.hasPermission('supermarket_smHotel_import')") + @PreAuthorize("@pms.hasPermission('supermarket_smHotel_sync')") public R importExcel(@RequestParam("file") MultipartFile file) { return hotelService.startImport(file); } @Operation(summary = "查询酒店导入进度") @GetMapping("/import/status/{taskId}") - @PreAuthorize("@pms.hasPermission('supermarket_smHotel_import')") + @PreAuthorize("@pms.hasPermission('supermarket_smHotel_sync')") public R importStatus(@PathVariable String taskId) { return hotelService.getImportStatus(taskId); } diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelMidController.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelMidController.java index 24bd1367..274f6d2f 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelMidController.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelMidController.java @@ -1,6 +1,7 @@ package com.bsd.pjcloud.supermarket.controller; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.supermarket.dto.SmHotelLocalDailyBatchDTO; import com.bsd.pjcloud.supermarket.entity.SmHotelDailyEntity; import com.bsd.pjcloud.supermarket.entity.SmHotelPriceConfigEntity; import com.bsd.pjcloud.supermarket.entity.SmHotelProductEntity; @@ -46,15 +47,15 @@ public class SmHotelMidController { } @SysLog("保存酒店售价调整配置") - @Operation(summary = "保存全局售价调整配置(正数加价、负数折扣;仅保存,不重算库存卖价)") + @Operation(summary = "保存全局售价调整配置(仅第三方来源;正数加价、负数折扣;仅保存,不重算库存卖价)") @PutMapping("/priceConfig") @PreAuthorize("@pms.hasPermission('supermarket_smHotelPrice_edit')") public R savePriceConfig(@RequestBody SmHotelPriceConfigEntity entity) { return priceConfigService.saveOrUpdateConfig(entity); } - @SysLog("全局同步酒店售价(按配置重算产品/日历,不动订单)") - @Operation(summary = "按当前配置重算全部产品与日历卖价(不动订单)") + @SysLog("全局同步酒店售价(仅第三方来源,按配置重算产品/日历,不动订单与本地酒店)") + @Operation(summary = "按当前配置重算第三方酒店产品与日历卖价(排除数据来源=本地;不动订单)") @PostMapping("/priceConfig/resyncSell") @PreAuthorize("@pms.hasPermission('supermarket_smHotelPrice_edit')") public R> resyncSellPrices() { @@ -79,56 +80,56 @@ public class SmHotelMidController { @Operation(summary = "从上游拉取房型产品并加价落库") @PostMapping("/mid/pull/{id}") - @PreAuthorize("@pms.hasPermission('supermarket_smHotel_edit')") + @PreAuthorize("@pms.hasPermission('supermarket_smHotel_sync')") public R> pull(@PathVariable Long id) { return midService.pullFromUpstream(id); } @Operation(summary = "仅拉取上游房型(getHotelComplexList,不拉房价)") @PostMapping("/mid/pullRooms/{id}") - @PreAuthorize("@pms.hasPermission('supermarket_smHotel_edit')") + @PreAuthorize("@pms.hasPermission('supermarket_smHotel_sync')") public R> pullRooms(@PathVariable Long id) { return midService.pullRoomsFromUpstream(id); } @Operation(summary = "仅拉取上游房价样例(getRoomPrice 今天 1 天)") @PostMapping("/mid/pullPrice/{id}") - @PreAuthorize("@pms.hasPermission('supermarket_smHotel_edit')") + @PreAuthorize("@pms.hasPermission('supermarket_smHotel_sync')") public R> pullPrice(@PathVariable Long id) { return midService.pullPriceFromUpstream(id); } @Operation(summary = "仅拉取上游产品样例(getRoomPrice 今天 1 天落产品)") @PostMapping("/mid/pullProducts/{id}") - @PreAuthorize("@pms.hasPermission('supermarket_smHotel_edit')") + @PreAuthorize("@pms.hasPermission('supermarket_smHotel_sync')") public R> pullProducts(@PathVariable Long id) { return midService.pullProductsFromUpstream(id); } @Operation(summary = "推送到下游销售方") @PostMapping("/mid/push/{id}") - @PreAuthorize("@pms.hasPermission('supermarket_smHotel_push')") + @PreAuthorize("@pms.hasPermission('supermarket_smHotel_sync')") public R> push(@PathVariable Long id) { return midService.pushToDownstream(id); } @Operation(summary = "拉取上游并推送下游") @PostMapping("/mid/pullAndPush/{id}") - @PreAuthorize("@pms.hasPermission('supermarket_smHotel_push')") + @PreAuthorize("@pms.hasPermission('supermarket_smHotel_sync')") public R> pullAndPush(@PathVariable Long id) { return midService.pullAndPush(id); } @Operation(summary = "批量拉取并推送") @PostMapping("/mid/pullAndPush/batch") - @PreAuthorize("@pms.hasPermission('supermarket_smHotel_push')") + @PreAuthorize("@pms.hasPermission('supermarket_smHotel_sync')") public R> pullAndPushBatch(@RequestBody List ids) { return midService.pullAndPushBatch(ids); } @Operation(summary = "查询上游账号已授权酒店列表") @GetMapping("/mid/authorizedHotels") - @PreAuthorize("@pms.hasPermission('supermarket_smHotel_view')") + @PreAuthorize("@pms.hasPermission('supermarket_smHotel_sync')") public R> authorizedHotels(@RequestParam(required = false) String keyword, @RequestParam(required = false, defaultValue = "1") long current, @RequestParam(required = false, defaultValue = "20") long size, @@ -149,7 +150,7 @@ public class SmHotelMidController { @SysLog("同步上游酒店列表并差集") @Operation(summary = "异步同步 getHotelList + Redis 差集(立刻返回 logId;须选数据来源)") @PostMapping("/mid/syncHotelList") - @PreAuthorize("@pms.hasPermission('supermarket_smHotel_edit')") + @PreAuthorize("@pms.hasPermission('supermarket_smHotel_sync')") public R> syncHotelList( @RequestParam(required = false, defaultValue = "true") boolean autoImport, @RequestParam("dataSource") String dataSource) { @@ -158,7 +159,7 @@ public class SmHotelMidController { @Operation(summary = "同步日志分页") @GetMapping("/mid/syncLog/page") - @PreAuthorize("@pms.hasPermission('supermarket_smHotel_view')") + @PreAuthorize("@pms.hasPermission('supermarket_smHotel_sync')") public R> syncLogPage(@ParameterObject Page page, @ParameterObject SmHotelSyncLogEntity query) { return R.ok(syncService.pageSyncLogs(page, query)); @@ -166,7 +167,7 @@ public class SmHotelMidController { @Operation(summary = "查询单次同步进度/结果") @GetMapping("/mid/syncLog/{id}") - @PreAuthorize("@pms.hasPermission('supermarket_smHotel_view')") + @PreAuthorize("@pms.hasPermission('supermarket_smHotel_sync')") public R syncLog(@PathVariable Long id) { return syncService.getSyncLog(id); } @@ -174,7 +175,7 @@ public class SmHotelMidController { @SysLog("终止酒店同步死任务") @Operation(summary = "终止仍显示进行中的同步日志(死任务)") @PostMapping("/mid/syncLog/{id}/abort") - @PreAuthorize("@pms.hasPermission('supermarket_smHotel_edit')") + @PreAuthorize("@pms.hasPermission('supermarket_smHotel_sync')") public R abortSyncLog(@PathVariable Long id) { return syncService.abortSyncLog(id); } @@ -182,21 +183,21 @@ public class SmHotelMidController { @SysLog("清理酒店同步卡死任务") @Operation(summary = "清理长时间无进度的 RUNNING 同步日志") @PostMapping("/mid/syncLog/abortStale") - @PreAuthorize("@pms.hasPermission('supermarket_smHotel_edit')") + @PreAuthorize("@pms.hasPermission('supermarket_smHotel_sync')") public R abortStaleSyncLogs() { return R.ok(syncService.abortStaleRunningSyncLogs()); } @Operation(summary = "日更时间配置(只读展示)") @GetMapping("/mid/syncSchedule") - @PreAuthorize("@pms.hasPermission('supermarket_smHotel_view')") + @PreAuthorize("@pms.hasPermission('supermarket_smHotel_sync')") public R> syncSchedule() { return R.ok(syncService.getSyncSchedule()); } @Operation(summary = "分页查看酒店差集 new|offline") @GetMapping("/mid/hotelDiff") - @PreAuthorize("@pms.hasPermission('supermarket_smHotel_view')") + @PreAuthorize("@pms.hasPermission('supermarket_smHotel_sync')") public R> hotelDiff(@RequestParam(required = false, defaultValue = "new") String type, @RequestParam(required = false, defaultValue = "1") long current, @RequestParam(required = false, defaultValue = "20") long size) { @@ -248,6 +249,15 @@ public class SmHotelMidController { return midService.saveLocalDaily(hotelId, daily); } + @SysLog("本地酒店批量生成/调价日历") + @Operation(summary = "本地酒店按日期区间批量生成或调价(FILL 补齐缺失 / ADJUST 改已有)") + @PostMapping("/local/{hotelId}/daily/batch") + @PreAuthorize("@pms.hasPermission('supermarket_smHotel_edit')") + public R> batchLocalDaily(@PathVariable Long hotelId, + @RequestBody SmHotelLocalDailyBatchDTO req) { + return midService.batchLocalDaily(hotelId, req); + } + @SysLog("本地酒店删除日历") @DeleteMapping("/local/{hotelId}/daily/{dailyId}") @PreAuthorize("@pms.hasPermission('supermarket_smHotel_edit')") @@ -258,7 +268,7 @@ public class SmHotelMidController { @SysLog("手动执行滚一天房价") @Operation(summary = "异步滚一天房价(立刻返回 logId)") @PostMapping("/mid/rollDailyPrice") - @PreAuthorize("@pms.hasPermission('supermarket_smHotel_edit')") + @PreAuthorize("@pms.hasPermission('supermarket_smHotel_sync')") public R> rollDailyPrice() { R started = syncService.startPriceRollAsync("MANUAL"); if (started.getCode() != 0) { @@ -271,10 +281,26 @@ public class SmHotelMidController { return R.ok(data, started.getMsg()); } + @SysLog("手动执行本地酒店滚加日历") + @Operation(summary = "异步本地滚加:对开启配置的本地酒店补齐今天起 N 天缺失日历") + @PostMapping("/mid/rollLocalDaily") + @PreAuthorize("@pms.hasPermission('supermarket_smHotel_edit')") + public R> rollLocalDaily() { + R started = syncService.startLocalDailyRollAsync("MANUAL"); + if (started.getCode() != 0) { + return R.failed(started.getMsg()); + } + Map data = new LinkedHashMap<>(); + data.put("async", true); + data.put("logId", started.getData().getId()); + data.put("status", started.getData().getStatus()); + return R.ok(data, started.getMsg()); + } + @SysLog("全量拉取启用酒店详情") @Operation(summary = "异步拉取全部启用酒店详情(房型+产品+未来房价),立刻返回 logId") @PostMapping("/mid/pullAllActiveDetails") - @PreAuthorize("@pms.hasPermission('supermarket_smHotel_edit')") + @PreAuthorize("@pms.hasPermission('supermarket_smHotel_sync')") public R> pullAllActiveDetails( @RequestParam(required = false) Integer limit) { R started = syncService.startDetailPullAsync(limit, "MANUAL"); @@ -292,7 +318,7 @@ public class SmHotelMidController { @SysLog("重试同步失败酒店") @Operation(summary = "根据某次同步日志的失败明细,只重试失败酒店(详情/滚价)") @PostMapping("/mid/retryFailedFromLog") - @PreAuthorize("@pms.hasPermission('supermarket_smHotel_edit')") + @PreAuthorize("@pms.hasPermission('supermarket_smHotel_sync')") public R> retryFailedFromLog(@RequestParam Long logId) { R started = syncService.startRetryFailedAsync(logId); if (started.getCode() != 0) { diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelUpstreamController.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelUpstreamController.java index cbf956bb..0c205aa4 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelUpstreamController.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/controller/SmHotelUpstreamController.java @@ -29,14 +29,14 @@ public class SmHotelUpstreamController { @Operation(summary = "同步上游酒店信息到本地") @PostMapping("/sync/{id}") - @PreAuthorize("@pms.hasPermission('supermarket_smHotel_edit')") + @PreAuthorize("@pms.hasPermission('supermarket_smHotel_sync')") public R sync(@PathVariable Long id) { return upstreamService.syncHotelInfo(id); } @Operation(summary = "批量同步上游酒店信息") @PostMapping("/sync/batch") - @PreAuthorize("@pms.hasPermission('supermarket_smHotel_edit')") + @PreAuthorize("@pms.hasPermission('supermarket_smHotel_sync')") public R> syncBatch(@RequestBody List ids) { return upstreamService.syncHotelInfoBatch(ids); } diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/dto/SmHotelLocalDailyBatchDTO.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/dto/SmHotelLocalDailyBatchDTO.java new file mode 100644 index 00000000..af5b716b --- /dev/null +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/dto/SmHotelLocalDailyBatchDTO.java @@ -0,0 +1,47 @@ +package com.bsd.pjcloud.supermarket.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +/** + * 本地酒店日历批量生成 / 调价。 + *
    + *
  • FILL:按产品默认价/库存补齐缺失日期(已有行跳过)
  • + *
  • ADJUST:对区间内已有行改价/配额/房态(保留已售);可选同时补齐缺失
  • + *
+ */ +@Data +@Schema(description = "本地酒店日历批量请求") +public class SmHotelLocalDailyBatchDTO { + + @Schema(description = "开始日期(含)", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDate startDate; + + @Schema(description = "结束日期(含)", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDate endDate; + + @Schema(description = "产品编码列表,空=该酒店全部产品") + private List productCodes; + + @Schema(description = "FILL=补齐缺失;ADJUST=调整已有", requiredMode = Schema.RequiredMode.REQUIRED) + private String mode; + + @Schema(description = "ADJUST 时是否同时按产品默认补齐缺失日期,默认 false") + private Boolean fillMissing; + + @Schema(description = "覆盖成本价;FILL 时空则用产品成本价;ADJUST 时空表示不改") + private BigDecimal costPrice; + + @Schema(description = "覆盖卖价;FILL 时空则用产品卖价;ADJUST 时空表示不改") + private BigDecimal sellPrice; + + @Schema(description = "覆盖当日配额;FILL 时空则用产品库存;ADJUST 时空表示不改") + private Integer stock; + + @Schema(description = "覆盖房态 0关1开;FILL 时空默认开房;ADJUST 时空表示不改") + private Integer dailyStatus; +} diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/job/SmHotelShangyouSpringSchedule.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/job/SmHotelShangyouSpringSchedule.java index 7f2ef02b..c294c387 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/job/SmHotelShangyouSpringSchedule.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/job/SmHotelShangyouSpringSchedule.java @@ -11,7 +11,7 @@ import org.springframework.stereotype.Component; * YAML 可配 Cron 的日更入口(与 XXL Job 二选一即可)。 *

* 开关:{@code shangyou-hotel.spring-schedule-enabled}
- * 时间:{@code hotel-list-daily-cron} / {@code price-roll-daily-cron} + * 时间:{@code hotel-list-daily-cron} / {@code price-roll-daily-cron} / {@code local-daily-roll-cron} */ @Slf4j @Component @@ -55,4 +55,21 @@ public class SmHotelShangyouSpringSchedule { log.error("|酒店日更|Spring 滚价异常", e); } } + + @Scheduled(cron = "${shangyou-hotel.local-daily-roll-cron:0 30 3 * * ?}") + public void scheduledLocalDailyRoll() { + if (!properties.isSpringScheduleEnabled()) { + return; + } + if (!properties.isLocalDailyRollEnabled()) { + return; + } + try { + log.info("|酒店日更|Spring Cron 触发本地滚加|cron={}|", properties.getLocalDailyRollCron()); + syncService.jobRollLocalDaily("CRON"); + } + catch (Exception e) { + log.error("|酒店日更|Spring 本地滚加异常", e); + } + } } diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/job/SmHotelShangyouSyncJob.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/job/SmHotelShangyouSyncJob.java index a90e0bf4..f3b92791 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/job/SmHotelShangyouSyncJob.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/job/SmHotelShangyouSyncJob.java @@ -13,7 +13,8 @@ import java.util.Map; * 上游酒店日更: *

    *
  • {@link #HANDLER_LIST_SYNC}:getHotelList + Redis 差集 + 可选自动导入新增
  • - *
  • {@link #HANDLER_PRICE_ROLL}:对本地酒店只拉 today+offset 一天房价
  • + *
  • {@link #HANDLER_PRICE_ROLL}:对第三方酒店只拉 today+offset 一天房价
  • + *
  • {@link #HANDLER_LOCAL_ROLL}:本地酒店按配置补齐日历窗口
  • *
* 频率在 XXL-JOB 控制台配 Cron;开关与参数在 shangyou-hotel.*。 */ @@ -26,6 +27,8 @@ public class SmHotelShangyouSyncJob { public static final String HANDLER_PRICE_ROLL = "smHotelPriceRollJobHandler"; + public static final String HANDLER_LOCAL_ROLL = "smHotelLocalDailyRollJobHandler"; + private final SmHotelSyncService syncService; @XxlJob(HANDLER_LIST_SYNC) @@ -53,4 +56,17 @@ public class SmHotelShangyouSyncJob { XxlJobHelper.handleFail(e.getMessage()); } } + + @XxlJob(HANDLER_LOCAL_ROLL) + public void smHotelLocalDailyRollJobHandler() { + try { + Map r = syncService.jobRollLocalDaily(); + XxlJobHelper.log("hotel local daily roll: " + r); + XxlJobHelper.handleSuccess(String.valueOf(r)); + } + catch (Exception e) { + log.error("smHotelLocalDailyRollJobHandler 异常", e); + XxlJobHelper.handleFail(e.getMessage()); + } + } } diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelDailyMapper.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelDailyMapper.java index f679d48f..83e029d6 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelDailyMapper.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelDailyMapper.java @@ -26,14 +26,16 @@ public interface SmHotelDailyMapper extends PjcloudBaseMapper list); /** - * 按底价重算日历卖价:sell = ROUND(cost * rate, 2)。不改订单。 + * 按底价重算日历卖价:sell = ROUND(cost * rate, 2)。仅第三方来源酒店;本地 sheet_name=local 不参与。 */ @Update(""" - UPDATE canteen_hotel_daily - SET sell_price = ROUND(cost_price * #{rate}, 2), - update_time = NOW() - WHERE del_flag = '0' - AND cost_price IS NOT NULL + UPDATE canteen_hotel_daily d + INNER JOIN canteen_hotel h ON h.id = d.hotel_id AND h.del_flag = '0' + SET d.sell_price = ROUND(d.cost_price * #{rate}, 2), + d.update_time = NOW() + WHERE d.del_flag = '0' + AND d.cost_price IS NOT NULL + AND (h.sheet_name IS NULL OR LOWER(TRIM(h.sheet_name)) <> 'local') """) int resyncSellPriceByCost(@Param("rate") BigDecimal rate); diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelProductMapper.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelProductMapper.java index 2fb461a6..f9301f5c 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelProductMapper.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/mapper/SmHotelProductMapper.java @@ -18,14 +18,16 @@ public interface SmHotelProductMapper extends PjcloudBaseMapper list); /** - * 按底价重算卖价:sell = ROUND(cost * rate, 2)。不改订单。 + * 按底价重算卖价:sell = ROUND(cost * rate, 2)。仅第三方来源酒店;本地 sheet_name=local 不参与。 */ @Update(""" - UPDATE canteen_hotel_product - SET sell_price = ROUND(cost_price * #{rate}, 2), - update_time = NOW() - WHERE del_flag = '0' - AND cost_price IS NOT NULL + UPDATE canteen_hotel_product p + INNER JOIN canteen_hotel h ON h.id = p.hotel_id AND h.del_flag = '0' + SET p.sell_price = ROUND(p.cost_price * #{rate}, 2), + p.update_time = NOW() + WHERE p.del_flag = '0' + AND p.cost_price IS NOT NULL + AND (h.sheet_name IS NULL OR LOWER(TRIM(h.sheet_name)) <> 'local') """) int resyncSellPriceByCost(@Param("rate") BigDecimal rate); diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelMidService.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelMidService.java index 2e4e1da7..3681d303 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelMidService.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelMidService.java @@ -1,6 +1,7 @@ package com.bsd.pjcloud.supermarket.service; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.bsd.pjcloud.supermarket.dto.SmHotelLocalDailyBatchDTO; import com.bsd.pjcloud.supermarket.entity.SmHotelDailyEntity; import com.bsd.pjcloud.supermarket.entity.SmHotelProductEntity; import com.bsd.pjcloud.supermarket.entity.SmHotelRoomEntity; @@ -81,4 +82,10 @@ public interface SmHotelMidService { /** 本地数据来源:删除日历行 */ R removeLocalDaily(Long hotelId, Long dailyId); + + /** + * 本地数据来源:按日期区间批量生成或调价。 + * FILL 补齐缺失(产品默认价);ADJUST 改已有行(可选再补齐)。 + */ + R> batchLocalDaily(Long hotelId, SmHotelLocalDailyBatchDTO req); } diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelSyncService.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelSyncService.java index a55e049b..164a515c 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelSyncService.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/SmHotelSyncService.java @@ -72,4 +72,12 @@ public interface SmHotelSyncService { Map jobRollDailyPrice(); Map jobRollDailyPrice(String triggerType); + + /** 提交本地酒店自动滚加(补齐今天起 N 天缺失日历) */ + R startLocalDailyRollAsync(String triggerType); + + /** XXL / 定时:本地滚加 */ + Map jobRollLocalDaily(); + + Map jobRollLocalDaily(String triggerType); } diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelMidServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelMidServiceImpl.java index c0358553..5ecc6f2b 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelMidServiceImpl.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelMidServiceImpl.java @@ -11,6 +11,7 @@ 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.dto.SmHotelLocalDailyBatchDTO; import com.bsd.pjcloud.supermarket.entity.*; import com.bsd.pjcloud.supermarket.integration.downstream.DownstreamHotelClient; import com.bsd.pjcloud.supermarket.integration.shangyou.ShangyouHotelApi; @@ -47,6 +48,9 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { /** 上游单次房价查询建议不超过 31 天 */ private static final int ROOM_PRICE_CHUNK_DAYS = 30; + /** 本地日历单次批量最长天数(含首尾) */ + private static final int LOCAL_DAILY_BATCH_MAX_DAYS = 366; + private final ShangyouHotelClient upstreamClient; private final ShangyouHotelProperties upstreamProperties; @@ -668,6 +672,18 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { if (room == null || StrUtil.isBlank(room.getRoomTypeCode())) { return R.failed("房型编码不能为空"); } + if (StrUtil.isBlank(room.getRoomTypeName())) { + return R.failed("房型名称不能为空"); + } + if (StrUtil.isBlank(room.getBedType())) { + return R.failed("床型不能为空"); + } + if (room.getCapacity() == null) { + return R.failed("可住人数不能为空"); + } + if (room.getRoomNumber() == null) { + return R.failed("房间数不能为空"); + } room.setHotelId(hotel.getId()); room.setHotelCode(hotel.getHotelCode()); room.setGroupCode(hotel.getGroupCode()); @@ -716,9 +732,24 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { if (product == null || StrUtil.isBlank(product.getProductCode())) { return R.failed("产品编码不能为空"); } + if (StrUtil.isBlank(product.getProductName())) { + return R.failed("产品名称不能为空"); + } if (StrUtil.isBlank(product.getRoomTypeCode())) { return R.failed("所属房型编码不能为空"); } + if (product.getMealCount() == null) { + return R.failed("早餐份数不能为空"); + } + if (product.getCostPrice() == null) { + return R.failed("成本价不能为空"); + } + if (product.getSellPrice() == null) { + return R.failed("卖价不能为空"); + } + if (product.getStock() == null) { + return R.failed("库存不能为空"); + } Long roomCnt = roomMapper.selectCount(Wrappers.lambdaQuery() .eq(SmHotelRoomEntity::getHotelId, hotelId) .eq(SmHotelRoomEntity::getRoomTypeCode, product.getRoomTypeCode().trim())); @@ -780,6 +811,18 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { if (StrUtil.isBlank(daily.getProductCode())) { return R.failed("产品编码不能为空"); } + if (daily.getCostPrice() == null) { + return R.failed("成本价不能为空"); + } + if (daily.getSellPrice() == null) { + return R.failed("卖价不能为空"); + } + if (daily.getStock() == null) { + return R.failed("当日配额不能为空"); + } + if (daily.getDailyStatus() == null) { + return R.failed("房态不能为空"); + } SmHotelProductEntity product = productMapper.selectOne(Wrappers.lambdaQuery() .eq(SmHotelProductEntity::getHotelId, hotelId) .eq(SmHotelProductEntity::getProductCode, daily.getProductCode().trim()) @@ -845,8 +888,165 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { return R.ok(dailyMapper.deleteById(dailyId) > 0); } + @Override + @Transactional(rollbackFor = Exception.class) + public R> batchLocalDaily(Long hotelId, SmHotelLocalDailyBatchDTO req) { + SmHotelEntity hotel = requireLocalHotel(hotelId); + if (req == null || req.getStartDate() == null || req.getEndDate() == null) { + return R.failed("请选择开始/结束日期"); + } + if (req.getEndDate().isBefore(req.getStartDate())) { + return R.failed("结束日期不能早于开始日期"); + } + long dayCount = req.getEndDate().toEpochDay() - req.getStartDate().toEpochDay() + 1; + if (dayCount > LOCAL_DAILY_BATCH_MAX_DAYS) { + return R.failed("单次最多生成/调整 " + LOCAL_DAILY_BATCH_MAX_DAYS + " 天"); + } + String mode = StrUtil.blankToDefault(req.getMode(), "").trim().toUpperCase(Locale.ROOT); + boolean fillMode = "FILL".equals(mode); + boolean adjustMode = "ADJUST".equals(mode); + if (!fillMode && !adjustMode) { + return R.failed("mode 仅支持 FILL(补齐缺失)或 ADJUST(调整已有)"); + } + boolean fillMissing = Boolean.TRUE.equals(req.getFillMissing()); + if (adjustMode && req.getCostPrice() == null && req.getSellPrice() == null && req.getStock() == null + && req.getDailyStatus() == null && !fillMissing) { + return R.failed("调整模式下请至少填写一项:成本价/卖价/配额/房态,或勾选同时补齐缺失"); + } + + List products = loadLocalProductsForBatch(hotelId, req.getProductCodes()); + if (products.isEmpty()) { + return R.failed("没有可操作的产品,请先维护产品"); + } + for (SmHotelProductEntity p : products) { + if (p.getCostPrice() == null && req.getCostPrice() == null && (fillMode || fillMissing)) { + return R.failed("产品 " + p.getProductCode() + " 未设置成本价,请先维护产品或在本次填写覆盖成本价"); + } + if (p.getSellPrice() == null && req.getSellPrice() == null && (fillMode || fillMissing)) { + return R.failed("产品 " + p.getProductCode() + " 未设置卖价,请先维护产品或在本次填写覆盖卖价"); + } + if (p.getStock() == null && req.getStock() == null && (fillMode || fillMissing)) { + return R.failed("产品 " + p.getProductCode() + " 未设置库存,请先维护产品或在本次填写覆盖配额"); + } + } + + List productCodes = products.stream().map(SmHotelProductEntity::getProductCode).toList(); + List existRows = dailyMapper.selectList(Wrappers.lambdaQuery() + .eq(SmHotelDailyEntity::getHotelId, hotelId) + .in(SmHotelDailyEntity::getProductCode, productCodes) + .ge(SmHotelDailyEntity::getStayDate, req.getStartDate()) + .le(SmHotelDailyEntity::getStayDate, req.getEndDate())); + Map existMap = new HashMap<>(Math.max(16, existRows.size() * 2)); + for (SmHotelDailyEntity d : existRows) { + existMap.put(d.getProductCode() + "|" + d.getStayDate(), d); + } + + LocalDateTime now = LocalDateTime.now(); + Long tenantId = resolveTenantId(); + List toInsert = new ArrayList<>(); + List toUpdate = new ArrayList<>(); + int skippedExist = 0; + int skippedStock = 0; + + for (SmHotelProductEntity product : products) { + for (LocalDate day = req.getStartDate(); !day.isAfter(req.getEndDate()); day = day.plusDays(1)) { + String key = product.getProductCode() + "|" + day; + SmHotelDailyEntity exist = existMap.get(key); + if (exist == null) { + if (fillMode || (adjustMode && fillMissing)) { + toInsert.add(buildLocalDailyFromProduct(hotel, product, day, req, tenantId, now)); + } + continue; + } + if (fillMode) { + skippedExist++; + continue; + } + // ADJUST:改已有行,未传的字段保持原值 + int sold = exist.getSold() == null ? 0 : exist.getSold(); + Integer newStock = req.getStock() != null ? req.getStock() : exist.getStock(); + if (newStock == null) { + newStock = 0; + } + if (newStock < sold) { + skippedStock++; + continue; + } + if (req.getCostPrice() != null) { + exist.setCostPrice(req.getCostPrice()); + } + if (req.getSellPrice() != null) { + exist.setSellPrice(req.getSellPrice()); + } + if (req.getStock() != null) { + exist.setStock(req.getStock()); + } + if (req.getDailyStatus() != null) { + exist.setDailyStatus(req.getDailyStatus()); + } + exist.setSold(sold); + exist.setUpdateTime(now); + toUpdate.add(exist); + } + } + + insertDailiesBatch(toInsert); + if (CollUtil.isNotEmpty(toUpdate)) { + Db.updateBatchById(toUpdate, PERSIST_BATCH); + } + + Map data = new LinkedHashMap<>(); + data.put("mode", mode); + data.put("startDate", req.getStartDate().toString()); + data.put("endDate", req.getEndDate().toString()); + data.put("productCount", products.size()); + data.put("dayCount", dayCount); + data.put("inserted", toInsert.size()); + data.put("updated", toUpdate.size()); + data.put("skippedExist", skippedExist); + data.put("skippedStockLtSold", skippedStock); + String msg = fillMode + ? String.format("已补齐 %d 天行,跳过已有 %d", toInsert.size(), skippedExist) + : String.format("已调整 %d 行,新补齐 %d,配额不足跳过 %d", toUpdate.size(), toInsert.size(), skippedStock); + return R.ok(data, msg); + } + + /** 按产品编码筛选;空列表=全部产品 */ + private List loadLocalProductsForBatch(Long hotelId, List productCodes) { + var wrapper = Wrappers.lambdaQuery().eq(SmHotelProductEntity::getHotelId, hotelId); + if (CollUtil.isNotEmpty(productCodes)) { + List codes = productCodes.stream() + .filter(StrUtil::isNotBlank) + .map(String::trim) + .distinct() + .toList(); + if (codes.isEmpty()) { + return Collections.emptyList(); + } + wrapper.in(SmHotelProductEntity::getProductCode, codes); + } + return productMapper.selectList(wrapper); + } + + private SmHotelDailyEntity buildLocalDailyFromProduct(SmHotelEntity hotel, SmHotelProductEntity product, + LocalDate stayDate, SmHotelLocalDailyBatchDTO req, Long tenantId, LocalDateTime now) { + SmHotelDailyEntity daily = new SmHotelDailyEntity(); + daily.setHotelId(hotel.getId()); + daily.setProductId(product.getId()); + daily.setProductCode(product.getProductCode()); + daily.setRoomTypeCode(product.getRoomTypeCode()); + daily.setStayDate(stayDate); + daily.setCostPrice(req.getCostPrice() != null ? req.getCostPrice() : product.getCostPrice()); + daily.setSellPrice(req.getSellPrice() != null ? req.getSellPrice() : product.getSellPrice()); + daily.setStock(req.getStock() != null ? req.getStock() : product.getStock()); + daily.setSold(0); + daily.setDailyStatus(req.getDailyStatus() != null ? req.getDailyStatus() : 1); + fillNewMeta(daily, tenantId, now); + return daily; + } + private SmHotelEntity requireLocalHotel(Long hotelId) { - SmHotelEntity hotel = requireHotel(hotelId); + SmHotelEntity hotel = requireHotel(hotelId, false); if (!SmHotelDataSource.isLocal(hotel.getSheetName())) { throw new IllegalArgumentException("仅「本地」数据来源的酒店可手工维护房型/产品/日历"); } @@ -854,6 +1054,10 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { } private SmHotelEntity requireHotel(Long hotelId) { + return requireHotel(hotelId, true); + } + + private SmHotelEntity requireHotel(Long hotelId, boolean requireListed) { if (hotelId == null) { throw new IllegalArgumentException("酒店ID不能为空"); } @@ -864,8 +1068,9 @@ public class SmHotelMidServiceImpl implements SmHotelMidService { if (StrUtil.isBlank(hotel.getHotelCode())) { throw new IllegalArgumentException("酒店缺少 hotelCode(对应上游 hotelId)"); } - if ("OFFLINE".equalsIgnoreCase(hotel.getUpstreamStatus())) { - throw new IllegalArgumentException("酒店已因上游下线停用,禁止拉取/推送"); + if (requireListed && "OFFLINE".equalsIgnoreCase(hotel.getUpstreamStatus())) { + throw new IllegalArgumentException( + SmHotelDataSource.isLocal(hotel.getSheetName()) ? "酒店已下架,禁止拉取/推送" : "酒店已因上游下线停用,禁止拉取/推送"); } return hotel; } diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelOrderServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelOrderServiceImpl.java index b8574f17..14246a68 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelOrderServiceImpl.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelOrderServiceImpl.java @@ -112,7 +112,7 @@ public class SmHotelOrderServiceImpl extends ServiceImpl local = finishLocalOrder(order, product); if (local.getData() != null) { local.getData().set("hotelName", hotel.getHotelName()); diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelPriceConfigServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelPriceConfigServiceImpl.java index 0a2c9c58..0c5d5fbb 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelPriceConfigServiceImpl.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelPriceConfigServiceImpl.java @@ -104,13 +104,15 @@ public class SmHotelPriceConfigServiceImpl extends ServiceImpl out = new LinkedHashMap<>(); out.put("markupPercent", percent); out.put("rate", rate); out.put("productUpdated", productUpdated); out.put("dailyUpdated", dailyUpdated); + out.put("scope", "non_local"); return R.ok(out); } } diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelServiceImpl.java index 7ada144b..4efe7766 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelServiceImpl.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelServiceImpl.java @@ -114,8 +114,11 @@ public class SmHotelServiceImpl extends ServiceImpl 0; } @@ -398,6 +429,27 @@ public class SmHotelServiceImpl extends ServiceImpl 366) { + days = 366; + } + entity.setLocalRollHorizonDays(days); + } + private static String trimToNull(String v) { return StrUtil.isBlank(v) ? null : v.trim(); } diff --git a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelSyncServiceImpl.java b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelSyncServiceImpl.java index d72caa49..e78567d5 100644 --- a/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelSyncServiceImpl.java +++ b/pjcloud-supermarket/pjcloud-supermarket-biz/src/main/java/com/bsd/pjcloud/supermarket/service/impl/SmHotelSyncServiceImpl.java @@ -6,6 +6,7 @@ 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.dto.SmHotelLocalDailyBatchDTO; import com.bsd.pjcloud.supermarket.entity.SmHotelEntity; import com.bsd.pjcloud.supermarket.entity.SmHotelSyncLogEntity; import com.bsd.pjcloud.supermarket.integration.shangyou.ShangyouHotelApi; @@ -49,6 +50,9 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService { private static final String TYPE_DETAIL = "DETAIL_PULL"; + /** 本地酒店按配置补齐日历窗口 */ + private static final String TYPE_LOCAL_ROLL = "LOCAL_ROLL"; + private static final String STATUS_RUNNING = "RUNNING"; private static final String STATUS_SUCCESS = "SUCCESS"; @@ -140,6 +144,33 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService { return R.ok(logEntity, "已提交异步滚价"); } + @Override + public R startLocalDailyRollAsync(String triggerType) { + if (!properties.isLocalDailyRollEnabled() && !"MANUAL".equalsIgnoreCase(triggerType)) { + return R.failed("本地自动滚加未启用"); + } + abortStaleRunningSyncLogs(); + Long running = syncLogMapper.selectCount(Wrappers.lambdaQuery() + .eq(SmHotelSyncLogEntity::getSyncType, TYPE_LOCAL_ROLL) + .eq(SmHotelSyncLogEntity::getStatus, STATUS_RUNNING)); + if (running != null && running > 0) { + return R.failed("已有本地滚加任务进行中(若长时间卡住请在同步日志点「终止」)"); + } + SmHotelSyncLogEntity logEntity = newLog(TYPE_LOCAL_ROLL, triggerType); + logEntity.setStage("QUEUED"); + logEntity.setStageMsg("已提交本地滚加任务…"); + logEntity.setProgressPct(1); + syncLogMapper.insert(logEntity); + Long tenantId = TenantContextHolder.getTenantId(); + if (tenantId == null) { + tenantId = properties.getCallbackTenantId() == null ? 1L : properties.getCallbackTenantId(); + } + final Long tid = tenantId; + final Long logId = logEntity.getId(); + EXECUTOR.execute(() -> TenantBroker.runAs(tid, id -> runLocalDailyRoll(logId))); + return R.ok(logEntity, "已提交本地滚加"); + } + @Override public R startDetailPullAsync(Integer limit, String triggerType) { abortStaleRunningSyncLogs(); @@ -402,10 +433,14 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService { int skip = 0; int processed = 0; List failLines = new ArrayList<>(); + // 上游滚价排除本地数据来源酒店 Long totalHint = hotelMapper.selectCount(Wrappers.lambdaQuery() .and(w -> w.isNull(SmHotelEntity::getUpstreamStatus) .or() - .ne(SmHotelEntity::getUpstreamStatus, "OFFLINE"))); + .ne(SmHotelEntity::getUpstreamStatus, "OFFLINE")) + .and(w -> w.isNull(SmHotelEntity::getSheetName) + .or() + .ne(SmHotelEntity::getSheetName, SmHotelDataSource.LOCAL))); long total = totalHint == null ? 0L : totalHint; while (true) { @@ -416,6 +451,9 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService { .and(w -> w.isNull(SmHotelEntity::getUpstreamStatus) .or() .ne(SmHotelEntity::getUpstreamStatus, "OFFLINE")) + .and(w -> w.isNull(SmHotelEntity::getSheetName) + .or() + .ne(SmHotelEntity::getSheetName, SmHotelDataSource.LOCAL)) .orderByAsc(SmHotelEntity::getId) .last("limit " + batchSize)); if (CollUtil.isEmpty(rows)) { @@ -485,6 +523,114 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService { } } + /** + * 本地酒店自动滚加:对开启配置的酒店,按产品默认价补齐「今天起共 N 天」缺失日历行。 + */ + private void runLocalDailyRoll(Long logId) { + long t0 = System.currentTimeMillis(); + try { + progress(logId, 5, "ROLL", "开始本地滚加(补齐缺失日历)…"); + int batchSize = Math.max(20, properties.getPriceRollBatchSize()); + long lastId = 0L; + int ok = 0; + int fail = 0; + int skip = 0; + int insertedTotal = 0; + int processed = 0; + List failLines = new ArrayList<>(); + Long totalHint = hotelMapper.selectCount(Wrappers.lambdaQuery() + .eq(SmHotelEntity::getSheetName, SmHotelDataSource.LOCAL) + .eq(SmHotelEntity::getLocalAutoRoll, 1) + .and(w -> w.isNull(SmHotelEntity::getUpstreamStatus) + .or() + .ne(SmHotelEntity::getUpstreamStatus, "OFFLINE"))); + long total = totalHint == null ? 0L : totalHint; + LocalDate today = LocalDate.now(); + + while (true) { + List rows = hotelMapper.selectList(Wrappers.lambdaQuery() + .select(SmHotelEntity::getId, SmHotelEntity::getHotelCode, SmHotelEntity::getHotelName, + SmHotelEntity::getLocalRollHorizonDays) + .gt(SmHotelEntity::getId, lastId) + .eq(SmHotelEntity::getSheetName, SmHotelDataSource.LOCAL) + .eq(SmHotelEntity::getLocalAutoRoll, 1) + .and(w -> w.isNull(SmHotelEntity::getUpstreamStatus) + .or() + .ne(SmHotelEntity::getUpstreamStatus, "OFFLINE")) + .orderByAsc(SmHotelEntity::getId) + .last("limit " + batchSize)); + if (CollUtil.isEmpty(rows)) { + break; + } + for (SmHotelEntity h : rows) { + lastId = h.getId(); + processed++; + int horizon = h.getLocalRollHorizonDays() == null ? 30 : h.getLocalRollHorizonDays(); + if (horizon < 1) { + horizon = 1; + } + if (horizon > 366) { + horizon = 366; + } + try { + SmHotelLocalDailyBatchDTO req = new SmHotelLocalDailyBatchDTO(); + req.setMode("FILL"); + req.setStartDate(today); + req.setEndDate(today.plusDays(horizon - 1L)); + req.setDailyStatus(1); + R> r = midService.batchLocalDaily(h.getId(), req); + if (r != null && r.getCode() == 0) { + ok++; + Object inserted = r.getData() == null ? null : r.getData().get("inserted"); + if (inserted instanceof Number) { + insertedTotal += ((Number) inserted).intValue(); + } + } + else { + fail++; + appendFail(failLines, h, r == null ? "返回为空" : StrUtil.blankToDefault(r.getMsg(), "业务失败")); + } + } + catch (Exception e) { + fail++; + appendFail(failLines, h, e.getMessage()); + } + } + int pct = total <= 0 ? 50 : Math.min(95, 5 + (int) (processed * 90L / total)); + progress(logId, pct, "ROLL", + "本地滚加中 已处理 " + processed + "/" + total + " 成功 " + ok + " 失败 " + fail + + " 新补 " + insertedTotal); + if (rows.size() < batchSize) { + break; + } + } + + SmHotelSyncLogEntity done = syncLogMapper.selectById(logId); + if (done == null) { + return; + } + done.setStatus(STATUS_SUCCESS); + done.setStage("DONE"); + done.setStageMsg("本地滚加完成,新补日历行 " + insertedTotal); + done.setProgressPct(100); + done.setSuccessCount(ok); + done.setFailCount(fail); + done.setSkipCount(skip); + done.setLocalCount(total); + done.setNewCount((long) insertedTotal); + applyFailDetail(done, failLines, fail); + done.setCostMs(System.currentTimeMillis() - t0); + done.setFinishTime(LocalDateTime.now()); + done.setUpdateTime(LocalDateTime.now()); + syncLogMapper.updateById(done); + log.info("|本地滚加|完成|logId={}|ok={}|fail={}|inserted={}|", logId, ok, fail, insertedTotal); + } + catch (Throwable e) { + log.error("|本地滚加|失败|logId={}|", logId, e); + failLog(logId, e.getMessage(), System.currentTimeMillis() - t0); + } + } + /** * 对本地全部启用酒店逐家 pullFromUpstream(综合信息 + 未来 price-days 房价)。 */ @@ -1029,10 +1175,12 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService { m.put("hotelListDailyCron", properties.getHotelListDailyCron()); m.put("priceRollDailyEnabled", properties.isPriceRollDailyEnabled()); m.put("priceRollDailyCron", properties.getPriceRollDailyCron()); + m.put("localDailyRollEnabled", properties.isLocalDailyRollEnabled()); + m.put("localDailyRollCron", properties.getLocalDailyRollCron()); m.put("autoImportNewHotels", properties.isAutoImportNewHotels()); m.put("autoMarkOfflineHotels", properties.isAutoMarkOfflineHotels()); - m.put("hint", "改 Nacos/本地 shangyou-hotel.hotel-list-daily-cron、price-roll-daily-cron;" - + "若用 XXL 请设 spring-schedule-enabled=false,在 XXL 控制台配 Cron"); + m.put("hint", "改 Nacos/本地 shangyou-hotel.hotel-list-daily-cron、price-roll-daily-cron、local-daily-roll-cron;" + + "若用 XXL 请设 spring-schedule-enabled=false,在 XXL 控制台配 Cron(含 smHotelLocalDailyRollJobHandler)"); return m; } @@ -1076,4 +1224,24 @@ public class SmHotelSyncServiceImpl implements SmHotelSyncService { return Map.of("ok", true, "async", true, "logId", r.getData().getId()); }); } + + @Override + public Map jobRollLocalDaily() { + return jobRollLocalDaily("XXL"); + } + + @Override + public Map jobRollLocalDaily(String triggerType) { + Long tenantId = properties.getCallbackTenantId() == null ? 1L : properties.getCallbackTenantId(); + return TenantBroker.applyAs(tenantId, tid -> { + if (!properties.isLocalDailyRollEnabled()) { + return Map.of("skipped", true, "reason", "local-daily-roll-enabled=false"); + } + R r = startLocalDailyRollAsync(StrUtil.blankToDefault(triggerType, "XXL")); + if (r.getCode() != 0) { + return Map.of("ok", false, "msg", r.getMsg()); + } + return Map.of("ok", true, "async", true, "logId", r.getData().getId()); + }); + } }