feat: 新增排班时段管理后端接口
This commit is contained in:
parent
418dbb5801
commit
f6679fd11a
@ -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>
|
||||||
|
* slotStart:ISO 本地时间,如 2026-04-17T10:00:00<br>
|
||||||
|
* blockType:walk_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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/main/java/com/petstore/entity/ScheduleBlock.java
Normal file
52
src/main/java/com/petstore/entity/ScheduleBlock.java
Normal 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;
|
||||||
|
}
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
29
src/main/java/com/petstore/mapper/ScheduleBlockMapper.java
Normal file
29
src/main/java/com/petstore/mapper/ScheduleBlockMapper.java
Normal 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);
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
|||||||
174
src/main/java/com/petstore/service/ScheduleService.java
Normal file
174
src/main/java/com/petstore/service/ScheduleService.java
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user