diff --git a/src/main/java/com/petstore/controller/AppointmentController.java b/src/main/java/com/petstore/controller/AppointmentController.java index 6316164..35da955 100644 --- a/src/main/java/com/petstore/controller/AppointmentController.java +++ b/src/main/java/com/petstore/controller/AppointmentController.java @@ -6,6 +6,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.web.bind.annotation.*; +import java.time.LocalDate; import java.util.List; import java.util.Map; @@ -16,6 +17,21 @@ import java.util.Map; public class AppointmentController { private final AppointmentService appointmentService; + /** + * 门店某日可预约时段(半小时一档,医院挂号式占号)。 + * date 格式:yyyy-MM-dd + */ + @GetMapping("/available-slots") + public Map 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") public Map list( @@ -93,8 +109,7 @@ public class AppointmentController { appointment.setPetId(Long.valueOf(params.get("petId").toString())); } - Appointment created = appointmentService.create(appointment); - return Map.of("code", 200, "message", "创建成功", "data", created); + return appointmentService.createBooking(appointment); } /** 开始服务:状态变进行中 + 指定技师 */ diff --git a/src/main/java/com/petstore/controller/StoreController.java b/src/main/java/com/petstore/controller/StoreController.java index 739e315..87a3cca 100644 --- a/src/main/java/com/petstore/controller/StoreController.java +++ b/src/main/java/com/petstore/controller/StoreController.java @@ -42,6 +42,12 @@ public class StoreController { m.put("longitude", s.getLongitude()); m.put("phone", s.getPhone()); 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; }).collect(Collectors.toList()); return Map.of("code", 200, "data", rows); @@ -63,15 +69,19 @@ public class StoreController { @PutMapping("/update") public Map update(@RequestBody Store store) { - Store updated = storeService.update(store); - if (updated == null) { - return Map.of("code", 404, "message", "店铺不存在"); + try { + Store updated = storeService.update(store); + if (updated == null) { + return Map.of("code", 404, "message", "店铺不存在"); + } + Map 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 result = new HashMap<>(); - result.put("code", 200); - result.put("message", "更新成功"); - result.put("data", updated); - return result; } @DeleteMapping("/delete") diff --git a/src/main/java/com/petstore/entity/Store.java b/src/main/java/com/petstore/entity/Store.java index 26e57cb..aec2dd9 100644 --- a/src/main/java/com/petstore/entity/Store.java +++ b/src/main/java/com/petstore/entity/Store.java @@ -2,7 +2,9 @@ package com.petstore.entity; import jakarta.persistence.*; import lombok.Data; + import java.time.LocalDateTime; +import java.time.LocalTime; @Data @Entity @@ -33,6 +35,20 @@ public class Store { @Column(name = "invite_code") 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") private LocalDateTime createTime; diff --git a/src/main/java/com/petstore/mapper/AppointmentMapper.java b/src/main/java/com/petstore/mapper/AppointmentMapper.java index c0633c9..8f2f9e4 100644 --- a/src/main/java/com/petstore/mapper/AppointmentMapper.java +++ b/src/main/java/com/petstore/mapper/AppointmentMapper.java @@ -4,7 +4,10 @@ import com.petstore.entity.Appointment; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; 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.Optional; @@ -19,4 +22,14 @@ public interface AppointmentMapper extends JpaRepository { Page findByStoreIdAndDeletedFalse(Long storeId, Pageable pageable); Page findByStoreIdAndStatusAndDeletedFalse(Long storeId, String status, Pageable pageable); Optional 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 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); } diff --git a/src/main/java/com/petstore/service/AppointmentService.java b/src/main/java/com/petstore/service/AppointmentService.java index 2e9f454..95ee193 100644 --- a/src/main/java/com/petstore/service/AppointmentService.java +++ b/src/main/java/com/petstore/service/AppointmentService.java @@ -8,14 +8,23 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDate; 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.Map; +import java.util.Set; @Service @RequiredArgsConstructor public class AppointmentService { private final AppointmentMapper appointmentMapper; + private final StoreService storeService; // 员工查看自己的预约 public List getByUserId(Long userId) { @@ -61,11 +70,102 @@ public class AppointmentService { return appointmentMapper.findByIdAndDeletedFalse(id).orElse(null); } - public Appointment create(Appointment appointment) { + /** + * 某门店某日:半小时挂号时段列表(已占用 / 已过 / 可约)。 + */ + public Map 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 occupiedRaw = appointmentMapper.findOccupiedAppointmentTimes(storeId, dayStart, dayEnd); + Set occupied = new HashSet<>(); + for (LocalDateTime t : occupiedRaw) { + occupied.add(AppointmentSlotSupport.alignToHalfHour(t)); + } + + LocalDateTime now = LocalDateTime.now(); + List> 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 one = new LinkedHashMap<>(); + one.put("time", hhmm); + one.put("available", available); + if (reason != null) { + one.put("reason", reason); + } + rows.add(one); + } + Map 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 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.setUpdateTime(LocalDateTime.now()); appointment.setDeleted(false); - return appointmentMapper.save(appointment); + Appointment saved = appointmentMapper.save(appointment); + Map ok = new HashMap<>(); + ok.put("code", 200); + ok.put("message", "创建成功"); + ok.put("data", saved); + return ok; } public Appointment updateStatus(Long id, String status) { diff --git a/src/main/java/com/petstore/service/AppointmentSlotSupport.java b/src/main/java/com/petstore/service/AppointmentSlotSupport.java new file mode 100644 index 0000000..e06e2c0 --- /dev/null +++ b/src/main/java/com/petstore/service/AppointmentSlotSupport.java @@ -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 allSlotStartsOnDay(LocalDate date, StoreBookingWindow window) { + List 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; + } +} diff --git a/src/main/java/com/petstore/service/StoreBookingWindow.java b/src/main/java/com/petstore/service/StoreBookingWindow.java new file mode 100644 index 0000000..1912d72 --- /dev/null +++ b/src/main/java/com/petstore/service/StoreBookingWindow.java @@ -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); + } +} diff --git a/src/main/java/com/petstore/service/StoreService.java b/src/main/java/com/petstore/service/StoreService.java index a5a9620..9244616 100644 --- a/src/main/java/com/petstore/service/StoreService.java +++ b/src/main/java/com/petstore/service/StoreService.java @@ -6,6 +6,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import java.time.LocalDateTime; +import java.time.LocalTime; import java.util.List; import java.util.UUID; @@ -35,12 +36,59 @@ public class StoreService { return storeMapper.findAllByDeletedFalse(); } - public Store update(Store store) { - store.setUpdateTime(LocalDateTime.now()); - if (store.getDeleted() == null) { - store.setDeleted(false); + public Store update(Store incoming) { + if (incoming == null || incoming.getId() == null) { + return null; } - 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) {