diff --git a/src/main/java/com/petstore/controller/ScheduleController.java b/src/main/java/com/petstore/controller/ScheduleController.java new file mode 100644 index 0000000..5748783 --- /dev/null +++ b/src/main/java/com/petstore/controller/ScheduleController.java @@ -0,0 +1,72 @@ +package com.petstore.controller; + +import com.petstore.service.ScheduleService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Map; + +@RestController +@RequestMapping("/api/schedule") +@RequiredArgsConstructor +@CrossOrigin +public class ScheduleController { + + private final ScheduleService scheduleService; + + /** date 格式:yyyy-MM-dd */ + @GetMapping("/day") + public Map day(@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 scheduleService.dayAgenda(storeId, d); + } + + /** + * 手动占用半小时档。
+ * slotStart:ISO 本地时间,如 2026-04-17T10:00:00
+ * blockType:walk_in(到店) / blocked(暂停线上预约)
+ * createdByUserId:当前登录用户 id(与前端会话一致即可) + */ + @PostMapping("/block") + public Map createBlock(@RequestBody Map body) { + Long storeId = parseLong(body.get("storeId")); + String slotStr = body.get("slotStart") != null ? body.get("slotStart").toString() : null; + String blockType = body.get("blockType") != null ? body.get("blockType").toString() : null; + String note = body.get("note") != null ? body.get("note").toString() : null; + Long createdBy = parseLong(body.get("createdByUserId")); + + LocalDateTime slotStart; + try { + slotStart = LocalDateTime.parse(slotStr); + } catch (Exception e) { + return Map.of("code", 400, "message", "slotStart 格式无效"); + } + return scheduleService.createBlock(storeId, slotStart, blockType, note, createdBy); + } + + @DeleteMapping("/block") + public Map deleteBlock(@RequestParam Long id) { + return scheduleService.deleteBlock(id); + } + + private static Long parseLong(Object o) { + if (o == null) { + return null; + } + if (o instanceof Number n) { + return n.longValue(); + } + try { + return Long.valueOf(o.toString()); + } catch (Exception e) { + return null; + } + } +} diff --git a/src/main/java/com/petstore/entity/ScheduleBlock.java b/src/main/java/com/petstore/entity/ScheduleBlock.java new file mode 100644 index 0000000..d5eea67 --- /dev/null +++ b/src/main/java/com/petstore/entity/ScheduleBlock.java @@ -0,0 +1,52 @@ +package com.petstore.entity; + +import jakarta.persistence.*; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 门店日程手动占用:与线上预约一样占用半小时档,用于到店客或暂停接受预约等。 + */ +@Data +@Entity +@Table( + name = "t_schedule_block", + indexes = { + @Index(name = "idx_sched_block_store_slot", columnList = "store_id,slot_start") + } +) +public class ScheduleBlock { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "store_id", nullable = false) + private Long storeId; + + /** 该半小时档的起始时刻(与预约 appointment_time 对齐规则一致) */ + @Column(name = "slot_start", nullable = false) + private LocalDateTime slotStart; + + /** + * walk_in:到店占用(未走线上预约)
+ * blocked:暂停预约(外出等,线上不可再约该档) + */ + @Column(name = "block_type", nullable = false, length = 32) + private String blockType; + + @Column(length = 500) + private String note; + + @Column(name = "created_by_user_id") + private Long createdByUserId; + + @Column(name = "create_time") + private LocalDateTime createTime; + + @Column(name = "update_time") + private LocalDateTime updateTime; + + @Column(nullable = false, columnDefinition = "TINYINT(1) DEFAULT 0") + private Boolean deleted = false; +} diff --git a/src/main/java/com/petstore/mapper/AppointmentMapper.java b/src/main/java/com/petstore/mapper/AppointmentMapper.java index 8f2f9e4..4137b07 100644 --- a/src/main/java/com/petstore/mapper/AppointmentMapper.java +++ b/src/main/java/com/petstore/mapper/AppointmentMapper.java @@ -32,4 +32,13 @@ public interface AppointmentMapper extends JpaRepository { @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); + + /** 某日门店内有效预约(不含已取消),用于日程视图 */ + @Query("SELECT a FROM Appointment a WHERE a.storeId = :storeId AND a.deleted = false " + + "AND a.appointmentTime >= :start AND a.appointmentTime < :end " + + "AND (a.status IS NULL OR a.status <> 'cancel') ORDER BY a.appointmentTime ASC") + List findActiveByStoreAndDateRange( + @Param("storeId") Long storeId, + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end); } diff --git a/src/main/java/com/petstore/mapper/ScheduleBlockMapper.java b/src/main/java/com/petstore/mapper/ScheduleBlockMapper.java new file mode 100644 index 0000000..486ef32 --- /dev/null +++ b/src/main/java/com/petstore/mapper/ScheduleBlockMapper.java @@ -0,0 +1,29 @@ +package com.petstore.mapper; + +import com.petstore.entity.ScheduleBlock; +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; + +public interface ScheduleBlockMapper extends JpaRepository { + + Optional findByIdAndDeletedFalse(Long id); + + @Query("SELECT b.slotStart FROM ScheduleBlock b WHERE b.storeId = :storeId AND b.deleted = false " + + "AND b.slotStart >= :start AND b.slotStart < :end") + List findOccupiedSlotStarts( + @Param("storeId") Long storeId, + @Param("start") LocalDateTime start, + @Param("end") LocalDateTime end); + + boolean existsByStoreIdAndSlotStartAndDeletedFalse(Long storeId, LocalDateTime slotStart); + + List findByStoreIdAndSlotStartGreaterThanEqualAndSlotStartBeforeAndDeletedFalseOrderBySlotStartAsc( + Long storeId, + LocalDateTime start, + LocalDateTime endExclusive); +} diff --git a/src/main/java/com/petstore/service/AppointmentService.java b/src/main/java/com/petstore/service/AppointmentService.java index e7440ea..ff0e10e 100644 --- a/src/main/java/com/petstore/service/AppointmentService.java +++ b/src/main/java/com/petstore/service/AppointmentService.java @@ -2,6 +2,7 @@ package com.petstore.service; import com.petstore.entity.Appointment; import com.petstore.mapper.AppointmentMapper; +import com.petstore.mapper.ScheduleBlockMapper; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -24,6 +25,7 @@ import java.util.Set; @RequiredArgsConstructor public class AppointmentService { private final AppointmentMapper appointmentMapper; + private final ScheduleBlockMapper scheduleBlockMapper; private final StoreService storeService; // 员工查看自己的预约 @@ -89,6 +91,10 @@ public class AppointmentService { for (LocalDateTime t : occupiedRaw) { occupied.add(AppointmentSlotSupport.alignToHalfHour(t)); } + List blockSlots = scheduleBlockMapper.findOccupiedSlotStarts(storeId, dayStart, dayEnd); + for (LocalDateTime t : blockSlots) { + occupied.add(AppointmentSlotSupport.alignToHalfHour(t)); + } LocalDateTime now = LocalDateTime.now(); List> rows = new ArrayList<>(); @@ -101,7 +107,7 @@ public class AppointmentService { if (past) { reason = "已过时段"; } else if (taken) { - reason = "已约满"; + reason = "已占用"; } Map one = new LinkedHashMap<>(); one.put("time", hhmm); @@ -157,6 +163,9 @@ public class AppointmentService { if (appointmentMapper.existsActiveBookingAt(appointment.getStoreId(), t)) { return Map.of("code", 409, "message", "该时段已被占用,请更换其它时段"); } + if (scheduleBlockMapper.existsByStoreIdAndSlotStartAndDeletedFalse(appointment.getStoreId(), t)) { + return Map.of("code", 409, "message", "该时段已被占用,请更换其它时段"); + } appointment.setCreateTime(LocalDateTime.now()); appointment.setUpdateTime(LocalDateTime.now()); appointment.setDeleted(false); diff --git a/src/main/java/com/petstore/service/ScheduleService.java b/src/main/java/com/petstore/service/ScheduleService.java new file mode 100644 index 0000000..95023dc --- /dev/null +++ b/src/main/java/com/petstore/service/ScheduleService.java @@ -0,0 +1,174 @@ +package com.petstore.service; + +import com.petstore.entity.Appointment; +import com.petstore.entity.ScheduleBlock; +import com.petstore.entity.Store; +import com.petstore.mapper.AppointmentMapper; +import com.petstore.mapper.ScheduleBlockMapper; +import lombok.RequiredArgsConstructor; +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.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class ScheduleService { + + private final ScheduleBlockMapper scheduleBlockMapper; + private final AppointmentMapper appointmentMapper; + private final StoreService storeService; + + public static boolean isAllowedBlockType(String blockType) { + return "walk_in".equals(blockType) || "blocked".equals(blockType); + } + + /** + * 某日门店日程:按营业时间生成半点行,挂载预约或手动占用。 + */ + public Map dayAgenda(Long storeId, LocalDate date) { + if (storeId == null || date == null) { + return Map.of("code", 400, "message", "缺少门店或日期"); + } + Store store = storeService.findById(storeId); + if (store == null) { + return Map.of("code", 404, "message", "门店不存在"); + } + StoreBookingWindow window = StoreBookingWindow.fromStore(store); + LocalDateTime rangeStart = date.atStartOfDay(); + LocalDateTime rangeEnd = date.plusDays(1).atStartOfDay(); + + List appointments = appointmentMapper.findActiveByStoreAndDateRange(storeId, rangeStart, rangeEnd); + Map apptBySlot = new LinkedHashMap<>(); + for (Appointment a : appointments) { + LocalDateTime slot = AppointmentSlotSupport.alignToHalfHour(a.getAppointmentTime()); + if (slot != null) { + apptBySlot.putIfAbsent(slot, a); + } + } + + List blocks = + scheduleBlockMapper.findByStoreIdAndSlotStartGreaterThanEqualAndSlotStartBeforeAndDeletedFalseOrderBySlotStartAsc( + storeId, rangeStart, rangeEnd); + Map blockBySlot = new LinkedHashMap<>(); + for (ScheduleBlock b : blocks) { + LocalDateTime slot = AppointmentSlotSupport.alignToHalfHour(b.getSlotStart()); + if (slot != null) { + blockBySlot.putIfAbsent(slot, b); + } + } + + LocalDateTime now = LocalDateTime.now(); + List> rows = new ArrayList<>(); + for (LocalDateTime slotStart : AppointmentSlotSupport.allSlotStartsOnDay(date, window)) { + Map row = new LinkedHashMap<>(); + row.put("time", formatHhMm(slotStart)); + row.put("slotStart", slotStart.toString()); + row.put("past", slotStart.isBefore(now)); + + Appointment appt = apptBySlot.get(slotStart); + ScheduleBlock block = blockBySlot.get(slotStart); + if (appt != null) { + row.put("kind", "appointment"); + row.put("appointment", appt); + row.put("block", null); + } else if (block != null) { + row.put("kind", "block"); + row.put("appointment", null); + row.put("block", block); + } else { + row.put("kind", "empty"); + row.put("appointment", null); + row.put("block", null); + } + rows.add(row); + } + + Map data = new HashMap<>(); + data.put("date", date.toString()); + data.put("dayStart", window.dayStart().toString()); + data.put("lastSlotStart", window.lastSlotStart().toString()); + data.put("rows", rows); + return Map.of("code", 200, "data", data); + } + + @Transactional + public Map createBlock( + Long storeId, + LocalDateTime slotStartRaw, + String blockType, + String note, + Long createdByUserId) { + if (storeId == null) { + return Map.of("code", 400, "message", "缺少门店"); + } + if (slotStartRaw == null) { + return Map.of("code", 400, "message", "缺少时段"); + } + if (!isAllowedBlockType(blockType)) { + return Map.of("code", 400, "message", "blockType 须为 walk_in 或 blocked"); + } + Store store = storeService.findById(storeId); + if (store == null) { + return Map.of("code", 404, "message", "门店不存在"); + } + LocalDateTime slot = AppointmentSlotSupport.alignToHalfHour(slotStartRaw); + StoreBookingWindow window = StoreBookingWindow.fromStore(store); + if (!AppointmentSlotSupport.isWithinBookableWindow(slot, window)) { + return Map.of( + "code", + 400, + "message", + String.format("仅可在 %s~%s 的半点档内占用", window.dayStart(), window.lastSlotStart()) + ); + } + if (appointmentMapper.existsActiveBookingAt(storeId, slot)) { + return Map.of("code", 409, "message", "该时段已有客户预约,无法占用"); + } + if (scheduleBlockMapper.existsByStoreIdAndSlotStartAndDeletedFalse(storeId, slot)) { + return Map.of("code", 409, "message", "该时段已被占用"); + } + + ScheduleBlock b = new ScheduleBlock(); + b.setStoreId(storeId); + b.setSlotStart(slot); + b.setBlockType(blockType); + b.setNote(note != null && !note.isBlank() ? note.trim() : null); + b.setCreatedByUserId(createdByUserId); + LocalDateTime n = LocalDateTime.now(); + b.setCreateTime(n); + b.setUpdateTime(n); + b.setDeleted(false); + ScheduleBlock saved = scheduleBlockMapper.save(b); + Map ok = new HashMap<>(); + ok.put("code", 200); + ok.put("message", "已占用该时段"); + ok.put("data", saved); + return ok; + } + + @Transactional + public Map deleteBlock(Long id) { + if (id == null) { + return Map.of("code", 400, "message", "缺少 id"); + } + ScheduleBlock b = scheduleBlockMapper.findByIdAndDeletedFalse(id).orElse(null); + if (b == null) { + return Map.of("code", 404, "message", "记录不存在"); + } + b.setDeleted(true); + b.setUpdateTime(LocalDateTime.now()); + scheduleBlockMapper.save(b); + return Map.of("code", 200, "message", "已取消占用"); + } + + private static String formatHhMm(LocalDateTime dt) { + return String.format("%02d:%02d", dt.getHour(), dt.getMinute()); + } +}