Просмотр исходного кода

客户端预约时段接口开发

wangzhijun 3 дней назад
Родитель
Сommit
76effe7801

+ 106 - 0
nightFragrance-common/src/main/java/com/ylx/common/utils/TimeSlotUtil.java

@@ -0,0 +1,106 @@
+package com.ylx.common.utils;
+
+import java.time.LocalDate;
+import java.time.LocalTime;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * 预约时段生成工具类
+ * 规则说明:
+ * 1. 未来日期:生成全天所有完整不跨天时段起点,步进 = 服务时长
+ * 2. 当天日期:只返回【当前时间之后】的时段,已过期时段全部过滤
+ * 示例:时长30分钟,当前10:01 → 第一个时段10:30;当前10:35 → 第一个时段11:00
+ */
+public class TimeSlotUtil {
+
+    /** 一天总分钟数 24*60 */
+    private static final int MINUTES_PER_DAY = 24 * 60;
+    /** 一天总纳秒数 */
+    private static final long DAY_NANOS = (long) MINUTES_PER_DAY * 60_000_000_000L;
+
+    /**
+     * 获取指定日期可预约时段起点列表
+     * @param queryDate 查询目标日期
+     * @param durationMin 单次服务时长(分钟,同时作为时段步进,必须>0且<=1440)
+     * @return 可用时段 LocalTime 集合
+     */
+    public static List<LocalTime> getAvailableTimeSlots(LocalDate queryDate, int durationMin) {
+        // 参数合法性拦截
+        if (durationMin <= 0 || durationMin > MINUTES_PER_DAY || queryDate == null) {
+            return Collections.emptyList();
+        }
+
+        LocalDate today = LocalDate.now();
+        // 非今天:返回全天完整时段
+        if (!queryDate.isEqual(today)) {
+            return generateFullDaySlots(durationMin);
+        }
+
+        // 今日逻辑:先生成全天 bounded 时段,再过滤当前时间之前的
+        LocalTime now = LocalTime.now();
+        LocalTime firstValidSlot = getNextStrictSlot(now, durationMin);
+        LocalTime latestStart = LocalTime.of(23, 59);
+        if (firstValidSlot.isAfter(latestStart)) {
+            return Collections.emptyList();
+        }
+
+        List<LocalTime> slotList = new ArrayList<>();
+        for (LocalTime slot : generateFullDaySlots(durationMin)) {
+            if (slot.isBefore(firstValidSlot)) {
+                continue;
+            }
+            if (slot.isAfter(latestStart)) {
+                break;
+            }
+            slotList.add(slot);
+        }
+        return slotList;
+    }
+
+    /**
+     * 生成全天完整时段起点(00:00开始,按服务时长步进)
+     * @param durationMin 步进/服务时长
+     * @return 全天所有时段起点
+     */
+    private static List<LocalTime> generateFullDaySlots(int durationMin) {
+        int totalSlotCount = MINUTES_PER_DAY / durationMin;
+        List<LocalTime> slotList = new ArrayList<>(totalSlotCount);
+        LocalTime current = LocalTime.MIDNIGHT;
+        for (int i = 0; i < totalSlotCount; i++) {
+            slotList.add(current);
+            current = current.plusMinutes(durationMin);
+        }
+        return slotList;
+    }
+
+    /**
+     * 获取严格大于传入时间的下一个时段起点
+     * 步进等于服务时长,自动向上取档
+     * 示例:
+     * durationMin=30
+     *  10:00:00 → 10:30
+     *  10:01:00 → 10:30
+     *  10:30:00 → 11:00
+     * @param time 基准时间
+     * @param durationMin 时段步进分钟
+     * @return 下一档时段起点,超出当日则返回00:00
+     */
+    public static LocalTime getNextStrictSlot(LocalTime time, int durationMin) {
+        // 单步对应的纳秒值
+        long stepNanos = (long) durationMin * 60_000_000_000L;
+        // 当前时间纳秒 + 1纳秒,刚好等于时段边界也会进到下一档
+        long currentNano = time.toNanoOfDay() + 1;
+
+        // 向上取整计算下一个时段纳秒点
+        long nextSlotNano = ((currentNano / stepNanos) + 1) * stepNanos;
+
+        // 超过当天时间范围,返回次日零点(上层会用latestStart过滤掉)
+        if (nextSlotNano >= DAY_NANOS) {
+            return LocalTime.MIDNIGHT;
+        }
+        return LocalTime.ofNanoOfDay(nextSlotNano);
+    }
+
+}

+ 9 - 0
nightFragrance-massage/src/main/java/com/ylx/order/controller/CustomerOrderController.java

@@ -3,6 +3,7 @@ package com.ylx.order.controller;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.ylx.common.core.domain.R;
 import com.ylx.order.domain.dto.*;
+import com.ylx.order.domain.vo.BookingSlotsVO;
 import com.ylx.order.domain.vo.OrderDateQueryVo;
 import com.ylx.order.domain.vo.OrderDetailVO;
 import com.ylx.order.domain.vo.RecentMerchantVO;
@@ -16,6 +17,7 @@ import org.springframework.validation.annotation.Validated;
 import org.springframework.web.bind.annotation.*;
 
 import javax.annotation.Resource;
+import java.util.List;
 import java.util.Map;
 
 @RestController
@@ -72,6 +74,13 @@ public class CustomerOrderController {
         return R.ok(result);
     }
 
+    @ApiOperation("客户端预约时段")
+    @PostMapping("/booking/slots")
+    public R<List<BookingSlotsVO>> bookingSlots(@RequestBody @Validated BookingSlotsDTO dto) {
+        List<BookingSlotsVO> result = this.orderService.bookingSlots(dto);
+        return R.ok(result);
+    }
+
     @ApiOperation("获取近期下单商户列表")
     @PostMapping("/recent/merchants")
     public R<Page<RecentMerchantVO>> getRecentMerchants(@RequestBody RecentOrderQueryDTO dto) {

+ 3 - 15
nightFragrance-massage/src/main/java/com/ylx/order/domain/dto/BookingCheckDTO.java

@@ -4,27 +4,15 @@ import com.fasterxml.jackson.annotation.JsonFormat;
 import io.swagger.annotations.ApiModel;
 import io.swagger.annotations.ApiModelProperty;
 import lombok.Data;
+import lombok.EqualsAndHashCode;
 
 import javax.validation.constraints.NotNull;
-import java.time.LocalDate;
 import java.time.LocalTime;
 
+@EqualsAndHashCode(callSuper = true)
 @Data
 @ApiModel("客户端预约校验DTO")
-public class BookingCheckDTO {
-
-    @NotNull(message = "商户ID不能为空")
-    @ApiModelProperty("商户ID")
-    private Long merchantId;
-
-    @NotNull(message = "项目ID不能为空")
-    @ApiModelProperty("项目ID")
-    private Long projectId;
-
-    @NotNull(message = "预约日期不能为空")
-    @ApiModelProperty(value = "预约日期", example = "2026-06-12")
-    @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
-    private LocalDate appointmentDate;
+public class BookingCheckDTO extends BookingSlotsDTO{
 
     @NotNull(message = "预约开始时间不能为空")
     @ApiModelProperty(value = "预约开始时间", example = "14:30")

+ 28 - 0
nightFragrance-massage/src/main/java/com/ylx/order/domain/dto/BookingSlotsDTO.java

@@ -0,0 +1,28 @@
+package com.ylx.order.domain.dto;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import javax.validation.constraints.NotNull;
+import java.time.LocalDate;
+
+@Data
+@ApiModel("客户端预约时间段DTO")
+public class BookingSlotsDTO {
+
+    @NotNull(message = "商户ID不能为空")
+    @ApiModelProperty("商户ID")
+    private Long merchantId;
+
+    @NotNull(message = "项目ID不能为空")
+    @ApiModelProperty("项目ID")
+    private Long projectId;
+
+    @NotNull(message = "预约日期不能为空")
+    @ApiModelProperty(value = "预约日期", example = "2026-06-12")
+    @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
+    private LocalDate appointmentDate;
+
+}

+ 31 - 0
nightFragrance-massage/src/main/java/com/ylx/order/domain/vo/BookingSlotsVO.java

@@ -0,0 +1,31 @@
+package com.ylx.order.domain.vo;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import java.time.LocalTime;
+
+@ApiModel("客户端预约时间段VO")
+@Data
+public class BookingSlotsVO {
+
+    @ApiModelProperty(value = "开始时间(HH:mm)")
+    private LocalTime startTime;
+
+    @ApiModelProperty("标时")
+    private Integer standardDuration;
+
+    @ApiModelProperty("计量单位")
+    private String unitTypeName;
+
+    @ApiModelProperty(value = "结束时间(HH:mm)")
+    private LocalTime endTime;
+
+    @ApiModelProperty(value = "是否为白天: true-白天, false-晚上")
+    private Boolean isDay;
+
+    @ApiModelProperty(value = "是否可预约: true-可约, false-停用")
+    private Boolean available;
+
+}

+ 3 - 0
nightFragrance-massage/src/main/java/com/ylx/order/service/TOrderService.java

@@ -10,6 +10,7 @@ import com.ylx.massage.domain.vo.HomeBlock;
 import com.ylx.massage.domain.vo.OrderVerificationVo;
 import com.ylx.massage.domain.vo.TechnicianAvailabilityVo;
 import com.ylx.order.domain.dto.*;
+import com.ylx.order.domain.vo.BookingSlotsVO;
 import com.ylx.order.domain.vo.OrderDateQueryVo;
 import com.ylx.order.domain.vo.OrderDetailVO;
 import com.ylx.order.domain.vo.RecentMerchantVO;
@@ -216,4 +217,6 @@ public interface TOrderService extends IService<TOrder> {
     Page<RecentMerchantVO> getRecentMerchants(RecentOrderQueryDTO dto);
 
     MerchantOrderDetailVO getMerchantOrderDetail(MerchantOrderDetailDTO dto);
+
+    List<BookingSlotsVO> bookingSlots(BookingSlotsDTO dto);
 }

+ 78 - 20
nightFragrance-massage/src/main/java/com/ylx/order/service/impl/TOrderServiceImpl.java

@@ -19,6 +19,7 @@ import com.ylx.common.exception.ServiceException;
 import com.ylx.common.utils.*;
 import com.ylx.common.weixinPay.enums.WxPayTypeEnum;
 import com.ylx.common.weixinPay.service.WxPayV3Service;
+import com.ylx.fareSetting.service.IMaProjectFareSettingService;
 import com.ylx.massage.domain.*;
 import com.ylx.massage.domain.vo.HomeBlock;
 import com.ylx.massage.domain.vo.OrderVerificationVo;
@@ -33,10 +34,7 @@ import com.ylx.order.domain.AfterSalesService;
 import com.ylx.order.domain.OrderStatusFlow;
 import com.ylx.order.domain.TOrder;
 import com.ylx.order.domain.dto.*;
-import com.ylx.order.domain.vo.OrderDateQueryVo;
-import com.ylx.order.domain.vo.OrderDetailVO;
-import com.ylx.order.domain.vo.OrderStatusFlowVO;
-import com.ylx.order.domain.vo.RecentMerchantVO;
+import com.ylx.order.domain.vo.*;
 import com.ylx.order.domain.vo.merchant.MerchantCancelOrderDTO;
 import com.ylx.order.domain.vo.merchant.MerchantOrderDetailVO;
 import com.ylx.order.domain.vo.merchant.OrderCustomerPhoneVO;
@@ -125,6 +123,9 @@ public class TOrderServiceImpl extends ServiceImpl<TOrderMapper, TOrder> impleme
     @Value("${ylx.fixedVerifyCode:123456}")
     private String fixedVerifyCode;
 
+    @Resource
+    private IMaProjectFareSettingService maProjectFareSettingService;
+
     @Override
     public TOrder addOrder(TOrder order) {
         return null;
@@ -832,8 +833,6 @@ public class TOrderServiceImpl extends ServiceImpl<TOrderMapper, TOrder> impleme
 
         // 2. 组装目标时间段
         LocalDateTime newStart = LocalDateTime.of(dto.getAppointmentDate(), dto.getStartTime());
-        LocalDateTime actualEnd = newStart.plusMinutes(duration); // 实际服务结束时间
-        LocalDateTime newEndWithBuffer = actualEnd.plusMinutes(APPOINT_BUFFER_MINUTES); // 含30分钟缓冲的结束时间
 
         // 3. 查询该商户当天的【有效占用】订单
         LocalDateTime dayStart = dto.getAppointmentDate().atStartOfDay();
@@ -841,20 +840,7 @@ public class TOrderServiceImpl extends ServiceImpl<TOrderMapper, TOrder> impleme
 
         List<TOrder> validOrders = this.baseMapper.selectValidOrdersByMerchantAndDate(dto.getMerchantId(), dto.getProjectId(), dayStart, dayEnd);
 
-        // 4. 内存中遍历判断重叠
-        for (TOrder order : validOrders) {
-            LocalDateTime existStart = order.getAppointmentStartTime();
-            LocalDateTime existEnd = order.getAppointmentEndTime();
-
-            // 核心重叠算法:
-            // 新订单开始时间 < 已有订单结束时间  且  新订单结束时间(含30min缓冲) > 已有订单开始时间
-            if (newStart.isBefore(existEnd) && newEndWithBuffer.isAfter(existStart)) {
-                return false;
-            }
-        }
-
-        // 5. 没有任何冲突,可以预约
-        return true;
+        return isAppointmentSlotAvailable(newStart, duration, validOrders);
     }
 
     @Override
@@ -1258,6 +1244,78 @@ public class TOrderServiceImpl extends ServiceImpl<TOrderMapper, TOrder> impleme
         return vo;
     }
 
+    @Override
+    public List<BookingSlotsVO> bookingSlots(BookingSlotsDTO dto) {
+
+        // 1. 获取项目信息(关键:服务时长)
+        Project project = this.projectService.getById(dto.getProjectId());
+        if (ObjectUtil.isNull(project)) {
+            throw new ServiceException("项目不存在");
+        }
+
+        Integer standardDuration = project.getStandardDuration();
+        Integer unitType = project.getUnitType();
+        String unitTypeName = "未知单位";
+        List<SysDictData> dictList = DictUtils.getSortedDictCache(DICT_UNIT_TYPE);
+        if (CollUtil.isNotEmpty(dictList) && ObjectUtil.isNotNull(unitType)) {
+            unitTypeName = dictList.stream()
+                    .filter(dict -> unitType.toString().equals(dict.getDictValue()))
+                    .map(SysDictData::getDictLabel)
+                    .findFirst()
+                    .orElse("未知单位");
+        }
+
+        Integer durationMinutes = convertToMinutes(standardDuration, unitType);
+        if (ObjectUtil.isNull(durationMinutes) || durationMinutes <= 0) {
+            throw new ServiceException("项目时长配置异常");
+        }
+
+        // 2. 生成所有候选开始时间(半点粒度,步进30分钟)
+        List<LocalTime> candidateStarts = TimeSlotUtil.getAvailableTimeSlots(dto.getAppointmentDate(), 30);
+
+        // 3. 查询该商户当天的【有效占用】订单
+        LocalDateTime dayStart = dto.getAppointmentDate().atStartOfDay();
+        LocalDateTime dayEnd = dto.getAppointmentDate().atTime(LocalTime.MAX);
+        List<TOrder> validOrders = this.baseMapper.selectValidOrdersByMerchantAndDate(
+                dto.getMerchantId(), dto.getProjectId(), dayStart, dayEnd);
+
+        // 4. 遍历候选时段,构建 VO
+        List<BookingSlotsVO> voList = new ArrayList<>(candidateStarts.size());
+        for (LocalTime startTime : candidateStarts) {
+            LocalDateTime slotStart = LocalDateTime.of(dto.getAppointmentDate(), startTime);
+            LocalDateTime actualEnd = slotStart.plusMinutes(durationMinutes);
+
+            BookingSlotsVO vo = new BookingSlotsVO();
+            vo.setStartTime(startTime);
+            vo.setStandardDuration(standardDuration);
+            vo.setUnitTypeName(unitTypeName);
+            vo.setEndTime(actualEnd.toLocalTime());
+            vo.setIsDay(maProjectFareSettingService.isDayTimePeriod(slotStart));
+
+            boolean exceedsDay = !actualEnd.toLocalDate().isEqual(dto.getAppointmentDate());
+            vo.setAvailable(!exceedsDay && isAppointmentSlotAvailable(slotStart, durationMinutes, validOrders));
+            voList.add(vo);
+        }
+
+        return voList;
+    }
+
+    /**
+     * 判断预约时段是否与已有订单冲突(含30分钟缓冲)
+     */
+    private boolean isAppointmentSlotAvailable(LocalDateTime newStart, int durationMinutes, List<TOrder> validOrders) {
+        LocalDateTime actualEnd = newStart.plusMinutes(durationMinutes);
+        LocalDateTime newEndWithBuffer = actualEnd.plusMinutes(APPOINT_BUFFER_MINUTES);
+        for (TOrder order : validOrders) {
+            LocalDateTime existStart = order.getAppointmentStartTime();
+            LocalDateTime existEnd = order.getAppointmentEndTime();
+            if (newStart.isBefore(existEnd) && newEndWithBuffer.isAfter(existStart)) {
+                return false;
+            }
+        }
+        return true;
+    }
+
     private void fillCurrentAfterSaleInfo(IAfterSaleDisplay vo, Long orderId) {
         LambdaQueryWrapper<AfterSalesService> wrapper = new LambdaQueryWrapper<>();
         wrapper.eq(AfterSalesService::getOrderId, orderId).orderByDesc(AfterSalesService::getCreateTime).last("LIMIT 1");