diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardApptDto.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardApptDto.java index 816cf73c5..7a0bd3b8b 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardApptDto.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/HardApptDto.java @@ -1,6 +1,7 @@ package com.bsd.pjcloud.hard.api.dto; import com.bsd.pjcloud.hard.api.entity.HardApptEntity; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import java.util.List; @@ -11,6 +12,11 @@ import java.util.List; @Data public class HardApptDto extends HardApptEntity { + /** 规则包含的完整窗口及目标集合;编辑时为空表示只更新规则头。 */ private List windowList; + /** 只筛选存在匹配窗口的规则:true 已过期、false 未过期、null 全部;不参与持久化。 */ + @Schema(description = "开放时间是否已过期:true 已过期,false 未过期,不传查全部;返回的 windowList 保留完整窗口供编辑") + private Boolean windowExpired; + } diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptItemVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptItemVo.java index 3186053ba..f600336b0 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptItemVo.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptItemVo.java @@ -4,6 +4,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import java.time.LocalDate; +import java.time.LocalDateTime; /** * 动态时间窗可预约目标项展示:目标用餐日期 × 餐段。 @@ -12,9 +13,15 @@ import java.time.LocalDate; @Schema(description = "动态时间窗可预约目标项展示") public class HardApptItemVo { + /** 此窗口配置的实际用餐日期,不代表开放预约的日期。 */ @Schema(description = "目标用餐日期") private LocalDate targetDate; + /** 此日期的餐段持久化编码,来自 daily_menu 字典。 */ @Schema(description = "餐段字典值(daily_menu)") private String hourType; + + /** 同商户全部启用窗口合并后的免审申请截止,北京时间;无启用目标时为空,仅用于展示。 */ + @Schema(description = "同日期餐段最终免审退款截止,只读计算值") + private LocalDateTime autoRefundDeadline; } diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptOptionVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptOptionVo.java index b3ea7414b..06a21a992 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptOptionVo.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptOptionVo.java @@ -47,4 +47,7 @@ public class HardApptOptionVo { /** 该日期和餐段本批次的实际截止时间。 */ private LocalDateTime reserveEndDateTime; + + /** 动态模式同日期餐段全部启用窗口的最终免审申请截止,北京时间;周期模式为空,仅供展示。 */ + private LocalDateTime autoRefundDeadline; } diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptWindowVo.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptWindowVo.java index 704c2e9e3..2cdf7fe13 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptWindowVo.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/vo/HardApptWindowVo.java @@ -22,6 +22,10 @@ public class HardApptWindowVo { @Schema(description = "结束预约时间") private LocalDateTime endTime; + /** 查询时服务器北京时间严格晚于结束时间才过期,结束时刻仍有效;只读且不持久化。 */ + @Schema(description = "开放预约时间是否已过期,结束时刻仍有效", accessMode = Schema.AccessMode.READ_ONLY) + private boolean expired; + @Schema(description = "免审退款申请截止时间(闭区间),空=开放预约结束时间") private LocalDateTime autoRefundTime; diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/HardApptController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/HardApptController.java index e24c4fea0..cda91d872 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/HardApptController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/HardApptController.java @@ -52,6 +52,14 @@ public class HardApptController { /* ************************************* end app end ************************************* */ + /** 按当前用户商户数据权限只读合并草稿,返回涉及日期餐段的实际开放范围与免审退款截止。 */ + @Operation(summary = "推演预约规则草稿", description = "只读合并草稿与其他启用规则,不写配置或订单") + @PostMapping("/preview") + @PreAuthorize("@pms.hasPermission('hard_hardAppt_view')") + public R> preview(@RequestBody HardApptDto dto) { + return R.ok(hardApptService.preview(dto, DataScope.of())); + } + @Operation(summary = "分页查询", description = "分页查询") @GetMapping("/page") @PreAuthorize("@pms.hasPermission('hard_hardAppt_view')") diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptReservationService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptReservationService.java index ace408dfe..eda629e60 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptReservationService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptReservationService.java @@ -11,6 +11,7 @@ import java.util.List; */ public interface HardApptReservationService { + /** 按商户当前预约模式返回日期餐段;动态模式叠加全部启用窗口,每个目标最多一个选项。 */ List listOptions(Long mchId); /** @@ -22,17 +23,19 @@ public interface HardApptReservationService { */ List previewOptions(Long mchId, Integer days); + /** 校验真实窗口归属和目标后匹配当前开放区间,返回仅供服务端固化订单的可信时间快照。 */ ReservationValidation validateReservation(Long mchId, Long apptId, Long apptRuleId, String hourType, LocalDate apptDate); /** - * 服务端校验通过的预约上下文,窗口/周期批次截止时间可直接作为自动退款订单快照,后续不再重算规则。 + * 服务端校验通过的预约上下文。动态模式按同目标全部启用窗口计算免审截止, + * 周期模式保留原批次截止;订单保存快照后,规则增删改均不回算历史订单。 * * @param apptId 动态预约窗口 ID(订单 appt_id 固化该值);周期模式为空 * @param hourType 标准化餐段编码 * @param apptDate 实际用餐日期 * @param apptRuleId 周期预约规则 ID;动态模式为空 - * @param eligibilityDeadline 当前已校验预约窗口的截止时间,使用服务器现有预约时间口径 + * @param eligibilityDeadline 动态目标最终免审截止或周期批次截止,北京时间,包含截止准点 * @param usageEndDateTime 实际用餐日的核销有效期结束时间,固定为该日 23:59:59,供订单固化后重扫使用 */ record ReservationValidation(Long apptId, String hourType, LocalDate apptDate, Long apptRuleId, diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptService.java index dcbe7dff5..fc1e57add 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptService.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/HardApptService.java @@ -8,6 +8,8 @@ import com.bsd.pjcloud.hard.api.dto.HardApptDto; import com.bsd.pjcloud.hard.api.dto.HardApptWindowDto; import com.bsd.pjcloud.hard.api.entity.HardApptEntity; import com.bsd.pjcloud.hard.api.vo.HardApptVo; +import com.bsd.pjcloud.hard.api.vo.HardApptDateOptionsVo; +import com.bsd.pjcloud.hard.service.support.HardApptWindowResolver.WindowSource; import java.util.ArrayList; import java.util.List; @@ -30,7 +32,7 @@ public interface HardApptService extends IService { void delByIds(ArrayList list); /** - * 窗口级修改:更新单个开放窗口(时间/免审退款截止/明细全量重建),商户级唯一性排除本窗口。 + * 窗口级修改:更新时间/免审退款截止并重建明细,允许与同商户其他启用窗口叠加。 */ void updateWindow(HardApptWindowDto window); @@ -50,4 +52,21 @@ public interface HardApptService extends IService { * 查询详情 */ HardApptVo join(Long id); -} \ No newline at end of file + + /** + * 读取同商户全部启用规则的窗口和明细,不受管理端分页影响;租户隔离沿用现有 Mapper。 + * + * @param mchId 目标商户 ID,不能混入其他商户的数据 + * @return 窗口及其明细,无配置时返回空集合 + */ + List listEnabledWindows(Long mchId); + + /** + * 只读推演草稿涉及的日期餐段,合并同商户其他启用规则;编辑时先验证归属及数据范围再排除原规则。 + * + * @param dto 未保存的规则头及窗口明细,停用草稿不贡献开放窗口 + * @param dataScope 当前后台用户的数据权限范围 + * @return 草稿目标的最终预约选项及退款截止,不写数据库或订单 + */ + List preview(HardApptDto dto, DataScope dataScope); +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImpl.java index 838b680f5..45d6c6dc5 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImpl.java @@ -23,6 +23,9 @@ import com.bsd.pjcloud.hard.service.HardApptMerchantModeService; import com.bsd.pjcloud.hard.service.HardApptReservationService; import com.bsd.pjcloud.hard.service.HardApptService; import com.bsd.pjcloud.hard.service.support.HardApptCycleRuleCalculator; +import com.bsd.pjcloud.hard.service.support.HardApptWindowResolver; +import com.bsd.pjcloud.hard.service.support.HardApptWindowResolver.Target; +import com.bsd.pjcloud.hard.service.support.HardApptWindowResolver.ResolvedTarget; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -39,7 +42,6 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.TreeMap; -import java.util.stream.Collectors; /** * 预约列表和下单共用同一套规则,避免客户端展示状态与服务端最终校验不一致。 @@ -49,7 +51,8 @@ import java.util.stream.Collectors; @RequiredArgsConstructor public class HardApptReservationServiceImpl implements HardApptReservationService { - static final ZoneId RESERVATION_ZONE = ZoneId.of("Asia/Shanghai"); + /** 与动态解析器使用同一北京时间,周期计算继续保持原时区。 */ + static final ZoneId RESERVATION_ZONE = HardApptWindowResolver.RESERVATION_ZONE; private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm"); private static final int DEFAULT_PREVIEW_DAYS = 7; private static final int MAX_PREVIEW_DAYS = 31; @@ -60,6 +63,7 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic private final HardApptCycleRuleService cycleRuleService; private final HardApptMerchantModeService merchantModeService; + /** 返回当前商户的日期餐段,动态模式使用统一解析器,按周模式保留原批次计算。 */ @Override public List listOptions(Long mchId) { return listOptions(mchId, nowInShanghai()); @@ -143,25 +147,16 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic } /** - * 动态模式按"窗口×明细"展开:仅输出 startDate 及以后的目标日期,endDate 非空时限制在推演范围内。 - * 选项按 "窗口ID:餐段" 去重,正常数据由保存时的商户级唯一性保证不重复,这里兜底脏数据。 + * 按日期餐段汇总商户全部启用窗口,每个目标只返回一个选项;推演范围只筛选用餐日期, + * 不能提前过滤未来开放窗口,否则最终预约范围和退款截止会随查询时刻漂移。 */ private void addDynamicOptions(Long mchId, LocalDateTime now, LocalDate startDate, LocalDate endDate, Map> optionsByDate) { - for (EnabledWindow enabled : findEnabledWindows(mchId)) { - Availability availability = windowAvailability(enabled.window(), now); - for (HardApptItemEntity item : enabled.items()) { - if (item.getTargetDate() == null || item.getTargetDate().isBefore(startDate)) { - continue; - } - if (endDate != null && item.getTargetDate().isAfter(endDate)) { - continue; - } - HardApptOptionVo option = buildDynamicOption(enabled.window(), item, availability); - optionsByDate.computeIfAbsent(item.getTargetDate(), key -> new LinkedHashMap<>()) - .put("appt:" + enabled.window().getId() + ":" + item.getHourType(), option); - } - } + HardApptWindowResolver.resolve(hardApptService.listEnabledWindows(mchId), now).forEach((target, resolved) -> { + if (target.date().isBefore(startDate) || endDate != null && target.date().isAfter(endDate)) return; + optionsByDate.computeIfAbsent(target.date(), key -> new LinkedHashMap<>()) + .put("appt:" + target.hourType(), HardApptWindowResolver.toOption(target, resolved, now)); + }); } /** @@ -253,6 +248,7 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic return validateReservation(mchId, apptId, apptRuleId, hourType, apptDate, nowInShanghai()); } + /** 固定当前时刻校验目标和窗口归属,并返回当前代表窗口及不可由客户端覆盖的最终退款截止。 */ ReservationValidation validateReservation(Long mchId, Long apptId, Long apptRuleId, String hourType, LocalDate apptDate, LocalDateTime now) { HardApptReserveModeEnum merchantMode = resolveMerchantMode(mchId); @@ -294,21 +290,20 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic throw new I18nBizException(ErrorCodes.HARD_APPT_DATE_NO_SLOT); } - Availability availability = windowAvailability(window, now); - if (!availability.available()) { - throwUnavailable(availability); + // 先验证客户端原窗口归属和目标,之后才能在同一目标的其他启用窗口间重匹配。 + // 旧页面跨过一个窗口截止时,仍可沿后续开放窗口下单,最终退款截止始终由全部窗口计算。 + ResolvedTarget resolved = HardApptWindowResolver.resolve(hardApptService.listEnabledWindows(mchId), now) + .get(new Target(apptDate, hourType)); + if (resolved == null) throw new I18nBizException(ErrorCodes.HARD_APPT_DATE_NO_SLOT); + if (!resolved.available()) { + throw new I18nBizException(resolved.start().isAfter(now) + ? ErrorCodes.HARD_APPT_NOT_OPEN : ErrorCodes.HARD_APPT_CLOSED); } - return new ReservationValidation(window.getId(), hourType, apptDate, null, resolveAutoRefundDeadline(window), - resolveUsageEndDateTime(apptDate)); - } - - /** - * 免审退款申请截止:窗口持久化的可自动退款时间优先,为空回退开放预约结束时间(与周期模式批次截止同源)。 - */ - private LocalDateTime resolveAutoRefundDeadline(HardApptWindowEntity window) { - return window.getAutoRefundTime() != null ? window.getAutoRefundTime() : window.getEndTime(); + return new ReservationValidation(resolved.window().getId(), hourType, apptDate, null, + resolved.autoRefundDeadline(), resolveUsageEndDateTime(apptDate)); } + /** 校验按周预约配置,继续沿用原有周批次和截止口径。 */ private ReservationValidation validateCycleReservation(Long mchId, Long apptRuleId, String hourType, LocalDate apptDate, LocalDateTime now) { if (apptDate == null || HardHourTypeEnum.valueOfByType(hourType) == null) { throw new I18nBizException(ErrorCodes.HARD_APPT_DATE_MEAL_REQUIRED); @@ -361,41 +356,6 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic return new CycleMatch(rule, cycleAvailability(rule, batch)); } - /** - * 商户全部启用规则的开放窗口及其明细(窗口按开始时间升序,明细按日期+餐段排序)。 - */ - private List findEnabledWindows(Long mchId) { - List ruleIds = hardApptService.list(new LambdaQueryWrapper() - .eq(HardApptEntity::getMchId, mchId) - .eq(HardApptEntity::getStatus, YesNoEnum.YES.getCode())).stream() - .map(HardApptEntity::getId).toList(); - if (ruleIds.isEmpty()) { - return List.of(); - } - List windows = hardApptWindowMapper.selectList( - new LambdaQueryWrapper() - .in(HardApptWindowEntity::getApptId, ruleIds) - .orderByAsc(HardApptWindowEntity::getStartTime)); - if (windows.isEmpty()) { - return List.of(); - } - Map> itemsByWindow = hardApptItemMapper.selectList( - new LambdaQueryWrapper() - .in(HardApptItemEntity::getWindowId, - windows.stream().map(HardApptWindowEntity::getId).toList())).stream() - .collect(Collectors.groupingBy(HardApptItemEntity::getWindowId)); - return windows.stream() - .map(window -> new EnabledWindow(window, - itemsByWindow.getOrDefault(window.getId(), List.of()).stream() - .filter(item -> item.getTargetDate() != null && item.getHourType() != null) - .sorted((left, right) -> { - int byDate = left.getTargetDate().compareTo(right.getTargetDate()); - return byDate != 0 ? byDate : left.getHourType().compareTo(right.getHourType()); - }) - .toList())) - .toList(); - } - /** * 窗口开放性判定:闭区间 [start, end],开始和结束准点均可提交。 */ @@ -413,28 +373,6 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic return Availability.available(window.getStartTime(), window.getEndTime()); } - /** - * 动态选项以窗口 ID 作为 id(App 提交订单 appt_id 即该值),窗口起止时间原样透出供客户端展示。 - */ - private static HardApptOptionVo buildDynamicOption(HardApptWindowEntity window, HardApptItemEntity item, - Availability availability) { - HardApptOptionVo option = new HardApptOptionVo(); - option.setId(window.getId()); - option.setReserveMode(HardApptReserveModeEnum.TIME_WINDOW.getType()); - HardHourTypeEnum hourType = HardHourTypeEnum.valueOfByType(item.getHourType()); - String hourTypeName = hourType != null ? hourType.getDescription() : item.getHourType(); - option.setHourType(item.getHourType()); - option.setHourTypeName(hourTypeName); - option.setTitle(hourTypeName); - option.setStartTime(window.getStartTime()); - option.setEndTime(window.getEndTime()); - option.setAvailable(availability.available()); - option.setUnavailableReason(availability.resolvedUnavailableReason()); - option.setReserveStartDateTime(availability.startDateTime()); - option.setReserveEndDateTime(availability.endDateTime()); - return option; - } - private HardApptOptionVo buildCycleOption(HardHourTypeEnum hourType, Availability availability) { HardApptOptionVo option = new HardApptOptionVo(); option.setReserveMode(HardApptReserveModeEnum.WEEKLY_CYCLE.getType()); @@ -524,6 +462,4 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic record CycleMatch(HardApptCycleRuleVo rule, Availability availability) { } - record EnabledWindow(HardApptWindowEntity window, List items) { - } } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptServiceImpl.java index 557a939db..95bfd001d 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptServiceImpl.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/HardApptServiceImpl.java @@ -5,6 +5,7 @@ import cn.hutool.core.util.ObjectUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.bsd.pjcloud.common.core.constant.CommonConstants; import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; import com.bsd.pjcloud.common.data.datascope.DataScope; import com.bsd.pjcloud.hard.api.constant.HardApptReserveModeEnum; @@ -17,6 +18,16 @@ import com.bsd.pjcloud.hard.api.entity.HardApptItemEntity; import com.bsd.pjcloud.hard.api.entity.HardApptWindowEntity; import com.bsd.pjcloud.hard.api.vo.HardApptItemVo; import com.bsd.pjcloud.hard.api.vo.HardApptVo; +import com.bsd.pjcloud.hard.api.vo.HardApptDateOptionsVo; +import com.bsd.pjcloud.hard.api.vo.HardApptOptionVo; +import com.bsd.pjcloud.hard.service.support.HardApptWindowResolver; +import com.bsd.pjcloud.hard.service.support.HardApptWindowResolver.WindowSource; +import com.bsd.pjcloud.hard.service.support.HardApptWindowResolver.Target; +import com.bsd.pjcloud.hard.service.support.HardApptWindowResolver.ResolvedTarget; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.wallet.api.dto.MchManageDto; +import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; +import com.bsd.pjcloud.wallet.api.vo.MchManageVo; import com.bsd.pjcloud.hard.api.vo.HardApptWindowVo; import com.bsd.pjcloud.hard.mapper.HardApptItemMapper; import com.bsd.pjcloud.hard.mapper.HardApptMapper; @@ -30,13 +41,12 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.validation.annotation.Validated; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.ArrayList; import java.util.LinkedHashMap; -import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.Set; import java.util.stream.Collectors; /** @@ -48,20 +58,26 @@ import java.util.stream.Collectors; @Service public class HardApptServiceImpl extends ServiceImpl implements HardApptService { + /** 窗口持久化入口,沿用租户拦截和逻辑删除。 */ private final HardApptWindowMapper hardApptWindowMapper; + /** 窗口目标明细入口,用于批量加载和窗口内去重后的保存。 */ private final HardApptItemMapper hardApptItemMapper; + /** 商户当前预约模式及部门来源。 */ private final HardApptMerchantModeService merchantModeService; + /** 草稿预览通过既有带数据权限的商户接口校验访问范围。 */ + private final RemoteMchService remoteMchService; + /** - * 新增:一条规则携带多个开放窗口,逐窗口校验并做商户级唯一性检查。 + * 新增:一条规则携带多个开放窗口,逐窗口校验,允许同一目标由多个窗口叠加。 */ @Override @Transactional(rollbackFor = Exception.class) public Long create(HardApptDto dto) { HardApptMerchantModeService.MerchantModeContext merchantContext = requireTimeWindowMerchant(dto.getMchId()); - List windows = validateAndNormalizeWindows(dto, null); + List windows = validateAndNormalizeWindows(dto); dto.setStatus(ObjectUtil.defaultIfEmpty(dto.getStatus(), YesNoEnum.YES.getCode())); dto.setDeptId(merchantContext.merchant().getDeptId()); baseMapper.insert(dto); @@ -85,7 +101,7 @@ public class HardApptServiceImpl extends ServiceImpl windows = null; if (ObjectUtil.isNotEmpty(dto.getWindowList())) { HardApptMerchantModeService.MerchantModeContext merchantContext = requireTimeWindowMerchant(existing.getMchId()); - windows = validateAndNormalizeWindows(dto, dto.getId()); + windows = validateAndNormalizeWindows(dto); dto.setDeptId(merchantContext.merchant().getDeptId()); } baseMapper.updateById(dto); @@ -110,7 +126,7 @@ public class HardApptServiceImpl extends ServiceImpl scopePage(Page page, HardApptDto dto, DataScope of) { - LambdaQueryWrapper wrapper = buildQueryWrapper(dto); + LocalDateTime now = LocalDateTime.now(HardApptWindowResolver.RESERVATION_ZONE); + LambdaQueryWrapper wrapper = buildQueryWrapper(dto, now); Page pageList = baseMapper.selectPageByScope(page, wrapper, of); List hardApptVos = BeanUtil.copyToList(pageList.getRecords(), HardApptVo.class); pageList.setRecords(hardApptVos); - disposeWindowList(hardApptVos); + disposeWindowList(hardApptVos, now); return pageList; } + /** 按与分页相同的筛选口径查询规则,窗口保留完整数据用于编辑和退款预览。 */ @Override public List scopeList(HardApptDto dto, DataScope of) { - List list = baseMapper.selectListByScope(buildQueryWrapper(dto), of); + LocalDateTime now = LocalDateTime.now(HardApptWindowResolver.RESERVATION_ZONE); + List list = baseMapper.selectListByScope(buildQueryWrapper(dto, now), of); List resultList = BeanUtil.copyToList(list, HardApptVo.class); - disposeWindowList(resultList); + disposeWindowList(resultList, now); return resultList; } @@ -198,47 +216,38 @@ public class HardApptServiceImpl extends ServiceImpl resultList = List.of(BeanUtil.toBean(hardAppt, HardApptVo.class)); - disposeWindowList(resultList); + disposeWindowList(resultList, LocalDateTime.now(HardApptWindowResolver.RESERVATION_ZONE)); return resultList.get(0); } - private LambdaQueryWrapper buildQueryWrapper(HardApptDto dto) { - return new LambdaQueryWrapper() + /** 规则列表沿用数据范围查询;过期筛选只关联同租户、同商户且未删除的窗口,时间取本次查询快照。 */ + private LambdaQueryWrapper buildQueryWrapper(HardApptDto dto, LocalDateTime now) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() .eq(ObjectUtil.isNotEmpty(dto.getMchId()), HardApptEntity::getMchId, dto.getMchId()) .eq(ObjectUtil.isNotEmpty(dto.getDeptId()), HardApptEntity::getDeptId, dto.getDeptId()) .orderByDesc(HardApptEntity::getId); + if (dto.getWindowExpired() != null) { + // 比较符仅由布尔条件决定,值始终绑定参数;显式关联租户和商户,保留外层数据权限及租户拦截。 + String comparison = dto.getWindowExpired() ? "<" : ">="; + wrapper.exists("SELECT 1 FROM hard_appt_window w WHERE w.appt_id = hard_appt.id" + + " AND w.mch_id = hard_appt.mch_id AND w.tenant_id = hard_appt.tenant_id" + + " AND w.del_flag = {0} AND w.end_time " + comparison + " {1}", CommonConstants.STATUS_NORMAL, now); + } + return wrapper; } /** - * 服务端负责规范化窗口,避免绕过后台页面提交倒置时间窗、非法餐段或重复目标。 - * 商户级唯一性:与该商户其他启用规则的所有窗口比较,开放时间不得重叠、可预约日期不得重复。 + * 逐窗口校验和明细去重。跨窗口时间、日期均允许重复,实际可约范围由统一解析器取并集。 * - * @param dto 规则入参 - * @param selfRuleId 编辑时的当前规则 ID,唯一性检查排除自身;新增传 null + * @param dto 规则草稿,至少含一个有日期餐段的窗口 + * @return 归一化后的窗口列表,仅调整当前入参,不写数据库 */ - private List validateAndNormalizeWindows(HardApptDto dto, Long selfRuleId) { + private List validateAndNormalizeWindows(HardApptDto dto) { List windowList = dto.getWindowList(); if (ObjectUtil.isEmpty(windowList)) { throw new RuntimeException("至少需要配置一个预约窗口"); } - for (int i = 0; i < windowList.size(); i++) { - normalizeWindow(windowList.get(i), i + 1); - } - // 规则内:窗口之间开放时间不重叠、可预约日期不重复 - for (int i = 0; i < windowList.size(); i++) { - for (int j = i + 1; j < windowList.size(); j++) { - HardApptWindowDto left = windowList.get(i); - HardApptWindowDto right = windowList.get(j); - if (left.getStartTime().isBefore(right.getEndTime()) && right.getStartTime().isBefore(left.getEndTime())) { - throw new RuntimeException("窗口 " + (i + 1) + " 与窗口 " + (j + 1) + " 开放时间重叠"); - } - LocalDate duplicatedDate = findFirstDuplicatedDate(left, right); - if (duplicatedDate != null) { - throw new RuntimeException("窗口 " + (i + 1) + " 与窗口 " + (j + 1) + " 可预约日期重复:" + duplicatedDate); - } - } - } - checkMerchantUniqueness(dto.getMchId(), windowList, selfRuleId, null); + for (int i = 0; i < windowList.size(); i++) normalizeWindow(windowList.get(i), i + 1); return windowList; } @@ -248,7 +257,7 @@ public class HardApptServiceImpl extends ServiceImpl windowList, Long selfRuleId, Long selfWindowId) { - List existingWindows = findMerchantWindows(mchId, selfRuleId, selfWindowId); - if (existingWindows.isEmpty()) { - return; - } - Map> existingDates = loadWindowDates(existingWindows); - for (int i = 0; i < windowList.size(); i++) { - HardApptWindowDto window = windowList.get(i); - for (HardApptWindowEntity existing : existingWindows) { - if (window.getStartTime().isBefore(existing.getEndTime()) - && existing.getStartTime().isBefore(window.getEndTime())) { - throw new RuntimeException("窗口 " + (i + 1) + " 开放时间与商户已存在窗口重叠"); - } - } - Set windowDates = window.getItemList().stream() - .map(HardApptItemDto::getTargetDate).collect(Collectors.toSet()); - for (Map.Entry> entry : existingDates.entrySet()) { - LocalDate duplicatedDate = windowDates.stream() - .filter(entry.getValue()::contains).findFirst().orElse(null); - if (duplicatedDate != null) { - throw new RuntimeException("窗口 " + (i + 1) + " 可预约日期 " + duplicatedDate + " 已存在于商户其他窗口"); - } - } - } - } - - private LocalDate findFirstDuplicatedDate(HardApptWindowDto left, HardApptWindowDto right) { - Set rightDates = right.getItemList().stream() - .map(HardApptItemDto::getTargetDate).collect(Collectors.toSet()); - for (HardApptItemDto item : left.getItemList()) { - if (rightDates.contains(item.getTargetDate())) { - return item.getTargetDate(); - } - } - return null; + @Override + public List listEnabledWindows(Long mchId) { + if (mchId == null) return List.of(); + List ruleIds = baseMapper.selectList(new LambdaQueryWrapper() + .eq(HardApptEntity::getMchId, mchId) + .eq(HardApptEntity::getStatus, YesNoEnum.YES.getCode())).stream().map(HardApptEntity::getId).toList(); + if (ruleIds.isEmpty()) return List.of(); + List windows = hardApptWindowMapper.selectList(new LambdaQueryWrapper() + .eq(HardApptWindowEntity::getMchId, mchId) + .in(HardApptWindowEntity::getApptId, ruleIds).orderByAsc(HardApptWindowEntity::getStartTime)); + if (windows.isEmpty()) return List.of(); + Map> items = hardApptItemMapper.selectList(new LambdaQueryWrapper() + .eq(HardApptItemEntity::getMchId, mchId) + .in(HardApptItemEntity::getWindowId, windows.stream().map(HardApptWindowEntity::getId).toList())) + .stream().collect(Collectors.groupingBy(HardApptItemEntity::getWindowId)); + return windows.stream().map(window -> new WindowSource(window, items.getOrDefault(window.getId(), List.of()))).toList(); } /** - * 查询商户启用规则的开放窗口(可按规则或窗口排除自身)。 + * 将草稿叠加到同商户其他启用配置,使用与下单相同的解析器,只返回草稿涉及的目标。 * - * @param selfRuleId 规则级编辑排除的规则 ID;null 不排除 - * @param selfWindowId 窗口级编辑排除的窗口 ID;null 不排除 + * @param dto 当前规则草稿,编辑时带原规则 ID + * @param dataScope 当前后台用户的数据权限上下文 + * @return 每个目标唯一的预约选项;停用且没有其他覆盖时该日餐段为空 */ - private List findMerchantWindows(Long mchId, Long selfRuleId, Long selfWindowId) { - LambdaQueryWrapper ruleWrapper = new LambdaQueryWrapper() - .eq(HardApptEntity::getMchId, mchId) - .eq(HardApptEntity::getStatus, YesNoEnum.YES.getCode()) - .ne(selfRuleId != null, HardApptEntity::getId, selfRuleId); - List ruleIds = baseMapper.selectList(ruleWrapper).stream() - .map(HardApptEntity::getId).toList(); - if (ruleIds.isEmpty()) { - return List.of(); + @Override + public List preview(HardApptDto dto, DataScope dataScope) { + return preview(dto, dataScope, LocalDateTime.now(HardApptWindowResolver.RESERVATION_ZONE)); + } + + /** 使用固定北京时间推演,保证同一请求内状态一致,也便于按业务示例核对边界。 */ + List preview(HardApptDto dto, DataScope dataScope, LocalDateTime now) { + if (dto == null || dto.getMchId() == null) throw new RuntimeException("商户不能为空"); + R> scoped = remoteMchService.scopeList(MchManageDto.builder().ids(List.of(dto.getMchId())).build()); + if (scoped == null || !scoped.isOk() || scoped.getData() == null + || scoped.getData().stream().noneMatch(merchant -> Objects.equals(merchant.getId(), dto.getMchId()))) { + throw new RuntimeException("无权预览当前商户或商户数据权限查询失败"); } - return hardApptWindowMapper.selectList(new LambdaQueryWrapper() - .in(HardApptWindowEntity::getApptId, ruleIds)) - .stream() - .filter(window -> !Objects.equals(window.getId(), selfWindowId)) - .toList(); - } - - private Map> loadWindowDates(List windows) { - List windowIds = windows.stream().map(HardApptWindowEntity::getId).toList(); - return hardApptItemMapper.selectList(new LambdaQueryWrapper() - .in(HardApptItemEntity::getWindowId, windowIds)).stream() - .collect(Collectors.groupingBy(HardApptItemEntity::getWindowId, - Collectors.mapping(HardApptItemEntity::getTargetDate, Collectors.toSet()))); + requireTimeWindowMerchant(dto.getMchId()); + if (dto.getId() != null && baseMapper.selectListByScope(new LambdaQueryWrapper() + .eq(HardApptEntity::getId, dto.getId()).eq(HardApptEntity::getMchId, dto.getMchId()), dataScope).isEmpty()) { + throw new RuntimeException("预约规则不存在或不在当前数据权限范围内"); + } + List draft = validateAndNormalizeWindows(dto); + List sources = new ArrayList<>(listEnabledWindows(dto.getMchId()).stream() + .filter(source -> !Objects.equals(source.window().getApptId(), dto.getId())).toList()); + Map> dates = new java.util.TreeMap<>(); + List targets = new ArrayList<>(); + for (HardApptWindowDto window : draft) { + HardApptWindowEntity entity = new HardApptWindowEntity(); + entity.setMchId(dto.getMchId()); + entity.setStartTime(window.getStartTime()); + entity.setEndTime(window.getEndTime()); + entity.setAutoRefundTime(window.getAutoRefundTime()); + List items = window.getItemList().stream().map(item -> { + targets.add(new Target(item.getTargetDate(), item.getHourType())); + dates.computeIfAbsent(item.getTargetDate(), key -> new ArrayList<>()); + HardApptItemEntity value = new HardApptItemEntity(); + value.setTargetDate(item.getTargetDate()); + value.setHourType(item.getHourType()); + return value; + }).toList(); + if (!Objects.equals(dto.getStatus(), YesNoEnum.NO.getCode())) sources.add(new WindowSource(entity, items)); + } + Map resolved = HardApptWindowResolver.resolve(sources, now); + targets.stream().distinct().sorted(java.util.Comparator.comparing(Target::date).thenComparing(Target::hourType)) + .forEach(target -> { + ResolvedTarget value = resolved.get(target); + if (value != null) dates.get(target.date()).add(HardApptWindowResolver.toOption(target, value, now)); + }); + return dates.entrySet().stream().map(entry -> { + HardApptDateOptionsVo day = new HardApptDateOptionsVo(); + day.setApptDate(entry.getKey()); + day.setApptList(entry.getValue()); + return day; + }).toList(); } + /** 保存归一化后的窗口和日期餐段明细,不改写任何已有订单快照。 */ private void saveWindows(Long apptId, Long mchId, List windows) { for (HardApptWindowDto window : windows) { HardApptWindowEntity windowEntity = new HardApptWindowEntity(); @@ -374,6 +394,7 @@ public class HardApptServiceImpl extends ServiceImpl windows = hardApptWindowMapper.selectList( new LambdaQueryWrapper().eq(HardApptWindowEntity::getApptId, apptId)); @@ -386,9 +407,9 @@ public class HardApptServiceImpl extends ServiceImpl records) { + private void disposeWindowList(List records, LocalDateTime now) { if (ObjectUtil.isEmpty(records)) return; List ruleIds = records.stream().map(HardApptVo::getId).filter(ObjectUtil::isNotNull).toList(); if (ruleIds.isEmpty()) return; @@ -400,6 +421,10 @@ public class HardApptServiceImpl extends ServiceImpl() .in(HardApptItemEntity::getWindowId, windows.stream().map(HardApptWindowEntity::getId).toList())) .stream().collect(Collectors.groupingBy(HardApptItemEntity::getWindowId)); + // 必须按商户加载全部启用规则,当前页以外的后续窗口也可能决定最终退款截止。 + Map> resolvedByMerchant = new LinkedHashMap<>(); + records.stream().map(HardApptVo::getMchId).filter(Objects::nonNull).distinct().forEach(mchId -> + resolvedByMerchant.put(mchId, HardApptWindowResolver.resolve(listEnabledWindows(mchId), now))); for (HardApptVo record : records) { record.setWindowList(windows.stream() .filter(window -> Objects.equals(window.getApptId(), record.getId())) @@ -408,6 +433,7 @@ public class HardApptServiceImpl extends ServiceImpl { @@ -418,6 +444,9 @@ public class HardApptServiceImpl extends ServiceImpl ACTIVE_ORDER = Comparator + .comparing((WindowSource source) -> source.window().getEndTime(), Comparator.reverseOrder()) + .thenComparing(source -> source.window().getId(), Comparator.nullsLast(Comparator.naturalOrder())); + + /** 工具类没有实例状态,禁止实例化。 */ + private HardApptWindowResolver() { + } + + /** + * 同一商户下的预约目标,按用餐日期、餐段区分;不得把早餐和午餐的退款截止混在一起。 + * + * @param date 实际用餐日期 + * @param hourType daily_menu 餐段编码 + */ + public record Target(LocalDate date, String hourType) { + } + + /** + * 调用方提供的一个启用窗口及其明细;草稿窗口 ID 可以为空,不能用于真实下单。 + * + * @param window 原始窗口,只读使用 + * @param items 此窗口的日期餐段明细,空集合表示没有目标 + */ + public record WindowSource(HardApptWindowEntity window, List items) { + } + + /** + * 当前应展示的连续区间和该目标全部窗口的退款截止。区间只合并重叠或相接窗口, + * 退款截止包含尚未开放的后续窗口,因此不能从当前选中的代表窗口反推。 + * + * @param window 当前/最近将开放/最近结束的代表窗口 + * @param available 当前时刻是否命中实际开放区间 + * @param start 当前展示的连续区间起点,闭区间 + * @param end 当前展示的连续区间终点,闭区间 + * @param autoRefundDeadline 所有匹配窗口有效退款截止的最大值 + */ + public record ResolvedTarget(HardApptWindowEntity window, boolean available, LocalDateTime start, + LocalDateTime end, LocalDateTime autoRefundDeadline) { + } + + /** + * 汇总同一商户的全部启用窗口,按目标返回唯一结果;脏窗口和非法餐段不参与计算。 + * + * @param sources 已完成商户、租户和启用状态筛选的窗口集合 + * @param now 北京时间当前时刻,同一次解析内固定不变 + * @return 按日期、餐段稳定排序的目标结果 + */ + public static Map resolve(List sources, LocalDateTime now) { + Map> grouped = new TreeMap<>(Comparator + .comparing(Target::date).thenComparing(Target::hourType)); + for (WindowSource source : sources) { + if (!validWindow(source.window())) continue; + for (HardApptItemEntity item : source.items()) { + if (item == null || item.getTargetDate() == null + || HardHourTypeEnum.valueOfByType(item.getHourType()) == null) continue; + Target target = new Target(item.getTargetDate(), item.getHourType()); + List matching = grouped.computeIfAbsent(target, key -> new ArrayList<>()); + if (!matching.contains(source)) matching.add(source); + } + } + Map result = new LinkedHashMap<>(); + grouped.forEach((target, matching) -> result.put(target, resolveTarget(matching, now))); + return result; + } + + /** + * 校验配置窗口可参与计算。历史非法退款时间不允许扩大退款资格,整个脏窗口跳过。 + * + * @param window 持久化或草稿窗口 + * @return 时间和可选退款截止均合法时返回 true + */ + private static boolean validWindow(HardApptWindowEntity window) { + if (window == null || window.getStartTime() == null || window.getEndTime() == null + || !window.getEndTime().isAfter(window.getStartTime())) return false; + LocalDateTime refund = window.getAutoRefundTime(); + return refund == null || refund.isAfter(window.getStartTime()) && !refund.isAfter(window.getEndTime()); + } + + /** + * 按开放状态选择代表窗口,再展开它所在的连续区间。不能简单取最早开始、最晚结束, + * 否则中间关闭的空档会被错误标为可预约。 + * + * @param sources 同一个日期餐段的合法窗口 + * @param now 固定的北京时间当前时刻 + * @return 唯一代表窗口、所在区间及最终退款截止 + */ + private static ResolvedTarget resolveTarget(List sources, LocalDateTime now) { + List active = sources.stream().filter(source -> contains(source.window(), now)).toList(); + WindowSource chosen; + if (!active.isEmpty()) { + chosen = active.stream().min(ACTIVE_ORDER).orElseThrow(); + } else { + chosen = sources.stream().filter(source -> source.window().getStartTime().isAfter(now)) + .min(Comparator.comparing((WindowSource source) -> source.window().getStartTime()) + .thenComparing(ACTIVE_ORDER)) + .orElseGet(() -> sources.stream().min(ACTIVE_ORDER).orElseThrow()); + } + List ordered = sources.stream() + .sorted(Comparator.comparing(source -> source.window().getStartTime())).toList(); + LocalDateTime start = ordered.get(0).window().getStartTime(); + LocalDateTime end = ordered.get(0).window().getEndTime(); + LocalDateTime selectedStart = start; + LocalDateTime selectedEnd = end; + for (int i = 1; i <= ordered.size(); i++) { + if (i < ordered.size() && !ordered.get(i).window().getStartTime().isAfter(end)) { + LocalDateTime nextEnd = ordered.get(i).window().getEndTime(); + if (nextEnd.isAfter(end)) end = nextEnd; + continue; + } + if (!chosen.window().getStartTime().isBefore(start) && !chosen.window().getEndTime().isAfter(end)) { + selectedStart = start; + selectedEnd = end; + break; + } + if (i < ordered.size()) { + start = ordered.get(i).window().getStartTime(); + end = ordered.get(i).window().getEndTime(); + } + } + LocalDateTime refund = sources.stream().map(source -> source.window().getAutoRefundTime() == null + ? source.window().getEndTime() : source.window().getAutoRefundTime()) + .max(Comparator.naturalOrder()).orElseThrow(); + return new ResolvedTarget(chosen.window(), !active.isEmpty(), selectedStart, selectedEnd, refund); + } + + /** + * 真实开放窗口按闭区间判断,开始和结束准点均可下单。 + * + * @param window 已校验时间的窗口 + * @param now 北京时间待判定时刻 + * @return 是否处于窗口内 + */ + private static boolean contains(HardApptWindowEntity window, LocalDateTime now) { + return !now.isBefore(window.getStartTime()) && !now.isAfter(window.getEndTime()); + } + + /** + * 将统一解析结果转换为现有 App/后台预约选项协议;原 startTime/endTime 保持代表窗口原值, + * reserveStartDateTime/reserveEndDateTime 表示真实连续区间,新增截止仅供展示,不作为客户端可信入参。 + * + * @param target 日期餐段目标 + * @param resolved 当前解析结果 + * @param now 与解析共用的北京时间,不能读取系统时钟导致推演理由与状态不一致 + * @return 每个目标唯一的展示选项 + */ + public static HardApptOptionVo toOption(Target target, ResolvedTarget resolved, LocalDateTime now) { + HardApptOptionVo option = new HardApptOptionVo(); + option.setId(resolved.window().getId()); + option.setReserveMode(HardApptReserveModeEnum.TIME_WINDOW.getType()); + option.setHourType(target.hourType()); + String name = Objects.requireNonNull(HardHourTypeEnum.valueOfByType(target.hourType())).getDescription(); + option.setHourTypeName(name); + option.setTitle(name); + option.setStartTime(resolved.window().getStartTime()); + option.setEndTime(resolved.window().getEndTime()); + option.setAvailable(resolved.available()); + if (!resolved.available()) { + option.setUnavailableReason(MsgUtils.getMessage(resolved.start().isAfter(now) + ? ErrorCodes.HARD_APPT_NOT_OPEN : ErrorCodes.HARD_APPT_CLOSED)); + } + option.setReserveStartDateTime(resolved.start()); + option.setReserveEndDateTime(resolved.end()); + option.setAutoRefundDeadline(resolved.autoRefundDeadline()); + return option; + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImplTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImplTest.java index 89a38fe87..f00160431 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImplTest.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardApptReservationServiceImplTest.java @@ -18,6 +18,7 @@ import com.bsd.pjcloud.common.core.exception.ErrorCodes; import com.bsd.pjcloud.common.core.exception.I18nBizException; import com.baomidou.mybatisplus.core.conditions.Wrapper; import com.bsd.pjcloud.hard.service.support.HardApptCycleRuleCalculator; +import com.bsd.pjcloud.hard.service.support.HardApptWindowResolver.WindowSource; import com.bsd.pjcloud.wallet.api.vo.MchManageVo; import org.junit.jupiter.api.Test; @@ -178,6 +179,36 @@ class HardApptReservationServiceImplTest { .map(HardApptDateOptionsVo::getApptDate).toList()); } + /** 重叠窗口只显示一个餐段;旧窗口 ID 在后续窗口开放时仍可提交,固化最终退款截止。 */ + @Test + void oldWindowIdRebindsToActiveWindowAndKeepsFinalRefundDeadline() { + HardApptEntity rule = dynamicRule(); + HardApptWindowEntity first = wideWindow(); + first.setStartTime(LocalDateTime.of(2026, 10, 19, 0, 0)); + first.setEndTime(LocalDateTime.of(2026, 10, 20, 12, 0)); + first.setAutoRefundTime(null); + HardApptWindowEntity next = wideWindow(); + next.setId(2L); + next.setStartTime(LocalDateTime.of(2026, 10, 20, 0, 0)); + next.setEndTime(LocalDateTime.of(2026, 10, 22, 12, 0)); + next.setAutoRefundTime(null); + LocalDate target = LocalDate.of(2026, 10, 23); + List firstItems = List.of(windowItem(1L, target, "1")); + HardApptReservationServiceImpl service = dynamicService(rule, first, firstItems, 1L); + HardApptService apptService = (HardApptService) org.springframework.test.util.ReflectionTestUtils + .getField(service, "hardApptService"); + when(apptService.listEnabledWindows(10L)).thenReturn(List.of(new WindowSource(first, firstItems), + new WindowSource(next, List.of(windowItem(2L, target, "1"))))); + LocalDateTime now = LocalDateTime.of(2026, 10, 21, 8, 0); + var options = service.listOptions(10L, now); + assertEquals(1, options.get(0).getApptList().size()); + assertEquals(next.getEndTime(), options.get(0).getApptList().get(0).getAutoRefundDeadline()); + var validation = service.validateReservation(10L, 1L, null, "1", target, now); + assertEquals(2L, validation.apptId()); + assertEquals(next.getEndTime(), validation.eligibilityDeadline()); + assertEquals(target.atTime(23, 59, 59), validation.usageEndDateTime()); + } + /* ************************************* 周期预约 ************************************* */ /** 周期预约固化用餐日当天 23:59:59,不读取动态窗口配置。 */ @@ -399,6 +430,8 @@ class HardApptReservationServiceImplTest { when(apptService.list(org.mockito.ArgumentMatchers.>any())) .thenReturn(window == null ? List.of() : List.of(rule)); when(apptService.getById(rule.getId())).thenReturn(rule); + when(apptService.listEnabledWindows(any())).thenReturn(window == null || !"1".equals(rule.getStatus()) + ? List.of() : List.of(new WindowSource(window, items))); if (window != null) { when(windowMapper.selectById(window.getId())).thenReturn(window); when(windowMapper.selectList(org.mockito.ArgumentMatchers.>any())) diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardApptServiceImplTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardApptServiceImplTest.java index 12957c5cd..701462088 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardApptServiceImplTest.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/HardApptServiceImplTest.java @@ -11,6 +11,9 @@ import com.bsd.pjcloud.hard.mapper.HardApptItemMapper; import com.bsd.pjcloud.hard.mapper.HardApptMapper; import com.bsd.pjcloud.hard.mapper.HardApptWindowMapper; import com.bsd.pjcloud.hard.service.HardApptMerchantModeService; +import com.bsd.pjcloud.wallet.api.feign.RemoteMchService; +import com.bsd.pjcloud.common.core.util.R; +import com.bsd.pjcloud.common.data.datascope.DataScope; import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; import com.bsd.pjcloud.wallet.api.vo.MchManageVo; import org.junit.jupiter.api.Test; @@ -22,6 +25,7 @@ import java.time.LocalDateTime; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -114,9 +118,9 @@ class HardApptServiceImplTest { verify(fixture.itemMapper).insert(anyList()); } - /** 窗口级编辑与商户其他窗口时间重叠拒绝。 */ + /** 窗口级编辑允许与同商户其他规则的窗口时间重叠。 */ @Test - void updateWindowRejectsOverlapWithOtherWindow() { + void updateWindowAllowsOverlapWithOtherWindow() { Fixture fixture = new Fixture(); HardApptWindowEntity existing = existingWindow(); when(fixture.windowMapper.selectById(existing.getId())).thenReturn(existing); @@ -133,15 +137,13 @@ class HardApptServiceImplTest { HardApptWindowDto dto = window(null); dto.setId(existing.getId()); - RuntimeException exception = assertThrows(RuntimeException.class, - () -> fixture.service.updateWindow(dto)); - assertTrue(exception.getMessage().contains("开放时间与商户已存在窗口重叠")); - verify(fixture.windowMapper, never()).updateById(any(HardApptWindowEntity.class)); + assertDoesNotThrow(() -> fixture.service.updateWindow(dto)); + verify(fixture.windowMapper).updateById(any(HardApptWindowEntity.class)); } - /** 窗口级编辑撞商户其他窗口的可预约日期拒绝。 */ + /** 窗口级编辑允许同商户其他窗口配置相同用餐日期餐段。 */ @Test - void updateWindowRejectsDuplicatedTargetDate() { + void updateWindowAllowsDuplicatedTargetDate() { Fixture fixture = new Fixture(); HardApptWindowEntity existing = existingWindow(); when(fixture.windowMapper.selectById(existing.getId())).thenReturn(existing); @@ -163,9 +165,96 @@ class HardApptServiceImplTest { HardApptWindowDto duplicatedDto = window(null); duplicatedDto.setId(existing.getId()); - RuntimeException exception = assertThrows(RuntimeException.class, - () -> fixture.service.updateWindow(duplicatedDto)); - assertTrue(exception.getMessage().contains("已存在于商户其他窗口")); + assertDoesNotThrow(() -> fixture.service.updateWindow(duplicatedDto)); + verify(fixture.windowMapper).updateById(any(HardApptWindowEntity.class)); + } + + /** 同一规则内重叠窗口、相同目标均可保存,但每个窗口内部仍去重。 */ + @Test + void createAllowsOverlappingWindowsAndDeduplicatesItems() { + Fixture fixture = new Fixture(); + HardApptWindowDto first = window(null); + first.setItemList(List.of(first.getItemList().get(0), first.getItemList().get(0))); + HardApptDto dto = ruleDto(first); + dto.setWindowList(List.of(first, window(null))); + assertDoesNotThrow(() -> fixture.service.create(dto)); + assertEquals(1, first.getItemList().size()); + } + + /** 草稿合并同商户其他规则,停用草稿不贡献窗口,预览不写持久化层。 */ + @Test + void previewMergesSavedTargetsWithoutWriting() { + Fixture fixture = new Fixture(); + when(fixture.apptMapper.selectList(any())).thenReturn(List.of(existingRule())); + HardApptWindowEntity saved = existingWindow(); + saved.setEndTime(LocalDateTime.of(2026, 10, 1, 15, 0)); + saved.setAutoRefundTime(null); + when(fixture.windowMapper.selectList(any())).thenReturn(List.of(saved)); + HardApptItemEntity item = new HardApptItemEntity(); + item.setWindowId(saved.getId()); + item.setTargetDate(LocalDate.of(2026, 10, 2)); + item.setHourType("1"); + when(fixture.itemMapper.selectList(any())).thenReturn(List.of(item)); + HardApptDto dto = ruleDto(window(null)); + var result = fixture.service.preview(dto, DataScope.of(), LocalDateTime.of(2026, 10, 1, 9, 0)); + assertEquals(1, result.size()); + assertEquals(1, result.get(0).getApptList().size()); + assertEquals(saved.getEndTime(), result.get(0).getApptList().get(0).getAutoRefundDeadline()); + dto.setStatus("0"); + assertEquals(saved.getEndTime(), fixture.service.preview(dto, DataScope.of(), LocalDateTime.of(2026, 10, 1, 9, 0)).get(0) + .getApptList().get(0).getAutoRefundDeadline()); + verify(fixture.apptMapper, never()).insert(any(HardApptEntity.class)); + verify(fixture.windowMapper, never()).insert(any(HardApptWindowEntity.class)); + } + + /** 编辑预览排除原规则,且不能通过其他商户或超出数据范围的规则 ID 绕过校验。 */ + @Test + void previewChecksMerchantScopeAndExcludesOriginalRule() { + Fixture fixture = new Fixture(); + HardApptDto dto = ruleDto(window(null)); + dto.setId(1L); + when(fixture.apptMapper.selectListByScope(any(), any())).thenReturn(List.of(existingRule())); + when(fixture.apptMapper.selectList(any())).thenReturn(List.of(existingRule())); + HardApptWindowEntity saved = existingWindow(); + saved.setAutoRefundTime(null); + saved.setEndTime(LocalDateTime.of(2026, 10, 1, 18, 0)); + when(fixture.windowMapper.selectList(any())).thenReturn(List.of(saved)); + var result = fixture.service.preview(dto, DataScope.of(), LocalDateTime.of(2026, 10, 1, 9, 0)); + assertEquals(LocalDateTime.of(2026, 10, 1, 12, 0), result.get(0).getApptList().get(0).getAutoRefundDeadline()); + when(fixture.apptMapper.selectListByScope(any(), any())).thenReturn(List.of()); + assertThrows(RuntimeException.class, () -> fixture.service.preview(dto, DataScope.of(), LocalDateTime.of(2026, 10, 1, 9, 0))); + dto.setId(null); + when(fixture.remoteMchService.scopeList(any())).thenReturn(R.ok(List.of())); + assertThrows(RuntimeException.class, () -> fixture.service.preview(dto, DataScope.of(), LocalDateTime.of(2026, 10, 1, 9, 0))); + } + + /** 当前列表只包含规则 1 时,目标截止仍应包含规则 2 的后续窗口,不能受分页截断。 */ + @Test + void detailDeadlineIncludesRulesOutsideCurrentPage() { + Fixture fixture = new Fixture(); + when(fixture.apptMapper.selectById(1L)).thenReturn(existingRule()); + HardApptEntity anotherRule = existingRule(); + anotherRule.setId(2L); + when(fixture.apptMapper.selectList(any())).thenReturn(List.of(existingRule(), anotherRule)); + HardApptWindowEntity first = existingWindow(); + HardApptWindowEntity later = existingWindow(); + later.setId(6L); + later.setApptId(2L); + later.setEndTime(LocalDateTime.of(2026, 10, 1, 18, 0)); + later.setAutoRefundTime(null); + when(fixture.windowMapper.selectList(any())).thenReturn(List.of(first), List.of(first, later)); + HardApptItemEntity firstItem = new HardApptItemEntity(); + firstItem.setWindowId(5L); + firstItem.setTargetDate(LocalDate.of(2026, 10, 2)); + firstItem.setHourType("1"); + HardApptItemEntity laterItem = new HardApptItemEntity(); + laterItem.setWindowId(6L); + laterItem.setTargetDate(firstItem.getTargetDate()); + laterItem.setHourType("1"); + when(fixture.itemMapper.selectList(any())).thenReturn(List.of(firstItem), List.of(firstItem, laterItem)); + var result = fixture.service.join(1L); + assertEquals(1, result.getWindowList().size()); + assertEquals(later.getEndTime(), result.getWindowList().get(0).getItemList().get(0).getAutoRefundDeadline()); } /* ************************************* 窗口级删除 ************************************* */ @@ -247,13 +336,18 @@ class HardApptServiceImplTest { final HardApptMapper apptMapper = mock(HardApptMapper.class); final HardApptWindowMapper windowMapper = mock(HardApptWindowMapper.class); final HardApptItemMapper itemMapper = mock(HardApptItemMapper.class); + /** 商户数据权限远程接口替身,默认只允许商户 10。 */ + final RemoteMchService remoteMchService = mock(RemoteMchService.class); final HardApptServiceImpl service; Fixture() { HardApptMerchantModeService merchantModeService = mock(HardApptMerchantModeService.class); when(merchantModeService.resolve(any())).thenReturn(new HardApptMerchantModeService.MerchantModeContext( new MchManageVo(), HardApptReserveModeEnum.TIME_WINDOW)); - service = new HardApptServiceImpl(windowMapper, itemMapper, merchantModeService); + MchManageVo merchant = new MchManageVo(); + merchant.setId(MCH_ID); + when(remoteMchService.scopeList(any())).thenReturn(R.ok(List.of(merchant))); + service = new HardApptServiceImpl(windowMapper, itemMapper, merchantModeService, remoteMchService); ReflectionTestUtils.setField(service, "baseMapper", apptMapper); doAnswer(invocation -> { ((HardApptEntity) invocation.getArgument(0)).setId(100L); diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplAutoRefundSnapshotTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplAutoRefundSnapshotTest.java new file mode 100644 index 000000000..2e152143f --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/impl/order/HardOrderServiceImplAutoRefundSnapshotTest.java @@ -0,0 +1,98 @@ +package com.bsd.pjcloud.hard.service.impl.order; + +import com.bsd.pjcloud.hard.api.entity.HardApptItemEntity; +import com.bsd.pjcloud.hard.api.entity.HardApptWindowEntity; +import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity; +import com.bsd.pjcloud.hard.service.HardApptReservationService.ReservationValidation; +import com.bsd.pjcloud.hard.service.support.HardApptWindowResolver; +import com.bsd.pjcloud.hard.service.support.HardApptWindowResolver.Target; +import com.bsd.pjcloud.hard.service.support.HardApptWindowResolver.WindowSource; +import com.bsd.pjcloud.wallet.api.vo.mch.MchAutoRefundConfigVo; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Answers.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mock; + +/** 统一窗口计算结果落到新订单快照,过期退款和支付限时模式继续保持独立口径。 */ +class HardOrderServiceImplAutoRefundSnapshotTest { + + /** 只运行快照固化逻辑,不调用钱包、库存或订单持久化。 */ + private final HardOrderServiceImpl service = mock(HardOrderServiceImpl.class, CALLS_REAL_METHODS); + + /** 周一下单预约周五固化周四截止,后续修改窗口不会改写该订单,过期退款仍使用用餐日末加延迟。 */ + @Test + void finalTargetDeadlineIsFrozenIndependentlyFromExpiredRefund() { + WindowSource early = source(1L, 19, 20); + WindowSource later = source(2L, 21, 22); + HardOrderEntity paid = snapshot(reservation(List.of(early, later)), config("APPT_CYCLE_CLOSE", "1")); + assertEquals(LocalDateTime.of(2026, 10, 22, 12, 0), paid.getAutoRefundDeadline()); + assertEquals(LocalDateTime.of(2026, 10, 23, 23, 59, 59), paid.getExpiredRefundUsageEndTime()); + assertEquals(LocalDateTime.of(2026, 10, 24, 1, 59, 59), paid.getExpiredRefundDeadline()); + + later.window().setEndTime(LocalDateTime.of(2026, 10, 22, 18, 0)); + HardOrderEntity next = snapshot(reservation(List.of(early, later)), config("APPT_CYCLE_CLOSE", "1")); + assertEquals(LocalDateTime.of(2026, 10, 22, 18, 0), next.getAutoRefundDeadline()); + assertEquals(LocalDateTime.of(2026, 10, 22, 12, 0), paid.getAutoRefundDeadline()); + assertEquals(paid.getExpiredRefundDeadline(), next.getExpiredRefundDeadline()); + } + + /** 支付后限时的免审截止由支付时间计算,不能被目标预约截止覆盖;关闭免审也不关闭过期退款。 */ + @Test + void payLimitAndDisabledApprovalKeepExpiredRefundIndependent() { + ReservationValidation reservation = reservation(List.of(source(1L, 19, 20))); + HardOrderEntity payLimit = snapshot(reservation, config("PAY_TIME_LIMIT", "1")); + assertEquals("PAY_TIME_LIMIT", payLimit.getAutoRefundRuleType()); + assertNull(payLimit.getAutoRefundDeadline()); + assertEquals(LocalDateTime.of(2026, 10, 24, 1, 59, 59), payLimit.getExpiredRefundDeadline()); + HardOrderEntity disabled = snapshot(reservation, config("APPT_CYCLE_CLOSE", "0")); + assertNull(disabled.getAutoRefundConfigId()); + assertNull(disabled.getAutoRefundDeadline()); + assertEquals(payLimit.getExpiredRefundDeadline(), disabled.getExpiredRefundDeadline()); + } + + /** 按周一固定时刻解析周五早餐,把统一计算结果作为订单服务的可信预约上下文。 */ + private ReservationValidation reservation(List sources) { + LocalDate date = LocalDate.of(2026, 10, 23); + var resolved = HardApptWindowResolver.resolve(sources, LocalDateTime.of(2026, 10, 19, 8, 0)) + .get(new Target(date, "1")); + return new ReservationValidation(resolved.window().getId(), "1", date, null, + resolved.autoRefundDeadline(), date.atTime(23, 59, 59)); + } + + /** 构造覆盖周五早餐的窗口,允许未来窗口参与免审截止计算。 */ + private WindowSource source(Long id, int openDay, int closeDay) { + HardApptWindowEntity window = new HardApptWindowEntity(); + window.setId(id); + window.setStartTime(LocalDateTime.of(2026, 10, openDay, 0, 0)); + window.setEndTime(LocalDateTime.of(2026, 10, closeDay, 12, 0)); + HardApptItemEntity item = new HardApptItemEntity(); + item.setTargetDate(LocalDate.of(2026, 10, 23)); + item.setHourType("1"); + return new WindowSource(window, List.of(item)); + } + + /** 开启独立的过期退款,延迟固定为用餐日结束后 2 小时。 */ + private MchAutoRefundConfigVo config(String ruleType, String enabled) { + MchAutoRefundConfigVo config = new MchAutoRefundConfigVo(); + config.setId(99L); + config.setEnabled(enabled); + config.setRuleType(ruleType); + config.setExpiredReserveRefundEnabled("1"); + config.setExpiredReserveRefundDelayHours(2); + return config; + } + + /** 对新建订单执行现有快照方法,验证新解析器无需改变订单或任务持久化协议。 */ + private HardOrderEntity snapshot(ReservationValidation reservation, MchAutoRefundConfigVo config) { + HardOrderEntity order = new HardOrderEntity(); + ReflectionTestUtils.invokeMethod(service, "applyDailyMenuAutoRefundSnapshot", order, reservation, config); + return order; + } +} diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/support/HardApptWindowResolverTest.java b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/support/HardApptWindowResolverTest.java new file mode 100644 index 000000000..53ede8e0b --- /dev/null +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/test/java/com/bsd/pjcloud/hard/service/support/HardApptWindowResolverTest.java @@ -0,0 +1,148 @@ +package com.bsd.pjcloud.hard.service.support; + +import com.bsd.pjcloud.hard.api.entity.HardApptItemEntity; +import com.bsd.pjcloud.hard.api.entity.HardApptWindowEntity; +import com.bsd.pjcloud.hard.service.support.HardApptWindowResolver.Target; +import com.bsd.pjcloud.hard.service.support.HardApptWindowResolver.WindowSource; +import org.junit.jupiter.api.Test; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** 多窗口叠加的业务回归:逐日截止、真实空档、未来窗口退款和提前退款配置。 */ +class HardApptWindowResolverTest { + + /** 周一下单时,尚未开放的后续窗口已经决定周四、周五的最终退款截止。 */ + @Test + void mondayOrdersUseFinalDeadlineOfEachTarget() { + var result = HardApptWindowResolver.resolve(example(), time(19, 0)); + assertEquals(3, result.size()); + for (int targetDay = 21; targetDay <= 23; targetDay++) { + var target = result.get(target(targetDay, "1")); + assertTrue(target.available()); + assertEquals(time(19, 0), target.start()); + assertEquals(time(targetDay - 1, 12), target.end()); + assertEquals(time(targetDay - 1, 12), target.autoRefundDeadline()); + assertEquals(1L, target.window().getId()); + } + } + + /** 每次午间截止使用闭区间,结束一纳秒后只关闭相应日期,不影响后续用餐日。 */ + @Test + void dailyNoonBoundariesAreInclusive() { + for (int day = 20; day <= 22; day++) { + var atEnd = HardApptWindowResolver.resolve(example(), time(day, 12)); + assertTrue(atEnd.get(target(day + 1, "1")).available()); + var afterEnd = HardApptWindowResolver.resolve(example(), time(day, 12).plusNanos(1)); + assertFalse(afterEnd.get(target(day + 1, "1")).available()); + for (int later = day + 2; later <= 23; later++) { + assertTrue(afterEnd.get(target(later, "1")).available()); + } + } + } + + /** 同时开放选择结束更晚的窗口;结束相同则按窗口 ID 升序,结果不依赖查询返回顺序。 */ + @Test + void overlappingSourcesHaveDeterministicRepresentative() { + WindowSource first = source(3L, 19, 21, 23); + WindowSource second = source(2L, 20, 21, 23); + var resolved = HardApptWindowResolver.resolve(List.of(first, second), time(20, 8)).get(target(23, "1")); + assertEquals(2L, resolved.window().getId()); + assertEquals(time(19, 0), resolved.start()); + assertEquals(time(21, 12), resolved.end()); + } + + /** 两次开放之间的空档必须保持不可约,选择最近将开放区间展示;退款仍按最终窗口截止。 */ + @Test + void gapsAreNotFilledByEarliestAndLatestBounds() { + List sources = List.of(source(1L, 19, 19, 23), source(2L, 21, 21, 23)); + var gap = HardApptWindowResolver.resolve(sources, time(20, 8)).get(target(23, "1")); + assertFalse(gap.available()); + assertEquals(2L, gap.window().getId()); + assertEquals(time(21, 0), gap.start()); + assertEquals(time(21, 12), gap.end()); + assertEquals(time(21, 12), gap.autoRefundDeadline()); + var closed = HardApptWindowResolver.resolve(sources, time(22, 0)).get(target(23, "1")); + assertFalse(closed.available()); + assertEquals(2L, closed.window().getId()); + } + + /** 相接窗口准点有真实覆盖,可合并;完全重复的目标明细仍只返回一个餐段。 */ + @Test + void touchingIntervalsAndDuplicateItemsAreMerged() { + WindowSource first = source(1L, 19, 20, 23, 23); + WindowSource second = source(2L, 20, 21, 23); + second.window().setStartTime(time(20, 12)); + var result = HardApptWindowResolver.resolve(List.of(first, second), time(20, 12)); + assertEquals(1, result.size()); + var resolved = result.get(target(23, "1")); + assertTrue(resolved.available()); + assertEquals(time(19, 0), resolved.start()); + assertEquals(time(21, 12), resolved.end()); + } + + /** 早餐和午餐独立汇总,各窗口合法的自定义提前退款值取最大,空值才回退预约结束。 */ + @Test + void customRefundsAndDifferentMealsRemainIndependent() { + WindowSource breakfast = source(1L, 19, 21, 23); + breakfast.window().setAutoRefundTime(time(21, 10)); + WindowSource laterBreakfast = source(2L, 20, 22, 23); + laterBreakfast.window().setAutoRefundTime(time(22, 9)); + WindowSource lunch = source(3L, 19, 20, 23); + lunch.items().get(0).setHourType("2"); + var result = HardApptWindowResolver.resolve(List.of(breakfast, laterBreakfast, lunch), time(19, 8)); + assertEquals(time(22, 9), result.get(target(23, "1")).autoRefundDeadline()); + assertEquals(time(20, 12), result.get(target(23, "2")).autoRefundDeadline()); + } + + /** 不完整时间和越界退款脏配置不扩大可约范围或退款资格。 */ + @Test + void invalidSourcesDoNotExtendValidTargets() { + WindowSource valid = source(1L, 19, 20, 23); + WindowSource broken = source(2L, 21, 22, 23); + broken.window().setAutoRefundTime(time(23, 12)); + WindowSource reversed = source(3L, 22, 21, 23); + var result = HardApptWindowResolver.resolve(List.of(valid, broken, reversed), time(21, 8)); + assertFalse(result.get(target(23, "1")).available()); + assertEquals(time(20, 12), result.get(target(23, "1")).autoRefundDeadline()); + } + + /** 用户确认的三组窗口,窗口明细允许覆盖同一用餐日。 */ + private List example() { + return List.of(source(1L, 19, 20, 21, 22, 23), source(2L, 20, 21, 22, 23), source(3L, 21, 22, 23)); + } + + /** 创建北京时间 2026 年 10 月的固定测试时刻,避免依赖执行机器当前日期。 */ + private LocalDateTime time(int day, int hour) { + return LocalDateTime.of(2026, 10, day, hour, 0); + } + + /** 构造实际用餐日期及餐段键。 */ + private Target target(int day, String hourType) { + return new Target(LocalDate.of(2026, 10, day), hourType); + } + + /** 构造商户 10 的窗口及早餐明细,窗口默认没有单独配置退款截止。 */ + private WindowSource source(Long id, int openDay, int closeDay, int... targetDays) { + HardApptWindowEntity window = new HardApptWindowEntity(); + window.setId(id); + window.setMchId(10L); + window.setApptId(id); + window.setStartTime(time(openDay, 0)); + window.setEndTime(time(closeDay, 12)); + List items = Arrays.stream(targetDays).mapToObj(day -> { + HardApptItemEntity item = new HardApptItemEntity(); + item.setWindowId(id); + item.setTargetDate(LocalDate.of(2026, 10, day)); + item.setHourType("1"); + return item; + }).toList(); + return new WindowSource(window, items); + } +}