feat: 优化预约时段支持及门店配置

This commit is contained in:
MaDaLei 2026-04-17 10:57:31 +08:00
parent 870190c709
commit e4410f5794
8 changed files with 305 additions and 17 deletions

View File

@ -6,6 +6,7 @@ import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@ -16,6 +17,21 @@ import java.util.Map;
public class AppointmentController { public class AppointmentController {
private final AppointmentService appointmentService; private final AppointmentService appointmentService;
/**
* 门店某日可预约时段半小时一档医院挂号式占号
* date 格式yyyy-MM-dd
*/
@GetMapping("/available-slots")
public Map<String, Object> availableSlots(@RequestParam Long storeId, @RequestParam String date) {
LocalDate d;
try {
d = LocalDate.parse(date);
} catch (Exception e) {
return Map.of("code", 400, "message", "日期格式应为 yyyy-MM-dd");
}
return appointmentService.availableSlots(storeId, d);
}
/** 获取预约列表(员工查自己/老板查全店) */ /** 获取预约列表(员工查自己/老板查全店) */
@GetMapping("/list") @GetMapping("/list")
public Map<String, Object> list( public Map<String, Object> list(
@ -93,8 +109,7 @@ public class AppointmentController {
appointment.setPetId(Long.valueOf(params.get("petId").toString())); appointment.setPetId(Long.valueOf(params.get("petId").toString()));
} }
Appointment created = appointmentService.create(appointment); return appointmentService.createBooking(appointment);
return Map.of("code", 200, "message", "创建成功", "data", created);
} }
/** 开始服务:状态变进行中 + 指定技师 */ /** 开始服务:状态变进行中 + 指定技师 */

View File

@ -42,6 +42,12 @@ public class StoreController {
m.put("longitude", s.getLongitude()); m.put("longitude", s.getLongitude());
m.put("phone", s.getPhone()); m.put("phone", s.getPhone());
m.put("logo", s.getLogo()); m.put("logo", s.getLogo());
if (s.getBookingDayStart() != null) {
m.put("bookingDayStart", s.getBookingDayStart().toString());
}
if (s.getBookingLastSlotStart() != null) {
m.put("bookingLastSlotStart", s.getBookingLastSlotStart().toString());
}
return m; return m;
}).collect(Collectors.toList()); }).collect(Collectors.toList());
return Map.of("code", 200, "data", rows); return Map.of("code", 200, "data", rows);
@ -63,15 +69,19 @@ public class StoreController {
@PutMapping("/update") @PutMapping("/update")
public Map<String, Object> update(@RequestBody Store store) { public Map<String, Object> update(@RequestBody Store store) {
Store updated = storeService.update(store); try {
if (updated == null) { Store updated = storeService.update(store);
return Map.of("code", 404, "message", "店铺不存在"); if (updated == null) {
return Map.of("code", 404, "message", "店铺不存在");
}
Map<String, Object> result = new HashMap<>();
result.put("code", 200);
result.put("message", "更新成功");
result.put("data", updated);
return result;
} catch (IllegalArgumentException e) {
return Map.of("code", 400, "message", e.getMessage() != null ? e.getMessage() : "参数错误");
} }
Map<String, Object> result = new HashMap<>();
result.put("code", 200);
result.put("message", "更新成功");
result.put("data", updated);
return result;
} }
@DeleteMapping("/delete") @DeleteMapping("/delete")

View File

@ -2,7 +2,9 @@ package com.petstore.entity;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.Data; import lombok.Data;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.LocalTime;
@Data @Data
@Entity @Entity
@ -34,6 +36,20 @@ public class Store {
@Column(name = "invite_code") @Column(name = "invite_code")
private String inviteCode; private String inviteCode;
/**
* 可预约每日首个半点号源起始时刻须为整点或半点
* null 时后端按默认 09:00 处理
*/
@Column(name = "booking_day_start")
private LocalTime bookingDayStart;
/**
* 可预约每日最后一个可约时段的起始时刻须为整点或半点且不早于 {@link #bookingDayStart}
* null 时后端按默认 21:30 处理
*/
@Column(name = "booking_last_slot_start")
private LocalTime bookingLastSlotStart;
@Column(name = "create_time") @Column(name = "create_time")
private LocalDateTime createTime; private LocalDateTime createTime;

View File

@ -4,7 +4,10 @@ import com.petstore.entity.Appointment;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@ -19,4 +22,14 @@ public interface AppointmentMapper extends JpaRepository<Appointment, Long> {
Page<Appointment> findByStoreIdAndDeletedFalse(Long storeId, Pageable pageable); Page<Appointment> findByStoreIdAndDeletedFalse(Long storeId, Pageable pageable);
Page<Appointment> findByStoreIdAndStatusAndDeletedFalse(Long storeId, String status, Pageable pageable); Page<Appointment> findByStoreIdAndStatusAndDeletedFalse(Long storeId, String status, Pageable pageable);
Optional<Appointment> findByIdAndDeletedFalse(Long id); Optional<Appointment> findByIdAndDeletedFalse(Long id);
@Query("SELECT a.appointmentTime FROM Appointment a WHERE a.storeId = :storeId AND a.deleted = false AND (a.status IS NULL OR a.status <> 'cancel') AND a.appointmentTime >= :start AND a.appointmentTime < :end")
List<LocalDateTime> findOccupiedAppointmentTimes(
@Param("storeId") Long storeId,
@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end
);
@Query("SELECT COUNT(a) > 0 FROM Appointment a WHERE a.storeId = :storeId AND a.appointmentTime = :t AND a.deleted = false AND (a.status IS NULL OR a.status <> 'cancel')")
boolean existsActiveBookingAt(@Param("storeId") Long storeId, @Param("t") LocalDateTime t);
} }

View File

@ -8,14 +8,23 @@ import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Set;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class AppointmentService { public class AppointmentService {
private final AppointmentMapper appointmentMapper; private final AppointmentMapper appointmentMapper;
private final StoreService storeService;
// 员工查看自己的预约 // 员工查看自己的预约
public List<Appointment> getByUserId(Long userId) { public List<Appointment> getByUserId(Long userId) {
@ -61,11 +70,102 @@ public class AppointmentService {
return appointmentMapper.findByIdAndDeletedFalse(id).orElse(null); return appointmentMapper.findByIdAndDeletedFalse(id).orElse(null);
} }
public Appointment create(Appointment appointment) { /**
* 某门店某日半小时挂号时段列表已占用 / 已过 / 可约
*/
public Map<String, Object> availableSlots(Long storeId, LocalDate date) {
if (storeId == null || date == null) {
return Map.of("code", 400, "message", "缺少门店或日期");
}
com.petstore.entity.Store store = storeService.findById(storeId);
if (store == null) {
return Map.of("code", 404, "message", "门店不存在");
}
StoreBookingWindow window = StoreBookingWindow.fromStore(store);
LocalDateTime dayStart = date.atStartOfDay();
LocalDateTime dayEnd = date.plusDays(1).atStartOfDay();
List<LocalDateTime> occupiedRaw = appointmentMapper.findOccupiedAppointmentTimes(storeId, dayStart, dayEnd);
Set<LocalDateTime> occupied = new HashSet<>();
for (LocalDateTime t : occupiedRaw) {
occupied.add(AppointmentSlotSupport.alignToHalfHour(t));
}
LocalDateTime now = LocalDateTime.now();
List<Map<String, Object>> rows = new ArrayList<>();
for (LocalDateTime slotStart : AppointmentSlotSupport.allSlotStartsOnDay(date, window)) {
String hhmm = formatHhMm(slotStart);
boolean past = slotStart.isBefore(now);
boolean taken = occupied.contains(slotStart);
boolean available = !past && !taken;
String reason = null;
if (past) {
reason = "已过时段";
} else if (taken) {
reason = "已约满";
}
Map<String, Object> one = new LinkedHashMap<>();
one.put("time", hhmm);
one.put("available", available);
if (reason != null) {
one.put("reason", reason);
}
rows.add(one);
}
Map<String, Object> data = new HashMap<>();
data.put("slots", rows);
data.put("dayStart", window.dayStart().toString());
data.put("lastSlotStart", window.lastSlotStart().toString());
return Map.of("code", 200, "data", data);
}
private static String formatHhMm(LocalDateTime dt) {
return String.format("%02d:%02d", dt.getHour(), dt.getMinute());
}
/**
* 创建预约半小时占号取消({@code cancel})不占号
*/
@Transactional
public Map<String, Object> createBooking(Appointment appointment) {
if (appointment.getAppointmentTime() == null) {
return Map.of("code", 400, "message", "请填写预约时间");
}
LocalDateTime t = AppointmentSlotSupport.alignToHalfHour(appointment.getAppointmentTime());
appointment.setAppointmentTime(t);
if (appointment.getStoreId() == null) {
return Map.of("code", 400, "message", "缺少门店");
}
com.petstore.entity.Store store = storeService.findById(appointment.getStoreId());
if (store == null) {
return Map.of("code", 404, "message", "门店不存在");
}
StoreBookingWindow window = StoreBookingWindow.fromStore(store);
if (!AppointmentSlotSupport.isWithinBookableWindow(t, window)) {
return Map.of(
"code", 400,
"message",
String.format(
"仅可预约 %s%s 的半点档",
window.dayStart(),
window.lastSlotStart()
)
);
}
if (!t.isAfter(LocalDateTime.now())) {
return Map.of("code", 400, "message", "不能预约当前及已过去的时段");
}
if (appointmentMapper.existsActiveBookingAt(appointment.getStoreId(), t)) {
return Map.of("code", 409, "message", "该时段已被占用,请更换其它时段");
}
appointment.setCreateTime(LocalDateTime.now()); appointment.setCreateTime(LocalDateTime.now());
appointment.setUpdateTime(LocalDateTime.now()); appointment.setUpdateTime(LocalDateTime.now());
appointment.setDeleted(false); appointment.setDeleted(false);
return appointmentMapper.save(appointment); Appointment saved = appointmentMapper.save(appointment);
Map<String, Object> ok = new HashMap<>();
ok.put("code", 200);
ok.put("message", "创建成功");
ok.put("data", saved);
return ok;
} }
public Appointment updateStatus(Long id, String status) { public Appointment updateStatus(Long id, String status) {

View File

@ -0,0 +1,50 @@
package com.petstore.service;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.ArrayList;
import java.util.List;
/**
* 预约时段半小时一档与前端 halfHourTime 对齐
*/
public final class AppointmentSlotSupport {
private AppointmentSlotSupport() {
}
/**
* 将时间对齐到半点档00 30 秒纳秒归零
*/
public static LocalDateTime alignToHalfHour(LocalDateTime dt) {
if (dt == null) {
return null;
}
int m = dt.getMinute();
int nm = m < 30 ? 0 : 30;
return dt.withMinute(nm).withSecond(0).withNano(0);
}
public static boolean isWithinBookableWindow(LocalDateTime slotStart, StoreBookingWindow window) {
if (slotStart == null || window == null) {
return false;
}
LocalTime t = slotStart.toLocalTime();
return !t.isBefore(window.dayStart()) && !t.isAfter(window.lastSlotStart());
}
/**
* 在给定放号窗口内生成某日所有半点起始时刻含首尾
*/
public static List<LocalDateTime> allSlotStartsOnDay(LocalDate date, StoreBookingWindow window) {
List<LocalDateTime> list = new ArrayList<>();
LocalDateTime cur = LocalDateTime.of(date, window.dayStart());
LocalDateTime end = LocalDateTime.of(date, window.lastSlotStart());
while (!cur.isAfter(end)) {
list.add(cur);
cur = cur.plusMinutes(30);
}
return list;
}
}

View File

@ -0,0 +1,36 @@
package com.petstore.service;
import com.petstore.entity.Store;
import java.time.LocalTime;
/**
* 门店可预约放号窗口半小时一档的首末时刻
*/
public record StoreBookingWindow(LocalTime dayStart, LocalTime lastSlotStart) {
public static final LocalTime DEFAULT_DAY_START = LocalTime.of(9, 0);
public static final LocalTime DEFAULT_LAST_SLOT_START = LocalTime.of(21, 30);
public static StoreBookingWindow fromStore(Store store) {
LocalTime a = (store != null && store.getBookingDayStart() != null)
? snapHalfHour(store.getBookingDayStart())
: DEFAULT_DAY_START;
LocalTime b = (store != null && store.getBookingLastSlotStart() != null)
? snapHalfHour(store.getBookingLastSlotStart())
: DEFAULT_LAST_SLOT_START;
if (b.isBefore(a)) {
b = a;
}
return new StoreBookingWindow(a, b);
}
public static LocalTime snapHalfHour(LocalTime t) {
if (t == null) {
return DEFAULT_DAY_START;
}
int m = t.getMinute();
int nm = m < 30 ? 0 : 30;
return LocalTime.of(t.getHour(), nm);
}
}

View File

@ -6,6 +6,7 @@ import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
@ -35,12 +36,59 @@ public class StoreService {
return storeMapper.findAllByDeletedFalse(); return storeMapper.findAllByDeletedFalse();
} }
public Store update(Store store) { public Store update(Store incoming) {
store.setUpdateTime(LocalDateTime.now()); if (incoming == null || incoming.getId() == null) {
if (store.getDeleted() == null) { return null;
store.setDeleted(false);
} }
return storeMapper.save(store); Store existing = storeMapper.findByIdAndDeletedFalse(incoming.getId()).orElse(null);
if (existing == null) {
return null;
}
if (incoming.getName() != null) {
existing.setName(incoming.getName());
}
if (incoming.getPhone() != null) {
existing.setPhone(incoming.getPhone());
}
if (incoming.getAddress() != null) {
existing.setAddress(incoming.getAddress());
}
if (incoming.getIntro() != null) {
existing.setIntro(incoming.getIntro());
}
if (incoming.getLatitude() != null) {
existing.setLatitude(incoming.getLatitude());
}
if (incoming.getLongitude() != null) {
existing.setLongitude(incoming.getLongitude());
}
if (incoming.getLogo() != null) {
existing.setLogo(incoming.getLogo());
}
LocalTime start = existing.getBookingDayStart() != null
? StoreBookingWindow.snapHalfHour(existing.getBookingDayStart())
: StoreBookingWindow.DEFAULT_DAY_START;
LocalTime last = existing.getBookingLastSlotStart() != null
? StoreBookingWindow.snapHalfHour(existing.getBookingLastSlotStart())
: StoreBookingWindow.DEFAULT_LAST_SLOT_START;
if (incoming.getBookingDayStart() != null) {
start = StoreBookingWindow.snapHalfHour(incoming.getBookingDayStart());
}
if (incoming.getBookingLastSlotStart() != null) {
last = StoreBookingWindow.snapHalfHour(incoming.getBookingLastSlotStart());
}
if (last.isBefore(start)) {
throw new IllegalArgumentException("预约「末号」必须不早于「首号」");
}
existing.setBookingDayStart(start);
existing.setBookingLastSlotStart(last);
existing.setUpdateTime(LocalDateTime.now());
if (existing.getDeleted() == null) {
existing.setDeleted(false);
}
return storeMapper.save(existing);
} }
public boolean softDelete(Long id) { public boolean softDelete(Long id) {