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 45d6c6dc..079362c8 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 @@ -63,19 +63,20 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic private final HardApptCycleRuleService cycleRuleService; private final HardApptMerchantModeService merchantModeService; - /** 返回当前商户的日期餐段,动态模式使用统一解析器,按周模式保留原批次计算。 */ + /** 返回当前商户的 App 日期餐段,动态模式隐藏已截止目标,按周模式保留原批次计算。 */ @Override public List listOptions(Long mchId) { return listOptions(mchId, nowInShanghai()); } + /** 按固定北京时间生成 App 列表,保留当前开放及未来将开放的目标,便于核对截止边界。 */ List listOptions(Long mchId, LocalDateTime now) { HardApptReserveModeEnum merchantMode = resolveMerchantMode(mchId); LocalDate today = now.toLocalDate(); Map> optionsByDate = new TreeMap<>(); if (merchantMode == HardApptReserveModeEnum.TIME_WINDOW) { - addDynamicOptions(mchId, now, today, null, optionsByDate); + addDynamicOptions(mchId, now, today, null, false, optionsByDate); } else { for (HardApptCycleRuleVo rule : cycleRuleService.listEnabledConfigurations(mchId)) { HardApptCycleRuleCalculator.CycleBatch batch; @@ -134,7 +135,7 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic } if (merchantMode == HardApptReserveModeEnum.TIME_WINDOW) { - addDynamicOptions(mchId, now, startDate, endDate, optionsByDate); + addDynamicOptions(mchId, now, startDate, endDate, true, optionsByDate); } else { addCyclePreviewOptions(mchId, now, startDate, endDate, optionsByDate); } @@ -147,13 +148,22 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic } /** - * 按日期餐段汇总商户全部启用窗口,每个目标只返回一个选项;推演范围只筛选用餐日期, - * 不能提前过滤未来开放窗口,否则最终预约范围和退款截止会随查询时刻漂移。 + * 按日期餐段汇总商户全部启用窗口,每个目标只返回一个选项。 + * 必须先合并所有窗口,再决定是否展示已截止目标,避免改变连续开放区间及最终退款截止。 + * + * @param mchId 当前租户内的商户 ID + * @param now 同一次请求固定的北京时间 + * @param startDate 最早展示的实际用餐日期,包含当天 + * @param endDate 最晚展示的实际用餐日期,包含当天;null 表示不限制上界 + * @param includeClosed 管理端推演为 true,保留已截止目标;App 列表为 false + * @param optionsByDate 按用餐日期汇总的输出容器,只向其中添加符合条件的餐段 */ private void addDynamicOptions(Long mchId, LocalDateTime now, LocalDate startDate, LocalDate endDate, - Map> optionsByDate) { + boolean includeClosed, Map> optionsByDate) { HardApptWindowResolver.resolve(hardApptService.listEnabledWindows(mchId), now).forEach((target, resolved) -> { if (target.date().isBefore(startDate) || endDate != null && target.date().isAfter(endDate)) return; + // 开放区间采用闭区间:截止准点仍展示;未来窗口和两次开放间的空档仍保留为尚未开放。 + if (!includeClosed && resolved.end().isBefore(now)) return; optionsByDate.computeIfAbsent(target.date(), key -> new LinkedHashMap<>()) .put("appt:" + target.hourType(), HardApptWindowResolver.toOption(target, resolved, now)); }); 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 f0016043..c7a38293 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 @@ -16,6 +16,7 @@ import com.bsd.pjcloud.hard.service.HardApptService; import com.bsd.pjcloud.common.core.constant.enums.YesNoEnum; import com.bsd.pjcloud.common.core.exception.ErrorCodes; import com.bsd.pjcloud.common.core.exception.I18nBizException; +import com.bsd.pjcloud.common.core.util.MsgUtils; import com.baomidou.mybatisplus.core.conditions.Wrapper; import com.bsd.pjcloud.hard.service.support.HardApptCycleRuleCalculator; import com.bsd.pjcloud.hard.service.support.HardApptWindowResolver.WindowSource; @@ -35,6 +36,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.when; /** @@ -164,6 +166,106 @@ class HardApptReservationServiceImplTest { && option.getStartTime() != null && option.getEndTime() != null)); } + /** 已截止窗口即使用餐日在今天或未来也不输出;同日其他有效餐段仍保留,空日期整体移除。 */ + @Test + void v3ListOmitsExpiredTargetsAndKeepsOtherMeals() { + LocalDate today = LocalDate.of(2026, 10, 23); + HardApptWindowEntity closed = wideWindow(); + closed.setStartTime(today.minusDays(4).atStartOfDay()); + closed.setEndTime(today.minusDays(3).atTime(12, 0)); + HardApptWindowEntity active = wideWindow(); + active.setId(2L); + active.setStartTime(today.minusDays(3).atStartOfDay()); + active.setEndTime(today.plusDays(1).atTime(12, 0)); + HardApptReservationServiceImpl service = dynamicOptionsService(List.of( + new WindowSource(closed, List.of(windowItem(1L, today, "1"), + windowItem(1L, today.plusDays(1), "1"), windowItem(1L, today.plusDays(2), "1"))), + new WindowSource(active, List.of(windowItem(2L, today.plusDays(2), "2"))))); + + try (var messages = mockStatic(MsgUtils.class)) { + messages.when(() -> MsgUtils.getMessage(any(String.class))).thenAnswer(invocation -> invocation.getArgument(0)); + List result = service.listOptions(10L, today.atTime(8, 0)); + + assertEquals(List.of(today.plusDays(2)), result.stream().map(HardApptDateOptionsVo::getApptDate).toList()); + assertEquals(List.of("2"), result.get(0).getApptList().stream().map(HardApptOptionVo::getHourType).toList()); + assertTrue(result.get(0).getApptList().get(0).getAvailable()); + } + } + + /** App 列表与下单保持闭区间:截止准点仍展示,结束一纳秒后隐藏且不留下空日期。 */ + @Test + void v3ListIncludesWindowEndAndOmitsOneNanosecondAfter() { + LocalDateTime end = LocalDateTime.of(2026, 10, 23, 12, 0); + HardApptWindowEntity window = wideWindow(); + window.setStartTime(end.minusDays(1)); + window.setEndTime(end); + HardApptReservationServiceImpl service = dynamicOptionsService(List.of( + new WindowSource(window, List.of(windowItem(1L, end.toLocalDate(), "1"))))); + + try (var messages = mockStatic(MsgUtils.class)) { + messages.when(() -> MsgUtils.getMessage(any(String.class))).thenAnswer(invocation -> invocation.getArgument(0)); + List atEnd = service.listOptions(10L, end); + + assertEquals(1, atEnd.size()); + assertTrue(atEnd.get(0).getApptList().get(0).getAvailable()); + assertTrue(service.listOptions(10L, end.plusNanos(1)).isEmpty()); + } + } + + /** 未来尚未开放的目标继续展示;同一目标的旧窗口已结束也不能误删空档后的下一窗口。 */ + @Test + void v3ListKeepsFutureWindowsIncludingGapsAfterClosedWindows() { + LocalDateTime now = LocalDateTime.of(2026, 10, 23, 8, 0); + LocalDate target = now.toLocalDate().plusDays(3); + HardApptWindowEntity closed = wideWindow(); + closed.setStartTime(now.minusDays(4)); + closed.setEndTime(now.minusDays(3)); + HardApptWindowEntity next = wideWindow(); + next.setId(2L); + next.setStartTime(now.plusDays(1)); + next.setEndTime(now.plusDays(2)); + HardApptReservationServiceImpl service = dynamicOptionsService(List.of( + new WindowSource(closed, List.of(windowItem(1L, target, "1"))), + new WindowSource(next, List.of(windowItem(2L, target, "1"), windowItem(2L, target.plusDays(1), "1"))))); + + try (var messages = mockStatic(MsgUtils.class)) { + messages.when(() -> MsgUtils.getMessage(any(String.class))).thenAnswer(invocation -> invocation.getArgument(0)); + List result = service.listOptions(10L, now); + + assertEquals(List.of(target, target.plusDays(1)), result.stream().map(HardApptDateOptionsVo::getApptDate).toList()); + assertTrue(result.stream().flatMap(day -> day.getApptList().stream()).allMatch(option -> + Long.valueOf(2L).equals(option.getId()) && Boolean.FALSE.equals(option.getAvailable()) + && ErrorCodes.HARD_APPT_NOT_OPEN.equals(option.getUnavailableReason()) + && next.getStartTime().equals(option.getReserveStartDateTime()) + && next.getEndTime().equals(option.getAutoRefundDeadline()))); + } + } + + /** 管理端仍需推演已截止配置及自然日空档,不跟随 App 列表隐藏过期窗口。 */ + @Test + void dynamicPreviewKeepsClosedOptionsAndEmptyDates() { + LocalDateTime now = LocalDateTime.of(2026, 10, 23, 8, 0); + HardApptWindowEntity closed = wideWindow(); + closed.setStartTime(now.minusDays(4)); + closed.setEndTime(now.minusDays(3)); + LocalDate target = now.toLocalDate().plusDays(1); + HardApptReservationServiceImpl service = dynamicOptionsService(List.of( + new WindowSource(closed, List.of(windowItem(1L, target, "1"))))); + + try (var messages = mockStatic(MsgUtils.class)) { + messages.when(() -> MsgUtils.getMessage(any(String.class))).thenAnswer(invocation -> invocation.getArgument(0)); + List result = service.previewOptions(10L, 3, now); + + assertEquals(3, result.size()); + assertTrue(result.get(0).getApptList().isEmpty()); + assertTrue(result.get(2).getApptList().isEmpty()); + HardApptOptionVo option = result.get(1).getApptList().get(0); + assertFalse(option.getAvailable()); + assertEquals(ErrorCodes.HARD_APPT_CLOSED, option.getUnavailableReason()); + assertEquals(closed.getEndTime(), option.getReserveEndDateTime()); + } + } + /** 管理端推演限制在推演日期范围内,范围外日期不输出。 */ @Test void dynamicPreviewBoundsOptionsToDateRange() { @@ -445,6 +547,15 @@ class HardApptReservationServiceImplTest { mock(HardApptCycleRuleService.class), merchantModeService(HardApptReserveModeEnum.TIME_WINDOW)); } + /** 仅用于列表与推演的多窗口替身,直接提供商户全部启用窗口,避免引入下单校验的无关桩。 */ + private HardApptReservationServiceImpl dynamicOptionsService(List sources) { + HardApptService apptService = mock(HardApptService.class); + when(apptService.listEnabledWindows(10L)).thenReturn(sources); + return new HardApptReservationServiceImpl(apptService, mock(HardApptWindowMapper.class), + mock(HardApptItemMapper.class), mock(HardApptCycleRuleService.class), + merchantModeService(HardApptReserveModeEnum.TIME_WINDOW)); + } + /** 周期模式的预约服务替身。 */ private HardApptReservationServiceImpl cycleService(List rules, Long mchId) { HardApptCycleRuleService cycleRuleService = mock(HardApptCycleRuleService.class);