feat:动态预约窗口修改

This commit is contained in:
dzc committed 2026-10-20 16:28:22 +08:00
1 parent 68b8c58dd3
commit 7564fef196
14 files changed
+794 -209

No files matched your search

@@ -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<HardApptWindowDto> windowList;
/** 只筛选存在匹配窗口的规则:true 已过期、false 未过期、null 全部;不参与持久化。 */
@Schema(description = "开放时间是否已过期:true 已过期,false 未过期,不传查全部;返回的 windowList 保留完整窗口供编辑")
private Boolean windowExpired;
}
@@ -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;
}
@@ -47,4 +47,7 @@ public class HardApptOptionVo {
/** 该日期和餐段本批次的实际截止时间。 */
private LocalDateTime reserveEndDateTime;
/** 动态模式同日期餐段全部启用窗口的最终免审申请截止,北京时间;周期模式为空,仅供展示。 */
private LocalDateTime autoRefundDeadline;
}
@@ -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;
@@ -52,6 +52,14 @@ public class HardApptController {
/* ************************************* end app end ************************************* */
/** 按当前用户商户数据权限只读合并草稿,返回涉及日期餐段的实际开放范围与免审退款截止。 */
@Operation(summary = "推演预约规则草稿", description = "只读合并草稿与其他启用规则,不写配置或订单")
@PostMapping("/preview")
@PreAuthorize("@pms.hasPermission('hard_hardAppt_view')")
public R<List<HardApptDateOptionsVo>> preview(@RequestBody HardApptDto dto) {
return R.ok(hardApptService.preview(dto, DataScope.of()));
}
@Operation(summary = "分页查询", description = "分页查询")
@GetMapping("/page")
@PreAuthorize("@pms.hasPermission('hard_hardAppt_view')")
@@ -11,6 +11,7 @@ import java.util.List;
*/
public interface HardApptReservationService {
/** 按商户当前预约模式返回日期餐段;动态模式叠加全部启用窗口,每个目标最多一个选项。 */
List<HardApptDateOptionsVo> listOptions(Long mchId);
/**
@@ -22,17 +23,19 @@ public interface HardApptReservationService {
*/
List<HardApptDateOptionsVo> 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,
@@ -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<HardApptEntity> {
void delByIds(ArrayList<Long> list);
/**
* 窗口级修改:更新单个开放窗口(时间/免审退款截止/明细全量重建),商户级唯一性排除本窗口。
* 窗口级修改:更新时间/免审退款截止并重建明细,允许与同商户其他启用窗口叠加。
*/
void updateWindow(HardApptWindowDto window);
@@ -50,4 +52,21 @@ public interface HardApptService extends IService<HardApptEntity> {
* 查询详情
*/
HardApptVo join(Long id);
}
/**
* 读取同商户全部启用规则的窗口和明细,不受管理端分页影响;租户隔离沿用现有 Mapper。
*
* @param mchId 目标商户 ID,不能混入其他商户的数据
* @return 窗口及其明细,无配置时返回空集合
*/
List<WindowSource> listEnabledWindows(Long mchId);
/**
* 只读推演草稿涉及的日期餐段,合并同商户其他启用规则;编辑时先验证归属及数据范围再排除原规则。
*
* @param dto 未保存的规则头及窗口明细,停用草稿不贡献开放窗口
* @param dataScope 当前后台用户的数据权限范围
* @return 草稿目标的最终预约选项及退款截止,不写数据库或订单
*/
List<HardApptDateOptionsVo> preview(HardApptDto dto, DataScope dataScope);
}
@@ -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<HardApptDateOptionsVo> 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<LocalDate, LinkedHashMap<String, HardApptOptionVo>> 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<EnabledWindow> findEnabledWindows(Long mchId) {
List<Long> ruleIds = hardApptService.list(new LambdaQueryWrapper<HardApptEntity>()
.eq(HardApptEntity::getMchId, mchId)
.eq(HardApptEntity::getStatus, YesNoEnum.YES.getCode())).stream()
.map(HardApptEntity::getId).toList();
if (ruleIds.isEmpty()) {
return List.of();
}
List<HardApptWindowEntity> windows = hardApptWindowMapper.selectList(
new LambdaQueryWrapper<HardApptWindowEntity>()
.in(HardApptWindowEntity::getApptId, ruleIds)
.orderByAsc(HardApptWindowEntity::getStartTime));
if (windows.isEmpty()) {
return List.of();
}
Map<Long, List<HardApptItemEntity>> itemsByWindow = hardApptItemMapper.selectList(
new LambdaQueryWrapper<HardApptItemEntity>()
.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<HardApptItemEntity> items) {
}
}
@@ -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<HardApptMapper, HardApptEntity> 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<HardApptWindowDto> windows = validateAndNormalizeWindows(dto, null);
List<HardApptWindowDto> 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<HardApptMapper, HardApptEnt
List<HardApptWindowDto> 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<HardApptMapper, HardApptEnt
}
/**
* 窗口级修改:时间/免审退款截止/明细全量重建,商户级唯一性排除本窗口(含同规则兄弟窗口参与比对)。
* 窗口级修改:时间/免审退款截止/明细全量重建,允许同商户兄弟窗口和其他规则叠加。
*/
@Override
@Transactional(rollbackFor = Exception.class)
@@ -128,7 +144,6 @@ public class HardApptServiceImpl extends ServiceImpl<HardApptMapper, HardApptEnt
}
requireTimeWindowMerchant(rule.getMchId());
normalizeWindow(window, 1);
checkMerchantUniqueness(rule.getMchId(), List.of(window), null, window.getId());
HardApptWindowEntity update = new HardApptWindowEntity();
update.setId(window.getId());
update.setStartTime(window.getStartTime());
@@ -167,24 +182,27 @@ public class HardApptServiceImpl extends ServiceImpl<HardApptMapper, HardApptEnt
}
/**
* 分页查询
* 按数据范围分页查询规则;先限制匹配窗口,再分页,避免页面过滤导致总数失真。
*/
@Override
public Page<HardApptVo> scopePage(Page page, HardApptDto dto, DataScope of) {
LambdaQueryWrapper<HardApptEntity> wrapper = buildQueryWrapper(dto);
LocalDateTime now = LocalDateTime.now(HardApptWindowResolver.RESERVATION_ZONE);
LambdaQueryWrapper<HardApptEntity> wrapper = buildQueryWrapper(dto, now);
Page pageList = baseMapper.selectPageByScope(page, wrapper, of);
List<HardApptVo> hardApptVos = BeanUtil.copyToList(pageList.getRecords(), HardApptVo.class);
pageList.setRecords(hardApptVos);
disposeWindowList(hardApptVos);
disposeWindowList(hardApptVos, now);
return pageList;
}
/** 按与分页相同的筛选口径查询规则,窗口保留完整数据用于编辑和退款预览。 */
@Override
public List<HardApptVo> scopeList(HardApptDto dto, DataScope of) {
List<HardApptEntity> list = baseMapper.selectListByScope(buildQueryWrapper(dto), of);
LocalDateTime now = LocalDateTime.now(HardApptWindowResolver.RESERVATION_ZONE);
List<HardApptEntity> list = baseMapper.selectListByScope(buildQueryWrapper(dto, now), of);
List<HardApptVo> resultList = BeanUtil.copyToList(list, HardApptVo.class);
disposeWindowList(resultList);
disposeWindowList(resultList, now);
return resultList;
}
@@ -198,47 +216,38 @@ public class HardApptServiceImpl extends ServiceImpl<HardApptMapper, HardApptEnt
throw new RuntimeException("预约规则不存在");
}
List<HardApptVo> resultList = List.of(BeanUtil.toBean(hardAppt, HardApptVo.class));
disposeWindowList(resultList);
disposeWindowList(resultList, LocalDateTime.now(HardApptWindowResolver.RESERVATION_ZONE));
return resultList.get(0);
}
private LambdaQueryWrapper<HardApptEntity> buildQueryWrapper(HardApptDto dto) {
return new LambdaQueryWrapper<HardApptEntity>()
/** 规则列表沿用数据范围查询;过期筛选只关联同租户、同商户且未删除的窗口,时间取本次查询快照。 */
private LambdaQueryWrapper<HardApptEntity> buildQueryWrapper(HardApptDto dto, LocalDateTime now) {
LambdaQueryWrapper<HardApptEntity> wrapper = new LambdaQueryWrapper<HardApptEntity>()
.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<HardApptWindowDto> validateAndNormalizeWindows(HardApptDto dto, Long selfRuleId) {
private List<HardApptWindowDto> validateAndNormalizeWindows(HardApptDto dto) {
List<HardApptWindowDto> 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<HardApptMapper, HardApptEnt
* @param ordinal 窗口序号(从 1 起),仅用于错误提示
*/
private void normalizeWindow(HardApptWindowDto window, int ordinal) {
if (window.getStartTime() == null || window.getEndTime() == null) {
if (window == null || window.getStartTime() == null || window.getEndTime() == null) {
throw new RuntimeException("窗口 " + ordinal + " 缺少开始/结束预约时间");
}
if (!window.getEndTime().isAfter(window.getStartTime())) {
@@ -280,79 +289,90 @@ public class HardApptServiceImpl extends ServiceImpl<HardApptMapper, HardApptEnt
}
/**
* 商户级唯一性:与该商户启用规则的存量窗口比较,开放时间不得重叠、可预约日期不得重复。
* 批量加载商户所有启用规则的窗口及目标,不按当前时间过滤,后续窗口也必须贡献最终退款截止。
*
* @param selfRuleId 规则级编辑排除的规则 ID;null 表示不排除任何规则(窗口级编辑,兄弟窗口参与比对)
* @param selfWindowId 窗口级编辑排除的窗口 ID;null 表示不排除任何窗口
* @param mchId 目标商户主键
* @return 当前租户内该商户的启用窗口,缺少配置时为空
*/
private void checkMerchantUniqueness(Long mchId, List<HardApptWindowDto> windowList, Long selfRuleId, Long selfWindowId) {
List<HardApptWindowEntity> existingWindows = findMerchantWindows(mchId, selfRuleId, selfWindowId);
if (existingWindows.isEmpty()) {
return;
}
Map<Long, Set<LocalDate>> 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<LocalDate> windowDates = window.getItemList().stream()
.map(HardApptItemDto::getTargetDate).collect(Collectors.toSet());
for (Map.Entry<Long, Set<LocalDate>> 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<LocalDate> 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<WindowSource> listEnabledWindows(Long mchId) {
if (mchId == null) return List.of();
List<Long> ruleIds = baseMapper.selectList(new LambdaQueryWrapper<HardApptEntity>()
.eq(HardApptEntity::getMchId, mchId)
.eq(HardApptEntity::getStatus, YesNoEnum.YES.getCode())).stream().map(HardApptEntity::getId).toList();
if (ruleIds.isEmpty()) return List.of();
List<HardApptWindowEntity> windows = hardApptWindowMapper.selectList(new LambdaQueryWrapper<HardApptWindowEntity>()
.eq(HardApptWindowEntity::getMchId, mchId)
.in(HardApptWindowEntity::getApptId, ruleIds).orderByAsc(HardApptWindowEntity::getStartTime));
if (windows.isEmpty()) return List.of();
Map<Long, List<HardApptItemEntity>> items = hardApptItemMapper.selectList(new LambdaQueryWrapper<HardApptItemEntity>()
.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<HardApptWindowEntity> findMerchantWindows(Long mchId, Long selfRuleId, Long selfWindowId) {
LambdaQueryWrapper<HardApptEntity> ruleWrapper = new LambdaQueryWrapper<HardApptEntity>()
.eq(HardApptEntity::getMchId, mchId)
.eq(HardApptEntity::getStatus, YesNoEnum.YES.getCode())
.ne(selfRuleId != null, HardApptEntity::getId, selfRuleId);
List<Long> ruleIds = baseMapper.selectList(ruleWrapper).stream()
.map(HardApptEntity::getId).toList();
if (ruleIds.isEmpty()) {
return List.of();
@Override
public List<HardApptDateOptionsVo> preview(HardApptDto dto, DataScope dataScope) {
return preview(dto, dataScope, LocalDateTime.now(HardApptWindowResolver.RESERVATION_ZONE));
}
/** 使用固定北京时间推演,保证同一请求内状态一致,也便于按业务示例核对边界。 */
List<HardApptDateOptionsVo> preview(HardApptDto dto, DataScope dataScope, LocalDateTime now) {
if (dto == null || dto.getMchId() == null) throw new RuntimeException("商户不能为空");
R<List<MchManageVo>> 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<HardApptWindowEntity>()
.in(HardApptWindowEntity::getApptId, ruleIds))
.stream()
.filter(window -> !Objects.equals(window.getId(), selfWindowId))
.toList();
}
private Map<Long, Set<LocalDate>> loadWindowDates(List<HardApptWindowEntity> windows) {
List<Long> windowIds = windows.stream().map(HardApptWindowEntity::getId).toList();
return hardApptItemMapper.selectList(new LambdaQueryWrapper<HardApptItemEntity>()
.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<HardApptEntity>()
.eq(HardApptEntity::getId, dto.getId()).eq(HardApptEntity::getMchId, dto.getMchId()), dataScope).isEmpty()) {
throw new RuntimeException("预约规则不存在或不在当前数据权限范围内");
}
List<HardApptWindowDto> draft = validateAndNormalizeWindows(dto);
List<WindowSource> sources = new ArrayList<>(listEnabledWindows(dto.getMchId()).stream()
.filter(source -> !Objects.equals(source.window().getApptId(), dto.getId())).toList());
Map<LocalDate, List<HardApptOptionVo>> dates = new java.util.TreeMap<>();
List<Target> 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<HardApptItemEntity> 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<Target, ResolvedTarget> 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<HardApptWindowDto> windows) {
for (HardApptWindowDto window : windows) {
HardApptWindowEntity windowEntity = new HardApptWindowEntity();
@@ -374,6 +394,7 @@ public class HardApptServiceImpl extends ServiceImpl<HardApptMapper, HardApptEnt
}
}
/** 级联逻辑删除规则的窗口和明细,历史订单继续使用自己的退款截止快照。 */
private void deleteWindows(Long apptId) {
List<HardApptWindowEntity> windows = hardApptWindowMapper.selectList(
new LambdaQueryWrapper<HardApptWindowEntity>().eq(HardApptWindowEntity::getApptId, apptId));
@@ -386,9 +407,9 @@ public class HardApptServiceImpl extends ServiceImpl<HardApptMapper, HardApptEnt
}
/**
* 为规则展示回填全部窗口及窗口内明细(窗口按开始时间升序,明细按日期+餐段排序)。
* 回填完整窗口及明细,保留编辑与退款预览所需数据;按本次查询统一时间回填只读过期标记。
*/
private void disposeWindowList(List<HardApptVo> records) {
private void disposeWindowList(List<HardApptVo> records, LocalDateTime now) {
if (ObjectUtil.isEmpty(records)) return;
List<Long> ruleIds = records.stream().map(HardApptVo::getId).filter(ObjectUtil::isNotNull).toList();
if (ruleIds.isEmpty()) return;
@@ -400,6 +421,10 @@ public class HardApptServiceImpl extends ServiceImpl<HardApptMapper, HardApptEnt
: hardApptItemMapper.selectList(new LambdaQueryWrapper<HardApptItemEntity>()
.in(HardApptItemEntity::getWindowId, windows.stream().map(HardApptWindowEntity::getId).toList()))
.stream().collect(Collectors.groupingBy(HardApptItemEntity::getWindowId));
// 必须按商户加载全部启用规则,当前页以外的后续窗口也可能决定最终退款截止。
Map<Long, Map<Target, ResolvedTarget>> 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<HardApptMapper, HardApptEnt
windowVo.setId(window.getId());
windowVo.setStartTime(window.getStartTime());
windowVo.setEndTime(window.getEndTime());
windowVo.setExpired(window.getEndTime() != null && window.getEndTime().isBefore(now));
windowVo.setAutoRefundTime(window.getAutoRefundTime());
windowVo.setItemList(itemsByWindow.getOrDefault(window.getId(), List.of()).stream()
.sorted((left, right) -> {
@@ -418,6 +444,9 @@ public class HardApptServiceImpl extends ServiceImpl<HardApptMapper, HardApptEnt
HardApptItemVo itemVo = new HardApptItemVo();
itemVo.setTargetDate(item.getTargetDate());
itemVo.setHourType(item.getHourType());
ResolvedTarget resolved = resolvedByMerchant.getOrDefault(record.getMchId(), Map.of())
.get(new Target(item.getTargetDate(), item.getHourType()));
itemVo.setAutoRefundDeadline(resolved == null ? null : resolved.autoRefundDeadline());
return itemVo;
})
.collect(Collectors.toList()));
@@ -0,0 +1,197 @@
package com.bsd.pjcloud.hard.service.support;
import com.bsd.pjcloud.common.core.exception.ErrorCodes;
import com.bsd.pjcloud.common.core.util.MsgUtils;
import com.bsd.pjcloud.hard.api.constant.HardApptReserveModeEnum;
import com.bsd.pjcloud.hard.api.constant.order.HardHourTypeEnum;
import com.bsd.pjcloud.hard.api.entity.HardApptItemEntity;
import com.bsd.pjcloud.hard.api.entity.HardApptWindowEntity;
import com.bsd.pjcloud.hard.api.vo.HardApptOptionVo;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.TreeMap;
/**
* 动态预约窗口的纯计算器。调用方先按租户和商户加载启用配置,再按日期餐段合并,
* 列表、草稿预览与下单必须使用同一结果;本类不查询数据库,也不改写窗口或历史订单。
*/
public final class HardApptWindowResolver {
/** 所有动态预约及退款截止均使用北京时间,避免服务部署时区影响边界。 */
public static final ZoneId RESERVATION_ZONE = ZoneId.of("Asia/Shanghai");
/** 已持久化窗口的优先级:结束越晚越优先;相同截止按 ID 升序,草稿无 ID 排在最后。 */
private static final Comparator<WindowSource> 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<HardApptItemEntity> 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<Target, ResolvedTarget> resolve(List<WindowSource> sources, LocalDateTime now) {
Map<Target, List<WindowSource>> 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<WindowSource> matching = grouped.computeIfAbsent(target, key -> new ArrayList<>());
if (!matching.contains(source)) matching.add(source);
}
}
Map<Target, ResolvedTarget> 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<WindowSource> sources, LocalDateTime now) {
List<WindowSource> 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<WindowSource> 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;
}
}
@@ -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<HardApptItemEntity> 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.<Wrapper<HardApptEntity>>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.<Wrapper<HardApptWindowEntity>>any()))
@@ -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);
@@ -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<WindowSource> 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;
}
}
@@ -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<WindowSource> 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<WindowSource> 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<HardApptItemEntity> 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);
}
}