feat:动态预约窗口修改
This commit is contained in:
1 parent
68b8c58dd3
commit
7564fef196
14 files changed
+794
-209
No files matched your search
+6
@@ -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;
|
||||
|
||||
}
|
||||
+7
@@ -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;
|
||||
}
|
||||
+3
@@ -47,4 +47,7 @@ public class HardApptOptionVo {
|
||||
|
||||
/** 该日期和餐段本批次的实际截止时间。 */
|
||||
private LocalDateTime reserveEndDateTime;
|
||||
|
||||
/** 动态模式同日期餐段全部启用窗口的最终免审申请截止,北京时间;周期模式为空,仅供展示。 */
|
||||
private LocalDateTime autoRefundDeadline;
|
||||
}
|
||||
+4
@@ -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;
|
||||
|
||||
|
||||
+8
@@ -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')")
|
||||
|
||||
+5
-2
@@ -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,
|
||||
|
||||
+21
-2
@@ -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);
|
||||
}
|
||||
+25
-89
@@ -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) {
|
||||
}
|
||||
}
|
||||
+133
-104
@@ -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()));
|
||||
|
||||
+197
@@ -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;
|
||||
}
|
||||
}
|
||||
+33
@@ -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()))
|
||||
|
||||
+106
-12
@@ -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);
|
||||
|
||||
+98
@@ -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;
|
||||
}
|
||||
}
|
||||
+148
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user