feat: 新增排班时段管理后端接口

This commit is contained in:
MaDaLei 2026-04-17 12:26:37 +08:00
parent 418dbb5801
commit f6679fd11a
6 changed files with 346 additions and 1 deletions

View File

@ -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<String, Object> 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);
}
/**
* 手动占用半小时档<br>
* slotStartISO 本地时间 2026-04-17T10:00:00<br>
* blockTypewalk_in到店 / blocked暂停线上预约<br>
* createdByUserId当前登录用户 id与前端会话一致即可
*/
@PostMapping("/block")
public Map<String, Object> createBlock(@RequestBody Map<String, Object> 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<String, Object> 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;
}
}
}

View File

@ -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到店占用未走线上预约<br>
* 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;
}

View File

@ -32,4 +32,13 @@ public interface AppointmentMapper extends JpaRepository<Appointment, Long> {
@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')") @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); 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<Appointment> findActiveByStoreAndDateRange(
@Param("storeId") Long storeId,
@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end);
} }

View File

@ -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<ScheduleBlock, Long> {
Optional<ScheduleBlock> 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<LocalDateTime> findOccupiedSlotStarts(
@Param("storeId") Long storeId,
@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end);
boolean existsByStoreIdAndSlotStartAndDeletedFalse(Long storeId, LocalDateTime slotStart);
List<ScheduleBlock> findByStoreIdAndSlotStartGreaterThanEqualAndSlotStartBeforeAndDeletedFalseOrderBySlotStartAsc(
Long storeId,
LocalDateTime start,
LocalDateTime endExclusive);
}

View File

@ -2,6 +2,7 @@ package com.petstore.service;
import com.petstore.entity.Appointment; import com.petstore.entity.Appointment;
import com.petstore.mapper.AppointmentMapper; import com.petstore.mapper.AppointmentMapper;
import com.petstore.mapper.ScheduleBlockMapper;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.PageRequest;
@ -24,6 +25,7 @@ import java.util.Set;
@RequiredArgsConstructor @RequiredArgsConstructor
public class AppointmentService { public class AppointmentService {
private final AppointmentMapper appointmentMapper; private final AppointmentMapper appointmentMapper;
private final ScheduleBlockMapper scheduleBlockMapper;
private final StoreService storeService; private final StoreService storeService;
// 员工查看自己的预约 // 员工查看自己的预约
@ -89,6 +91,10 @@ public class AppointmentService {
for (LocalDateTime t : occupiedRaw) { for (LocalDateTime t : occupiedRaw) {
occupied.add(AppointmentSlotSupport.alignToHalfHour(t)); occupied.add(AppointmentSlotSupport.alignToHalfHour(t));
} }
List<LocalDateTime> blockSlots = scheduleBlockMapper.findOccupiedSlotStarts(storeId, dayStart, dayEnd);
for (LocalDateTime t : blockSlots) {
occupied.add(AppointmentSlotSupport.alignToHalfHour(t));
}
LocalDateTime now = LocalDateTime.now(); LocalDateTime now = LocalDateTime.now();
List<Map<String, Object>> rows = new ArrayList<>(); List<Map<String, Object>> rows = new ArrayList<>();
@ -101,7 +107,7 @@ public class AppointmentService {
if (past) { if (past) {
reason = "已过时段"; reason = "已过时段";
} else if (taken) { } else if (taken) {
reason = "约满"; reason = "占用";
} }
Map<String, Object> one = new LinkedHashMap<>(); Map<String, Object> one = new LinkedHashMap<>();
one.put("time", hhmm); one.put("time", hhmm);
@ -157,6 +163,9 @@ public class AppointmentService {
if (appointmentMapper.existsActiveBookingAt(appointment.getStoreId(), t)) { if (appointmentMapper.existsActiveBookingAt(appointment.getStoreId(), t)) {
return Map.of("code", 409, "message", "该时段已被占用,请更换其它时段"); return Map.of("code", 409, "message", "该时段已被占用,请更换其它时段");
} }
if (scheduleBlockMapper.existsByStoreIdAndSlotStartAndDeletedFalse(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);

View File

@ -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<String, Object> 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<Appointment> appointments = appointmentMapper.findActiveByStoreAndDateRange(storeId, rangeStart, rangeEnd);
Map<LocalDateTime, Appointment> apptBySlot = new LinkedHashMap<>();
for (Appointment a : appointments) {
LocalDateTime slot = AppointmentSlotSupport.alignToHalfHour(a.getAppointmentTime());
if (slot != null) {
apptBySlot.putIfAbsent(slot, a);
}
}
List<ScheduleBlock> blocks =
scheduleBlockMapper.findByStoreIdAndSlotStartGreaterThanEqualAndSlotStartBeforeAndDeletedFalseOrderBySlotStartAsc(
storeId, rangeStart, rangeEnd);
Map<LocalDateTime, ScheduleBlock> 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<Map<String, Object>> rows = new ArrayList<>();
for (LocalDateTime slotStart : AppointmentSlotSupport.allSlotStartsOnDay(date, window)) {
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> ok = new HashMap<>();
ok.put("code", 200);
ok.put("message", "已占用该时段");
ok.put("data", saved);
return ok;
}
@Transactional
public Map<String, Object> 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());
}
}