feat:订餐时间过滤

This commit is contained in:
dzc committed 2026-10-23 09:43:52 +08:00
1 parent 617bd2ef24
commit d5f46aed99
2 files changed
+127 -6

No files matched your search

@@ -63,19 +63,20 @@ public class HardApptReservationServiceImpl implements HardApptReservationServic
private final HardApptCycleRuleService cycleRuleService;
private final HardApptMerchantModeService merchantModeService;
/** 返回当前商户的日期餐段,动态模式使用统一解析器,按周模式保留原批次计算。 */
/** 返回当前商户的 App 日期餐段,动态模式隐藏已截止目标,按周模式保留原批次计算。 */
@Override
public List<HardApptDateOptionsVo> listOptions(Long mchId) {
return listOptions(mchId, nowInShanghai());
}
/** 按固定北京时间生成 App 列表,保留当前开放及未来将开放的目标,便于核对截止边界。 */
List<HardApptDateOptionsVo> listOptions(Long mchId, LocalDateTime now) {
HardApptReserveModeEnum merchantMode = resolveMerchantMode(mchId);
LocalDate today = now.toLocalDate();
Map<LocalDate, LinkedHashMap<String, HardApptOptionVo>> 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<LocalDate, LinkedHashMap<String, HardApptOptionVo>> optionsByDate) {
boolean includeClosed, Map<LocalDate, LinkedHashMap<String, HardApptOptionVo>> 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));
});
@@ -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<HardApptDateOptionsVo> 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<HardApptDateOptionsVo> 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<HardApptDateOptionsVo> 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<HardApptDateOptionsVo> 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<WindowSource> 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<HardApptCycleRuleVo> rules, Long mchId) {
HardApptCycleRuleService cycleRuleService = mock(HardApptCycleRuleService.class);