feat: add real booking capacity
This commit is contained in:
parent
b0149ff0eb
commit
b6138475f0
54
db/migrations/20260801_create_booking_capacity.sql
Normal file
54
db/migrations/20260801_create_booking_capacity.sql
Normal file
@ -0,0 +1,54 @@
|
||||
-- Petstore Phase 0:真实预约容量模型
|
||||
-- 前置:20260801_split_service_identity.sql、20260801_create_store_customer.sql、
|
||||
-- 20260801_create_business_event.sql 已执行。
|
||||
|
||||
ALTER TABLE t_service_type
|
||||
ADD COLUMN duration_minutes INT NOT NULL DEFAULT 60 COMMENT '预计服务时长,30分钟粒度';
|
||||
|
||||
ALTER TABLE t_appointment
|
||||
ADD COLUMN duration_minutes INT NOT NULL DEFAULT 60 COMMENT '下单时服务时长快照';
|
||||
|
||||
ALTER TABLE t_schedule_block
|
||||
ADD COLUMN duration_minutes INT NOT NULL DEFAULT 30 COMMENT '连续占用时长,30分钟粒度';
|
||||
|
||||
ALTER TABLE t_store
|
||||
ADD COLUMN booking_capacity INT NOT NULL DEFAULT 1 COMMENT '门店并发接待数';
|
||||
|
||||
-- 系统默认服务时长;未知服务保留 60 分钟安全值。
|
||||
UPDATE t_service_type
|
||||
SET duration_minutes = CASE name
|
||||
WHEN '洗澡' THEN 60
|
||||
WHEN '美容' THEN 120
|
||||
WHEN '洗澡+美容' THEN 150
|
||||
WHEN '剪指甲' THEN 30
|
||||
WHEN '驱虫' THEN 30
|
||||
ELSE 60
|
||||
END;
|
||||
|
||||
-- 历史预约形成时没有时长快照,只能按当时服务名称采用稳定默认值回填。
|
||||
UPDATE t_appointment
|
||||
SET duration_minutes = CASE service_type
|
||||
WHEN '洗澡' THEN 60
|
||||
WHEN '美容' THEN 120
|
||||
WHEN '洗澡+美容' THEN 150
|
||||
WHEN '剪指甲' THEN 30
|
||||
WHEN '驱虫' THEN 30
|
||||
ELSE 60
|
||||
END;
|
||||
|
||||
-- 验证:以下四项必须全部为 0。
|
||||
SELECT COUNT(*) AS invalid_service_duration_count
|
||||
FROM t_service_type
|
||||
WHERE duration_minutes < 30 OR duration_minutes > 480 OR MOD(duration_minutes, 30) <> 0;
|
||||
|
||||
SELECT COUNT(*) AS invalid_appointment_duration_count
|
||||
FROM t_appointment
|
||||
WHERE duration_minutes < 30 OR duration_minutes > 480 OR MOD(duration_minutes, 30) <> 0;
|
||||
|
||||
SELECT COUNT(*) AS invalid_block_duration_count
|
||||
FROM t_schedule_block
|
||||
WHERE duration_minutes < 30 OR duration_minutes > 480 OR MOD(duration_minutes, 30) <> 0;
|
||||
|
||||
SELECT COUNT(*) AS invalid_store_capacity_count
|
||||
FROM t_store
|
||||
WHERE booking_capacity < 1 OR booking_capacity > 10;
|
||||
@ -14,4 +14,6 @@
|
||||
|
||||
`20260801_create_business_event.sql` 必须在客户主档迁移之后执行:建立不可变 `t_business_event`,回填预约创建、报告提交、可确认的服务完成和留资提交。脚本末尾四个验证计数必须为 0;脚本不回填无法从数据库可靠恢复的报告打开与服务开始事件。
|
||||
|
||||
`20260801_create_booking_capacity.sql` 必须在业务事件迁移之后执行:为服务项目、预约快照和手动占用补充时长,为门店补充并发接待数。历史预约按服务名称采用稳定默认值回填;脚本末尾四个验证计数必须为 0。
|
||||
|
||||
正式迁移框架仍属于 Phase 0 后续任务;接入前不得把本目录误当成自动执行目录。
|
||||
|
||||
@ -48,6 +48,7 @@ public class AdminStoreController {
|
||||
data.put("bookingDayStart", store.getBookingDayStart() == null ? null : store.getBookingDayStart().toString());
|
||||
data.put("bookingLastSlotStart",
|
||||
store.getBookingLastSlotStart() == null ? null : store.getBookingLastSlotStart().toString());
|
||||
data.put("bookingCapacity", StoreService.normalizeCapacity(store.getBookingCapacity()));
|
||||
return Map.of("code", 200, "data", data);
|
||||
}
|
||||
}
|
||||
|
||||
@ -23,18 +23,21 @@ public class AppointmentController {
|
||||
private final UserService userService;
|
||||
|
||||
/**
|
||||
* 门店某日可预约时段(半小时一档,每档最多一单)。
|
||||
* 门店某日可预约开始时段;按服务时长与门店并发接待数计算。
|
||||
* date 格式:yyyy-MM-dd。公开接口,无需登录。
|
||||
*/
|
||||
@GetMapping("/available-slots")
|
||||
public Map<String, Object> availableSlots(@RequestParam Long storeId, @RequestParam String date) {
|
||||
public Map<String, Object> availableSlots(
|
||||
@RequestParam Long storeId,
|
||||
@RequestParam String date,
|
||||
@RequestParam(required = false) String serviceType) {
|
||||
LocalDate d;
|
||||
try {
|
||||
d = LocalDate.parse(date);
|
||||
} catch (Exception e) {
|
||||
return Map.of("code", 400, "message", "日期格式应为 yyyy-MM-dd");
|
||||
}
|
||||
return appointmentService.availableSlots(storeId, d);
|
||||
return appointmentService.availableSlots(storeId, d, serviceType);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -38,7 +38,7 @@ public class ScheduleController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动占用半小时档。仅 boss/staff;storeId 与 createdByUserId 从上下文派生。
|
||||
* 手动占用一个或多个连续半小时档。仅 boss/staff;storeId 与 createdByUserId 从上下文派生。
|
||||
* <br>slotStart:ISO 本地时间,如 2026-04-17T10:00:00
|
||||
* <br>blockType:walk_in(到店) / blocked(暂停线上预约)
|
||||
*/
|
||||
@ -51,6 +51,14 @@ public class ScheduleController {
|
||||
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;
|
||||
Integer durationMinutes;
|
||||
try {
|
||||
durationMinutes = body.get("durationMinutes") == null
|
||||
? 30
|
||||
: Integer.valueOf(body.get("durationMinutes").toString());
|
||||
} catch (NumberFormatException e) {
|
||||
return Map.of("code", 400, "message", "durationMinutes 格式无效");
|
||||
}
|
||||
|
||||
LocalDateTime slotStart;
|
||||
try {
|
||||
@ -59,7 +67,7 @@ public class ScheduleController {
|
||||
return Map.of("code", 400, "message", "slotStart 格式无效");
|
||||
}
|
||||
// storeId / createdByUserId 从上下文派生,不信任请求体
|
||||
return scheduleService.createBlock(u.storeId(), slotStart, blockType, note, u.userId());
|
||||
return scheduleService.createBlock(u.storeId(), slotStart, durationMinutes, blockType, note, u.userId());
|
||||
}
|
||||
|
||||
/** 取消手动占用:仅 boss/staff 且同店(由 service 校验 storeId 匹配)。 */
|
||||
|
||||
@ -36,9 +36,14 @@ public class ServiceTypeController {
|
||||
if (!u.isBoss() || u.storeId() == null) {
|
||||
return Map.of("code", 403, "message", "仅老板可新增服务类型", "bizCode", "FORBIDDEN");
|
||||
}
|
||||
String name = params.get("name").toString();
|
||||
ServiceType created = serviceTypeService.create(u.storeId(), name);
|
||||
try {
|
||||
String name = params.get("name") == null ? null : params.get("name").toString();
|
||||
Integer durationMinutes = integerParam(params.get("durationMinutes"));
|
||||
ServiceType created = serviceTypeService.create(u.storeId(), name, durationMinutes);
|
||||
return Map.of("code", 200, "data", created);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Map.of("code", 400, "message", e.getMessage(), "bizCode", "INVALID_SERVICE_TYPE");
|
||||
}
|
||||
}
|
||||
|
||||
/** 编辑服务类型:仅 boss;必须为本店自定义类型(系统默认 storeId 为空不可改)。 */
|
||||
@ -56,9 +61,14 @@ public class ServiceTypeController {
|
||||
if (existing.getStoreId() == null || !existing.getStoreId().equals(u.storeId())) {
|
||||
return Map.of("code", 403, "message", "无权修改系统默认或他店服务类型", "bizCode", "FORBIDDEN");
|
||||
}
|
||||
String name = params.get("name").toString();
|
||||
ServiceType updated = serviceTypeService.update(id, name);
|
||||
try {
|
||||
String name = params.get("name") == null ? null : params.get("name").toString();
|
||||
Integer durationMinutes = integerParam(params.get("durationMinutes"));
|
||||
ServiceType updated = serviceTypeService.update(id, name, durationMinutes);
|
||||
return Map.of("code", 200, "data", updated);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Map.of("code", 400, "message", e.getMessage(), "bizCode", "INVALID_SERVICE_TYPE");
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除服务类型:仅 boss;必须为本店自定义类型(系统默认不可删)。 */
|
||||
@ -89,4 +99,15 @@ public class ServiceTypeController {
|
||||
serviceTypeService.initDefaults();
|
||||
return Map.of("code", 200, "message", "初始化成功");
|
||||
}
|
||||
|
||||
private static Integer integerParam(Object value) {
|
||||
if (value == null || value.toString().isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Integer.valueOf(value.toString());
|
||||
} catch (NumberFormatException e) {
|
||||
throw new IllegalArgumentException("服务时长格式不正确");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -75,6 +75,7 @@ public class StoreController {
|
||||
m.put("intro", s.getIntro());
|
||||
m.put("bookingDayStart", s.getBookingDayStart());
|
||||
m.put("bookingLastSlotStart", s.getBookingLastSlotStart());
|
||||
m.put("bookingCapacity", StoreService.normalizeCapacity(s.getBookingCapacity()));
|
||||
return m;
|
||||
}
|
||||
|
||||
@ -90,7 +91,12 @@ public class StoreController {
|
||||
if (store == null || store.getId() == null || !store.getId().equals(u.storeId())) {
|
||||
return Map.of("code", 403, "message", "无权修改他店信息", "bizCode", "FORBIDDEN");
|
||||
}
|
||||
Store updated = storeService.update(store);
|
||||
Store updated;
|
||||
try {
|
||||
updated = storeService.update(store);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Map.of("code", 400, "message", e.getMessage(), "bizCode", "INVALID_STORE_CONFIG");
|
||||
}
|
||||
if (updated == null) {
|
||||
return Map.of("code", 404, "message", "店铺不存在");
|
||||
}
|
||||
|
||||
@ -26,6 +26,23 @@ public class Appointment {
|
||||
private String serviceType;
|
||||
private LocalDateTime appointmentTime;
|
||||
|
||||
/** 下单时的服务时长快照;后续修改服务项目不会改变已预约占用。 */
|
||||
@Column(name = "duration_minutes", nullable = false)
|
||||
private Integer durationMinutes = 60;
|
||||
|
||||
/** 预约预计结束时间,由开始时间与时长快照派生。 */
|
||||
@Transient
|
||||
@JsonProperty("endTime")
|
||||
public LocalDateTime getEndTime() {
|
||||
return appointmentTime == null ? null : appointmentTime.plusMinutes(resolvedDurationMinutes());
|
||||
}
|
||||
|
||||
@Transient
|
||||
@JsonIgnore
|
||||
public int resolvedDurationMinutes() {
|
||||
return durationMinutes == null || durationMinutes <= 0 ? 60 : durationMinutes;
|
||||
}
|
||||
|
||||
/** 状态: new/doing/done/cancel */
|
||||
private String status;
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@ import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 门店日程手动占用:与线上预约一样占用半小时档,用于到店客或暂停接受预约等。
|
||||
* 门店日程手动占用:以半小时为粒度,可连续占用多个档,用于到店客或暂停接受预约等。
|
||||
*/
|
||||
@Data
|
||||
@Entity
|
||||
@ -28,6 +28,16 @@ public class ScheduleBlock {
|
||||
@Column(name = "slot_start", nullable = false)
|
||||
private LocalDateTime slotStart;
|
||||
|
||||
/** 连续占用时长,30~480 分钟且必须为 30 的倍数。 */
|
||||
@Column(name = "duration_minutes", nullable = false)
|
||||
private Integer durationMinutes = 30;
|
||||
|
||||
@Transient
|
||||
public LocalDateTime getEndTime() {
|
||||
int minutes = durationMinutes == null || durationMinutes <= 0 ? 30 : durationMinutes;
|
||||
return slotStart == null ? null : slotStart.plusMinutes(minutes);
|
||||
}
|
||||
|
||||
/**
|
||||
* walk_in:到店占用(未走线上预约)<br>
|
||||
* blocked:暂停预约(外出等,线上不可再约该档)
|
||||
|
||||
@ -22,6 +22,10 @@ public class ServiceType {
|
||||
|
||||
private String name;
|
||||
|
||||
/** 预计服务时长,按 30 分钟粒度配置。 */
|
||||
@Column(name = "duration_minutes", nullable = false)
|
||||
private Integer durationMinutes = 60;
|
||||
|
||||
@Column(name = "create_time")
|
||||
private LocalDateTime createTime;
|
||||
}
|
||||
|
||||
@ -50,6 +50,10 @@ public class Store {
|
||||
@Column(name = "booking_last_slot_start")
|
||||
private LocalTime bookingLastSlotStart;
|
||||
|
||||
/** 同一半小时内可并行服务的顾客数,默认 1。 */
|
||||
@Column(name = "booking_capacity", nullable = false)
|
||||
private Integer bookingCapacity;
|
||||
|
||||
@Column(name = "create_time")
|
||||
private LocalDateTime createTime;
|
||||
|
||||
|
||||
@ -2,6 +2,11 @@ package com.petstore.mapper;
|
||||
|
||||
import com.petstore.entity.Store;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Lock;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
import jakarta.persistence.LockModeType;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@ -10,4 +15,9 @@ public interface StoreMapper extends JpaRepository<Store, Long> {
|
||||
Store findByInviteCodeAndDeletedFalse(String inviteCode);
|
||||
Optional<Store> findByIdAndDeletedFalse(Long id);
|
||||
List<Store> findAllByDeletedFalse();
|
||||
|
||||
/** 创建预约/占用时串行化同一门店的容量判定,避免并发超卖。 */
|
||||
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||
@Query("SELECT s FROM Store s WHERE s.id = :id AND s.deleted = false")
|
||||
Optional<Store> findByIdAndDeletedFalseForUpdate(@Param("id") Long id);
|
||||
}
|
||||
|
||||
@ -41,6 +41,8 @@ public class AppointmentService {
|
||||
private final PetMapper petMapper;
|
||||
private final StoreCustomerService storeCustomerService;
|
||||
private final BusinessEventService businessEventService;
|
||||
private final ServiceTypeService serviceTypeService;
|
||||
private final BookingCapacityService bookingCapacityService;
|
||||
|
||||
// 宠主查看归属于自己的预约;兼容 customer_user_id 尚未回填的旧数据。
|
||||
public List<Appointment> getByUserId(Long customerUserId) {
|
||||
@ -89,7 +91,7 @@ public class AppointmentService {
|
||||
/**
|
||||
* 某门店某日:半小时预约档列表(已占用 / 已过 / 可约)。
|
||||
*/
|
||||
public Map<String, Object> availableSlots(Long storeId, LocalDate date) {
|
||||
public Map<String, Object> availableSlots(Long storeId, LocalDate date, String serviceType) {
|
||||
if (storeId == null || date == null) {
|
||||
return Map.of("code", 400, "message", "缺少门店或日期");
|
||||
}
|
||||
@ -98,34 +100,44 @@ public class AppointmentService {
|
||||
return Map.of("code", 404, "message", "门店不存在");
|
||||
}
|
||||
StoreBookingWindow window = StoreBookingWindow.fromStore(store);
|
||||
com.petstore.entity.ServiceType resolvedService = serviceTypeService.resolveForStore(storeId, serviceType);
|
||||
if (serviceType != null && !serviceType.isBlank() && resolvedService == null) {
|
||||
return Map.of("code", 400, "message", "服务项目不存在或不属于该门店", "bizCode", "SERVICE_TYPE_INVALID");
|
||||
}
|
||||
int durationMinutes = resolvedService == null
|
||||
? BookingDurationSupport.DEFAULT_SERVICE_MINUTES
|
||||
: BookingDurationSupport.serviceMinutes(resolvedService.getDurationMinutes());
|
||||
int capacity = StoreService.normalizeCapacity(store.getBookingCapacity());
|
||||
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));
|
||||
}
|
||||
List<LocalDateTime> blockSlots = scheduleBlockMapper.findOccupiedSlotStarts(storeId, dayStart, dayEnd);
|
||||
for (LocalDateTime t : blockSlots) {
|
||||
occupied.add(AppointmentSlotSupport.alignToHalfHour(t));
|
||||
}
|
||||
List<Appointment> appointments = appointmentMapper.findActiveByStoreAndDateRange(storeId, dayStart, dayEnd);
|
||||
List<com.petstore.entity.ScheduleBlock> blocks =
|
||||
scheduleBlockMapper.findByStoreIdAndSlotStartGreaterThanEqualAndSlotStartBeforeAndDeletedFalseOrderBySlotStartAsc(
|
||||
storeId, dayStart, dayEnd);
|
||||
|
||||
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;
|
||||
boolean withinWindow = AppointmentSlotSupport.isWithinBookableWindow(slotStart, durationMinutes, window);
|
||||
BookingCapacityService.CapacityResult capacityResult = bookingCapacityService.evaluateAppointment(
|
||||
slotStart, durationMinutes, capacity, appointments, blocks);
|
||||
boolean available = !past && withinWindow && capacityResult.available();
|
||||
String reason = null;
|
||||
if (past) {
|
||||
reason = "已过时段";
|
||||
} else if (taken) {
|
||||
reason = "已占用";
|
||||
} else if (!withinWindow) {
|
||||
reason = "服务将在营业结束后完成";
|
||||
} else if (!capacityResult.available()) {
|
||||
reason = capacityResult.reason();
|
||||
}
|
||||
Map<String, Object> one = new LinkedHashMap<>();
|
||||
one.put("time", hhmm);
|
||||
one.put("available", available);
|
||||
one.put("endTime", formatHhMm(slotStart.plusMinutes(durationMinutes)));
|
||||
one.put("usedCapacity", capacityResult.peakUsed());
|
||||
one.put("totalCapacity", capacity);
|
||||
if (reason != null) {
|
||||
one.put("reason", reason);
|
||||
}
|
||||
@ -135,6 +147,10 @@ public class AppointmentService {
|
||||
data.put("slots", rows);
|
||||
data.put("dayStart", window.dayStart().toString());
|
||||
data.put("lastSlotStart", window.lastSlotStart().toString());
|
||||
data.put("closingTime", formatHhMm(window.closingTime(date.atStartOfDay())));
|
||||
data.put("durationMinutes", durationMinutes);
|
||||
data.put("bookingCapacity", capacity);
|
||||
data.put("serviceType", resolvedService == null ? serviceType : resolvedService.getName());
|
||||
return Map.of("code", 200, "data", data);
|
||||
}
|
||||
|
||||
@ -143,7 +159,7 @@ public class AppointmentService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建预约:半小时占号,取消({@code cancel})不占号。
|
||||
* 创建预约:按服务时长跨半小时容量桶占号,取消({@code cancel})不占号。
|
||||
*/
|
||||
@Transactional
|
||||
public Map<String, Object> createBooking(Appointment appointment) {
|
||||
@ -175,30 +191,49 @@ public class AppointmentService {
|
||||
return Map.of("code", 403, "message", "宠物档案不属于该宠主", "bizCode", "PET_CUSTOMER_MISMATCH");
|
||||
}
|
||||
}
|
||||
com.petstore.entity.Store store = storeService.findById(appointment.getStoreId());
|
||||
com.petstore.entity.Store store = storeService.findByIdForUpdate(appointment.getStoreId());
|
||||
if (store == null) {
|
||||
return Map.of("code", 404, "message", "门店不存在");
|
||||
}
|
||||
com.petstore.entity.ServiceType resolvedService =
|
||||
serviceTypeService.resolveForStore(appointment.getStoreId(), appointment.getServiceType());
|
||||
if (resolvedService == null) {
|
||||
return Map.of("code", 400, "message", "请选择本店有效的服务项目", "bizCode", "SERVICE_TYPE_INVALID");
|
||||
}
|
||||
int durationMinutes = BookingDurationSupport.serviceMinutes(resolvedService.getDurationMinutes());
|
||||
appointment.setServiceType(resolvedService.getName());
|
||||
appointment.setDurationMinutes(durationMinutes);
|
||||
StoreBookingWindow window = StoreBookingWindow.fromStore(store);
|
||||
if (!AppointmentSlotSupport.isWithinBookableWindow(t, window)) {
|
||||
if (!AppointmentSlotSupport.isWithinBookableWindow(t, durationMinutes, window)) {
|
||||
return Map.of(
|
||||
"code", 400,
|
||||
"message",
|
||||
String.format(
|
||||
"仅可预约 %s~%s 的半点档",
|
||||
"服务须在 %s~%s 的营业容量时段内完成",
|
||||
window.dayStart(),
|
||||
window.lastSlotStart()
|
||||
window.closingTime(t).toLocalTime()
|
||||
)
|
||||
);
|
||||
}
|
||||
if (!t.isAfter(LocalDateTime.now())) {
|
||||
return Map.of("code", 400, "message", "不能预约当前及已过去的时段");
|
||||
}
|
||||
if (appointmentMapper.existsActiveBookingAt(appointment.getStoreId(), t)) {
|
||||
return Map.of("code", 409, "message", "该时段已被占用,请更换其它时段");
|
||||
}
|
||||
if (scheduleBlockMapper.existsByStoreIdAndSlotStartAndDeletedFalse(appointment.getStoreId(), t)) {
|
||||
return Map.of("code", 409, "message", "该时段已被占用,请更换其它时段");
|
||||
LocalDateTime dayStart = t.toLocalDate().atStartOfDay();
|
||||
LocalDateTime dayEnd = t.toLocalDate().plusDays(1).atStartOfDay();
|
||||
List<Appointment> activeAppointments = appointmentMapper.findActiveByStoreAndDateRange(
|
||||
appointment.getStoreId(), dayStart, dayEnd);
|
||||
List<com.petstore.entity.ScheduleBlock> blocks =
|
||||
scheduleBlockMapper.findByStoreIdAndSlotStartGreaterThanEqualAndSlotStartBeforeAndDeletedFalseOrderBySlotStartAsc(
|
||||
appointment.getStoreId(), dayStart, dayEnd);
|
||||
BookingCapacityService.CapacityResult capacityResult = bookingCapacityService.evaluateAppointment(
|
||||
t,
|
||||
durationMinutes,
|
||||
StoreService.normalizeCapacity(store.getBookingCapacity()),
|
||||
activeAppointments,
|
||||
blocks
|
||||
);
|
||||
if (!capacityResult.available()) {
|
||||
return Map.of("code", 409, "message", capacityResult.reason() + ",请更换其它时段", "bizCode", "CAPACITY_FULL");
|
||||
}
|
||||
appointment.setCreateTime(LocalDateTime.now());
|
||||
appointment.setUpdateTime(LocalDateTime.now());
|
||||
@ -406,6 +441,8 @@ public class AppointmentService {
|
||||
row.put("petType", a.getPetType());
|
||||
row.put("serviceType", a.getServiceType());
|
||||
row.put("appointmentTime", a.getAppointmentTime());
|
||||
row.put("endTime", a.getEndTime());
|
||||
row.put("durationMinutes", a.resolvedDurationMinutes());
|
||||
row.put("status", a.getStatus());
|
||||
row.put("storeId", a.getStoreId());
|
||||
row.put("userId", a.getUserId());
|
||||
|
||||
@ -34,6 +34,17 @@ public final class AppointmentSlotSupport {
|
||||
return !t.isBefore(window.dayStart()) && !t.isAfter(window.lastSlotStart());
|
||||
}
|
||||
|
||||
public static boolean isWithinBookableWindow(
|
||||
LocalDateTime slotStart,
|
||||
int durationMinutes,
|
||||
StoreBookingWindow window) {
|
||||
if (!isWithinBookableWindow(slotStart, window)
|
||||
|| !BookingDurationSupport.isValid(durationMinutes)) {
|
||||
return false;
|
||||
}
|
||||
return !slotStart.plusMinutes(durationMinutes).isAfter(window.closingTime(slotStart));
|
||||
}
|
||||
|
||||
/**
|
||||
* 在给定可预约时间窗口内,生成某日所有半点起始时刻(含首尾)。
|
||||
*/
|
||||
|
||||
108
src/main/java/com/petstore/service/BookingCapacityService.java
Normal file
108
src/main/java/com/petstore/service/BookingCapacityService.java
Normal file
@ -0,0 +1,108 @@
|
||||
package com.petstore.service;
|
||||
|
||||
import com.petstore.entity.Appointment;
|
||||
import com.petstore.entity.ScheduleBlock;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 门店级预约容量计算。线上预约尚不预选技师,因此容量口径是门店承诺的并发接待数;
|
||||
* walk_in 消耗一个并发名额,blocked 关闭覆盖范围内的全部名额。
|
||||
*/
|
||||
@Service
|
||||
public class BookingCapacityService {
|
||||
|
||||
public record CapacityResult(boolean available, String reason, int peakUsed) {
|
||||
}
|
||||
|
||||
public CapacityResult evaluateAppointment(
|
||||
LocalDateTime start,
|
||||
int durationMinutes,
|
||||
int capacity,
|
||||
List<Appointment> appointments,
|
||||
List<ScheduleBlock> blocks) {
|
||||
int peak = 0;
|
||||
LocalDateTime end = start.plusMinutes(durationMinutes);
|
||||
for (LocalDateTime bucket = start; bucket.isBefore(end); bucket = bucket.plusMinutes(30)) {
|
||||
LocalDateTime bucketStart = bucket;
|
||||
LocalDateTime bucketEnd = bucketStart.plusMinutes(30);
|
||||
if (blocks.stream().anyMatch(b -> isBlocked(b) && overlaps(
|
||||
b.getSlotStart(), blockEnd(b), bucketStart, bucketEnd))) {
|
||||
return new CapacityResult(false, "门店暂停预约", peak);
|
||||
}
|
||||
int used = (int) appointments.stream()
|
||||
.filter(a -> overlaps(a.getAppointmentTime(), appointmentEnd(a), bucketStart, bucketEnd))
|
||||
.count();
|
||||
used += (int) blocks.stream()
|
||||
.filter(this::isWalkIn)
|
||||
.filter(b -> overlaps(b.getSlotStart(), blockEnd(b), bucketStart, bucketEnd))
|
||||
.count();
|
||||
peak = Math.max(peak, used);
|
||||
if (used >= capacity) {
|
||||
return new CapacityResult(false, "接待容量已满", peak);
|
||||
}
|
||||
}
|
||||
return new CapacityResult(true, null, peak);
|
||||
}
|
||||
|
||||
public boolean hasAnyOccupancy(
|
||||
LocalDateTime start,
|
||||
int durationMinutes,
|
||||
List<Appointment> appointments,
|
||||
List<ScheduleBlock> blocks) {
|
||||
LocalDateTime end = start.plusMinutes(durationMinutes);
|
||||
return appointments.stream().anyMatch(a -> overlaps(
|
||||
a.getAppointmentTime(), appointmentEnd(a), start, end))
|
||||
|| blocks.stream().anyMatch(b -> overlaps(
|
||||
b.getSlotStart(), blockEnd(b), start, end));
|
||||
}
|
||||
|
||||
public int usedAt(
|
||||
LocalDateTime bucket,
|
||||
List<Appointment> appointments,
|
||||
List<ScheduleBlock> blocks) {
|
||||
LocalDateTime bucketEnd = bucket.plusMinutes(30);
|
||||
int used = (int) appointments.stream()
|
||||
.filter(a -> overlaps(a.getAppointmentTime(), appointmentEnd(a), bucket, bucketEnd))
|
||||
.count();
|
||||
used += (int) blocks.stream()
|
||||
.filter(this::isWalkIn)
|
||||
.filter(b -> overlaps(b.getSlotStart(), blockEnd(b), bucket, bucketEnd))
|
||||
.count();
|
||||
return used;
|
||||
}
|
||||
|
||||
public boolean hasBlackoutAt(LocalDateTime bucket, List<ScheduleBlock> blocks) {
|
||||
LocalDateTime bucketEnd = bucket.plusMinutes(30);
|
||||
return blocks.stream().anyMatch(b -> isBlocked(b) && overlaps(
|
||||
b.getSlotStart(), blockEnd(b), bucket, bucketEnd));
|
||||
}
|
||||
|
||||
public boolean overlaps(LocalDateTime aStart, LocalDateTime aEnd, LocalDateTime bStart, LocalDateTime bEnd) {
|
||||
return aStart != null && aEnd != null && bStart != null && bEnd != null
|
||||
&& aStart.isBefore(bEnd) && bStart.isBefore(aEnd);
|
||||
}
|
||||
|
||||
private LocalDateTime appointmentEnd(Appointment appointment) {
|
||||
return appointment.getAppointmentTime() == null
|
||||
? null
|
||||
: appointment.getAppointmentTime().plusMinutes(appointment.resolvedDurationMinutes());
|
||||
}
|
||||
|
||||
private LocalDateTime blockEnd(ScheduleBlock block) {
|
||||
if (block.getSlotStart() == null) {
|
||||
return null;
|
||||
}
|
||||
return block.getSlotStart().plusMinutes(BookingDurationSupport.blockMinutes(block.getDurationMinutes()));
|
||||
}
|
||||
|
||||
private boolean isWalkIn(ScheduleBlock block) {
|
||||
return "walk_in".equals(block.getBlockType());
|
||||
}
|
||||
|
||||
private boolean isBlocked(ScheduleBlock block) {
|
||||
return "blocked".equals(block.getBlockType());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
package com.petstore.service;
|
||||
|
||||
/** 预约容量模型共用的 30 分钟粒度与时长边界。 */
|
||||
public final class BookingDurationSupport {
|
||||
public static final int SLOT_MINUTES = 30;
|
||||
public static final int DEFAULT_SERVICE_MINUTES = 60;
|
||||
public static final int DEFAULT_BLOCK_MINUTES = 30;
|
||||
public static final int MIN_MINUTES = 30;
|
||||
public static final int MAX_MINUTES = 480;
|
||||
|
||||
private BookingDurationSupport() {
|
||||
}
|
||||
|
||||
public static boolean isValid(Integer minutes) {
|
||||
return minutes != null
|
||||
&& minutes >= MIN_MINUTES
|
||||
&& minutes <= MAX_MINUTES
|
||||
&& minutes % SLOT_MINUTES == 0;
|
||||
}
|
||||
|
||||
public static int serviceMinutes(Integer minutes) {
|
||||
return isValid(minutes) ? minutes : DEFAULT_SERVICE_MINUTES;
|
||||
}
|
||||
|
||||
public static int blockMinutes(Integer minutes) {
|
||||
return isValid(minutes) ? minutes : DEFAULT_BLOCK_MINUTES;
|
||||
}
|
||||
}
|
||||
@ -55,6 +55,9 @@ public class BusinessEventService {
|
||||
&& appointment.getCreatedByUserId().equals(appointment.resolvedCustomerUserId())
|
||||
? "customer"
|
||||
: "admin";
|
||||
Map<String, Object> metadata = new LinkedHashMap<>();
|
||||
metadata.put("bookingOrigin", source);
|
||||
metadata.put("durationMinutes", appointment.resolvedDurationMinutes());
|
||||
return record(new EventCommand(
|
||||
APPOINTMENT_CREATED,
|
||||
appointment.getStoreId(),
|
||||
@ -66,7 +69,7 @@ public class BusinessEventService {
|
||||
source,
|
||||
appointment.getCreateTime(),
|
||||
"appointment_created:" + appointment.getId(),
|
||||
Map.of("bookingOrigin", source)
|
||||
metadata
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@ -24,6 +24,7 @@ public class ScheduleService {
|
||||
private final ScheduleBlockMapper scheduleBlockMapper;
|
||||
private final AppointmentMapper appointmentMapper;
|
||||
private final StoreService storeService;
|
||||
private final BookingCapacityService bookingCapacityService;
|
||||
|
||||
public static boolean isAllowedBlockType(String blockType) {
|
||||
return "walk_in".equals(blockType) || "blocked".equals(blockType);
|
||||
@ -45,26 +46,11 @@ public class ScheduleService {
|
||||
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();
|
||||
int capacity = StoreService.normalizeCapacity(store.getBookingCapacity());
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for (LocalDateTime slotStart : AppointmentSlotSupport.allSlotStartsOnDay(date, window)) {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
@ -72,12 +58,30 @@ public class ScheduleService {
|
||||
row.put("slotStart", slotStart.toString());
|
||||
row.put("past", slotStart.isBefore(now));
|
||||
|
||||
Appointment appt = apptBySlot.get(slotStart);
|
||||
ScheduleBlock block = blockBySlot.get(slotStart);
|
||||
if (appt != null) {
|
||||
LocalDateTime slotEnd = slotStart.plusMinutes(30);
|
||||
List<Appointment> slotAppointments = appointments.stream()
|
||||
.filter(a -> bookingCapacityService.overlaps(
|
||||
a.getAppointmentTime(), a.getEndTime(), slotStart, slotEnd))
|
||||
.toList();
|
||||
List<ScheduleBlock> slotBlocks = blocks.stream()
|
||||
.filter(b -> bookingCapacityService.overlaps(
|
||||
b.getSlotStart(), b.getEndTime(), slotStart, slotEnd))
|
||||
.toList();
|
||||
Appointment appt = slotAppointments.isEmpty() ? null : slotAppointments.get(0);
|
||||
boolean blackout = bookingCapacityService.hasBlackoutAt(slotStart, blocks);
|
||||
ScheduleBlock block = slotBlocks.stream()
|
||||
.filter(b -> blackout && "blocked".equals(b.getBlockType()))
|
||||
.findFirst()
|
||||
.orElse(slotBlocks.isEmpty() ? null : slotBlocks.get(0));
|
||||
int used = bookingCapacityService.usedAt(slotStart, appointments, blocks);
|
||||
if (blackout) {
|
||||
row.put("kind", "block");
|
||||
row.put("appointment", appt);
|
||||
row.put("block", block);
|
||||
} else if (appt != null) {
|
||||
row.put("kind", "appointment");
|
||||
row.put("appointment", appt);
|
||||
row.put("block", null);
|
||||
row.put("block", block);
|
||||
} else if (block != null) {
|
||||
row.put("kind", "block");
|
||||
row.put("appointment", null);
|
||||
@ -87,6 +91,11 @@ public class ScheduleService {
|
||||
row.put("appointment", null);
|
||||
row.put("block", null);
|
||||
}
|
||||
row.put("appointments", slotAppointments);
|
||||
row.put("blocks", slotBlocks);
|
||||
row.put("usedCapacity", used);
|
||||
row.put("totalCapacity", capacity);
|
||||
row.put("remainingCapacity", blackout ? 0 : Math.max(0, capacity - used));
|
||||
rows.add(row);
|
||||
}
|
||||
|
||||
@ -94,6 +103,8 @@ public class ScheduleService {
|
||||
data.put("date", date.toString());
|
||||
data.put("dayStart", window.dayStart().toString());
|
||||
data.put("lastSlotStart", window.lastSlotStart().toString());
|
||||
data.put("closingTime", window.closingTime(date.atStartOfDay()).toLocalTime().toString());
|
||||
data.put("bookingCapacity", capacity);
|
||||
data.put("rows", rows);
|
||||
return Map.of("code", 200, "data", data);
|
||||
}
|
||||
@ -102,6 +113,7 @@ public class ScheduleService {
|
||||
public Map<String, Object> createBlock(
|
||||
Long storeId,
|
||||
LocalDateTime slotStartRaw,
|
||||
Integer durationMinutes,
|
||||
String blockType,
|
||||
String note,
|
||||
Long createdByUserId) {
|
||||
@ -114,30 +126,50 @@ public class ScheduleService {
|
||||
if (!isAllowedBlockType(blockType)) {
|
||||
return Map.of("code", 400, "message", "blockType 须为 walk_in 或 blocked");
|
||||
}
|
||||
Store store = storeService.findById(storeId);
|
||||
if (!BookingDurationSupport.isValid(durationMinutes)) {
|
||||
return Map.of("code", 400, "message", "占用时长须为 30~480 分钟,且为 30 的倍数");
|
||||
}
|
||||
Store store = storeService.findByIdForUpdate(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)) {
|
||||
if (!AppointmentSlotSupport.isWithinBookableWindow(slot, durationMinutes, window)) {
|
||||
return Map.of(
|
||||
"code",
|
||||
400,
|
||||
"message",
|
||||
String.format("仅可在 %s~%s 的半点档内占用", window.dayStart(), window.lastSlotStart())
|
||||
String.format("占用须在 %s~%s 的营业容量时段内完成", window.dayStart(), window.closingTime(slot).toLocalTime())
|
||||
);
|
||||
}
|
||||
if (appointmentMapper.existsActiveBookingAt(storeId, slot)) {
|
||||
return Map.of("code", 409, "message", "该时段已有客户预约,无法占用");
|
||||
LocalDateTime dayStart = slot.toLocalDate().atStartOfDay();
|
||||
LocalDateTime dayEnd = slot.toLocalDate().plusDays(1).atStartOfDay();
|
||||
List<Appointment> appointments = appointmentMapper.findActiveByStoreAndDateRange(storeId, dayStart, dayEnd);
|
||||
List<ScheduleBlock> blocks =
|
||||
scheduleBlockMapper.findByStoreIdAndSlotStartGreaterThanEqualAndSlotStartBeforeAndDeletedFalseOrderBySlotStartAsc(
|
||||
storeId, dayStart, dayEnd);
|
||||
if ("blocked".equals(blockType)
|
||||
&& bookingCapacityService.hasAnyOccupancy(slot, durationMinutes, appointments, blocks)) {
|
||||
return Map.of("code", 409, "message", "所选范围已有预约或占用,无法暂停全部号源");
|
||||
}
|
||||
if ("walk_in".equals(blockType)) {
|
||||
BookingCapacityService.CapacityResult result = bookingCapacityService.evaluateAppointment(
|
||||
slot,
|
||||
durationMinutes,
|
||||
StoreService.normalizeCapacity(store.getBookingCapacity()),
|
||||
appointments,
|
||||
blocks
|
||||
);
|
||||
if (!result.available()) {
|
||||
return Map.of("code", 409, "message", result.reason() + ",无法增加到店占用");
|
||||
}
|
||||
if (scheduleBlockMapper.existsByStoreIdAndSlotStartAndDeletedFalse(storeId, slot)) {
|
||||
return Map.of("code", 409, "message", "该时段已被占用");
|
||||
}
|
||||
|
||||
ScheduleBlock b = new ScheduleBlock();
|
||||
b.setStoreId(storeId);
|
||||
b.setSlotStart(slot);
|
||||
b.setDurationMinutes(durationMinutes);
|
||||
b.setBlockType(blockType);
|
||||
b.setNote(note != null && !note.isBlank() ? note.trim() : null);
|
||||
b.setCreatedByUserId(createdByUserId);
|
||||
|
||||
@ -7,6 +7,8 @@ import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@ -15,7 +17,11 @@ public class ServiceTypeService {
|
||||
|
||||
/** 获取服务类型(系统默认 + 当前店铺自定义) */
|
||||
public List<ServiceType> getByStoreId(Long storeId) {
|
||||
return serviceTypeMapper.findByStoreIdOrStoreIdIsNull(storeId);
|
||||
List<ServiceType> raw = serviceTypeMapper.findByStoreIdOrStoreIdIsNull(storeId);
|
||||
Map<String, ServiceType> merged = new LinkedHashMap<>();
|
||||
raw.stream().filter(x -> x.getStoreId() == null).forEach(x -> merged.put(x.getName(), x));
|
||||
raw.stream().filter(x -> x.getStoreId() != null).forEach(x -> merged.put(x.getName(), x));
|
||||
return List.copyOf(merged.values());
|
||||
}
|
||||
|
||||
/** 仅系统默认(未传门店或 C 端尚未绑定门店时用于展示可选名称) */
|
||||
@ -29,19 +35,23 @@ public class ServiceTypeService {
|
||||
}
|
||||
|
||||
/** 老板新增服务类型 */
|
||||
public ServiceType create(Long storeId, String name) {
|
||||
public ServiceType create(Long storeId, String name, Integer durationMinutes) {
|
||||
validate(name, durationMinutes);
|
||||
ServiceType st = new ServiceType();
|
||||
st.setStoreId(storeId);
|
||||
st.setName(name);
|
||||
st.setName(name.trim());
|
||||
st.setDurationMinutes(durationMinutes);
|
||||
st.setCreateTime(LocalDateTime.now());
|
||||
return serviceTypeMapper.save(st);
|
||||
}
|
||||
|
||||
/** 老板编辑服务类型 */
|
||||
public ServiceType update(Long id, String name) {
|
||||
public ServiceType update(Long id, String name, Integer durationMinutes) {
|
||||
validate(name, durationMinutes);
|
||||
ServiceType st = serviceTypeMapper.findById(id).orElse(null);
|
||||
if (st != null) {
|
||||
st.setName(name);
|
||||
st.setName(name.trim());
|
||||
st.setDurationMinutes(durationMinutes);
|
||||
serviceTypeMapper.save(st);
|
||||
}
|
||||
return st;
|
||||
@ -54,16 +64,52 @@ public class ServiceTypeService {
|
||||
|
||||
/** 初始化系统默认服务类型(如果不存在) */
|
||||
public void initDefaults() {
|
||||
List<ServiceType> defaults = serviceTypeMapper.findByStoreIdOrStoreIdIsNull(null);
|
||||
List<ServiceType> defaults = serviceTypeMapper.findByStoreIdIsNull();
|
||||
Map<String, Integer> builtins = new LinkedHashMap<>();
|
||||
builtins.put("洗澡", 60);
|
||||
builtins.put("美容", 120);
|
||||
builtins.put("洗澡+美容", 150);
|
||||
builtins.put("剪指甲", 30);
|
||||
builtins.put("驱虫", 30);
|
||||
if (defaults.isEmpty()) {
|
||||
String[] names = {"洗澡", "美容", "洗澡+美容", "剪指甲", "驱虫"};
|
||||
for (String name : names) {
|
||||
for (Map.Entry<String, Integer> item : builtins.entrySet()) {
|
||||
ServiceType st = new ServiceType();
|
||||
st.setStoreId(null);
|
||||
st.setName(name);
|
||||
st.setName(item.getKey());
|
||||
st.setDurationMinutes(item.getValue());
|
||||
st.setCreateTime(LocalDateTime.now());
|
||||
serviceTypeMapper.save(st);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// ddl-auto:update 只会补数据库默认 60;启动时把不可编辑的系统内置项校正为产品冻结值。
|
||||
for (ServiceType existing : defaults) {
|
||||
Integer canonical = builtins.get(existing.getName());
|
||||
if (canonical != null && !canonical.equals(existing.getDurationMinutes())) {
|
||||
existing.setDurationMinutes(canonical);
|
||||
serviceTypeMapper.save(existing);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析本店可用服务;同名时本店自定义优先于系统默认。 */
|
||||
public ServiceType resolveForStore(Long storeId, String name) {
|
||||
if (name == null || name.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String key = name.trim();
|
||||
return getByStoreId(storeId).stream()
|
||||
.filter(x -> key.equals(x.getName()))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private static void validate(String name, Integer durationMinutes) {
|
||||
if (name == null || name.isBlank()) {
|
||||
throw new IllegalArgumentException("服务名称不能为空");
|
||||
}
|
||||
if (!BookingDurationSupport.isValid(durationMinutes)) {
|
||||
throw new IllegalArgumentException("服务时长须为 30~480 分钟,且为 30 的倍数");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ package com.petstore.service;
|
||||
import com.petstore.entity.Store;
|
||||
|
||||
import java.time.LocalTime;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 门店线上可预约时间窗口(半小时一档的首末时刻)。
|
||||
@ -33,4 +34,9 @@ public record StoreBookingWindow(LocalTime dayStart, LocalTime lastSlotStart) {
|
||||
int nm = m < 30 ? 0 : 30;
|
||||
return LocalTime.of(t.getHour(), nm);
|
||||
}
|
||||
|
||||
/** lastSlotStart 表示最后一个容量桶;服务必须在该桶结束时刻前完成。 */
|
||||
public LocalDateTime closingTime(LocalDateTime slotStart) {
|
||||
return LocalDateTime.of(slotStart.toLocalDate(), lastSlotStart).plusMinutes(30);
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,6 +16,7 @@ public class StoreService {
|
||||
private final StoreMapper storeMapper;
|
||||
|
||||
public Store create(Store store) {
|
||||
store.setBookingCapacity(normalizeCapacity(store.getBookingCapacity()));
|
||||
store.setInviteCode(generateInviteCode());
|
||||
store.setCreateTime(LocalDateTime.now());
|
||||
store.setUpdateTime(LocalDateTime.now());
|
||||
@ -27,6 +28,10 @@ public class StoreService {
|
||||
return storeMapper.findByIdAndDeletedFalse(id).orElse(null);
|
||||
}
|
||||
|
||||
public Store findByIdForUpdate(Long id) {
|
||||
return storeMapper.findByIdAndDeletedFalseForUpdate(id).orElse(null);
|
||||
}
|
||||
|
||||
public Store findByInviteCode(String code) {
|
||||
return storeMapper.findByInviteCodeAndDeletedFalse(code);
|
||||
}
|
||||
@ -83,6 +88,11 @@ public class StoreService {
|
||||
}
|
||||
existing.setBookingDayStart(start);
|
||||
existing.setBookingLastSlotStart(last);
|
||||
if (incoming.getBookingCapacity() != null) {
|
||||
existing.setBookingCapacity(normalizeCapacity(incoming.getBookingCapacity()));
|
||||
} else if (existing.getBookingCapacity() == null) {
|
||||
existing.setBookingCapacity(1);
|
||||
}
|
||||
|
||||
existing.setUpdateTime(LocalDateTime.now());
|
||||
if (existing.getDeleted() == null) {
|
||||
@ -105,4 +115,14 @@ public class StoreService {
|
||||
private String generateInviteCode() {
|
||||
return UUID.randomUUID().toString().replace("-", "").substring(0, 8).toUpperCase();
|
||||
}
|
||||
|
||||
public static int normalizeCapacity(Integer capacity) {
|
||||
if (capacity == null) {
|
||||
return 1;
|
||||
}
|
||||
if (capacity < 1 || capacity > 10) {
|
||||
throw new IllegalArgumentException("并发接待数须为 1~10");
|
||||
}
|
||||
return capacity;
|
||||
}
|
||||
}
|
||||
|
||||
@ -72,13 +72,13 @@ class ScheduleControllerTest {
|
||||
@Test
|
||||
void staffCreatesBlockWithDerivedStoreId() {
|
||||
CurrentUserContext.set(new CurrentUser(2L, 10L, "staff"));
|
||||
when(scheduleService.createBlock(eq(10L), any(), eq("blocked"), any(), eq(2L)))
|
||||
when(scheduleService.createBlock(eq(10L), any(), eq(30), eq("blocked"), any(), eq(2L)))
|
||||
.thenReturn(Map.of("code", 200, "message", "已占用该时段"));
|
||||
Map<String, Object> body = Map.of("slotStart", "2026-07-06T10:00:00", "blockType", "blocked");
|
||||
Map<String, Object> result = controller.createBlock(body);
|
||||
assertEquals(200, result.get("code"));
|
||||
// 验证 storeId 与 createdByUserId 从上下文派生
|
||||
verify(scheduleService).createBlock(eq(10L), any(), eq("blocked"), any(), eq(2L));
|
||||
verify(scheduleService).createBlock(eq(10L), any(), eq(30), eq("blocked"), any(), eq(2L));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@ -60,11 +60,11 @@ class ServiceTypeControllerTest {
|
||||
st.setId(1L);
|
||||
st.setStoreId(10L);
|
||||
st.setName("新服务");
|
||||
when(serviceTypeService.create(10L, "新服务")).thenReturn(st);
|
||||
Map<String, Object> body = Map.of("name", "新服务");
|
||||
when(serviceTypeService.create(10L, "新服务", 90)).thenReturn(st);
|
||||
Map<String, Object> body = Map.of("name", "新服务", "durationMinutes", 90);
|
||||
Map<String, Object> result = controller.create(body);
|
||||
assertEquals(200, result.get("code"));
|
||||
verify(serviceTypeService).create(10L, "新服务");
|
||||
verify(serviceTypeService).create(10L, "新服务", 90);
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -78,7 +78,7 @@ class ServiceTypeControllerTest {
|
||||
Map<String, Object> result = controller.update(body);
|
||||
assertEquals(403, result.get("code"));
|
||||
assertEquals("FORBIDDEN", result.get("bizCode"));
|
||||
verify(serviceTypeService, never()).update(any(), any());
|
||||
verify(serviceTypeService, never()).update(any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -92,7 +92,7 @@ class ServiceTypeControllerTest {
|
||||
Map<String, Object> result = controller.update(body);
|
||||
assertEquals(403, result.get("code"));
|
||||
assertEquals("FORBIDDEN", result.get("bizCode"));
|
||||
verify(serviceTypeService, never()).update(any(), any());
|
||||
verify(serviceTypeService, never()).update(any(), any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@ -102,8 +102,8 @@ class ServiceTypeControllerTest {
|
||||
own.setId(5L);
|
||||
own.setStoreId(10L);
|
||||
when(serviceTypeService.findById(5L)).thenReturn(own);
|
||||
when(serviceTypeService.update(5L, "改名")).thenReturn(own);
|
||||
Map<String, Object> body = Map.of("id", "5", "name", "改名");
|
||||
when(serviceTypeService.update(5L, "改名", 120)).thenReturn(own);
|
||||
Map<String, Object> body = Map.of("id", "5", "name", "改名", "durationMinutes", 120);
|
||||
Map<String, Object> result = controller.update(body);
|
||||
assertEquals(200, result.get("code"));
|
||||
}
|
||||
|
||||
@ -0,0 +1,65 @@
|
||||
package com.petstore.mapper;
|
||||
|
||||
import org.h2.tools.RunScript;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.Reader;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Statement;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class BookingCapacityMigrationTest {
|
||||
|
||||
@Test
|
||||
void migrationBackfillsStableDurationsAndSafeCapacity() throws Exception {
|
||||
try (Connection connection = DriverManager.getConnection(
|
||||
"jdbc:h2:mem:booking-capacity-migration;MODE=MySQL;DATABASE_TO_LOWER=TRUE",
|
||||
"sa",
|
||||
""
|
||||
)) {
|
||||
createSourceTables(connection);
|
||||
insertSourceRows(connection);
|
||||
|
||||
try (Reader reader = Files.newBufferedReader(
|
||||
Path.of("db/migrations/20260801_create_booking_capacity.sql")
|
||||
)) {
|
||||
RunScript.execute(connection, reader);
|
||||
}
|
||||
|
||||
assertScalar(connection, "SELECT duration_minutes FROM t_service_type WHERE name = '美容'", 120);
|
||||
assertScalar(connection, "SELECT duration_minutes FROM t_appointment WHERE service_type = '剪指甲'", 30);
|
||||
assertScalar(connection, "SELECT duration_minutes FROM t_schedule_block", 30);
|
||||
assertScalar(connection, "SELECT booking_capacity FROM t_store", 1);
|
||||
}
|
||||
}
|
||||
|
||||
private static void createSourceTables(Connection connection) throws Exception {
|
||||
try (Statement statement = connection.createStatement()) {
|
||||
statement.execute("CREATE TABLE t_service_type (id BIGINT PRIMARY KEY, store_id BIGINT, name VARCHAR(100))");
|
||||
statement.execute("CREATE TABLE t_appointment (id BIGINT PRIMARY KEY, service_type VARCHAR(100))");
|
||||
statement.execute("CREATE TABLE t_schedule_block (id BIGINT PRIMARY KEY, slot_start DATETIME)");
|
||||
statement.execute("CREATE TABLE t_store (id BIGINT PRIMARY KEY, name VARCHAR(100))");
|
||||
}
|
||||
}
|
||||
|
||||
private static void insertSourceRows(Connection connection) throws Exception {
|
||||
try (Statement statement = connection.createStatement()) {
|
||||
statement.execute("INSERT INTO t_service_type VALUES (1, NULL, '美容'), (2, 10, '自定义SPA')");
|
||||
statement.execute("INSERT INTO t_appointment VALUES (1, '剪指甲'), (2, '未知服务')");
|
||||
statement.execute("INSERT INTO t_schedule_block VALUES (1, '2026-08-02 09:00:00')");
|
||||
statement.execute("INSERT INTO t_store VALUES (10, '测试门店')");
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertScalar(Connection connection, String sql, int expected) throws Exception {
|
||||
try (Statement statement = connection.createStatement(); ResultSet result = statement.executeQuery(sql)) {
|
||||
result.next();
|
||||
assertEquals(expected, result.getInt(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
package com.petstore.service;
|
||||
|
||||
import com.petstore.entity.Appointment;
|
||||
import com.petstore.entity.ServiceType;
|
||||
import com.petstore.entity.Store;
|
||||
import com.petstore.mapper.AppointmentMapper;
|
||||
import com.petstore.mapper.PetMapper;
|
||||
@ -36,6 +37,8 @@ class AppointmentServiceTest {
|
||||
@Mock private PetMapper petMapper;
|
||||
@Mock private StoreCustomerService storeCustomerService;
|
||||
@Mock private BusinessEventService businessEventService;
|
||||
@Mock private ServiceTypeService serviceTypeService;
|
||||
@Mock private BookingCapacityService bookingCapacityService;
|
||||
|
||||
@InjectMocks private AppointmentService appointmentService;
|
||||
|
||||
@ -134,7 +137,14 @@ class AppointmentServiceTest {
|
||||
store.setId(10L);
|
||||
store.setBookingDayStart(LocalTime.of(9, 0));
|
||||
store.setBookingLastSlotStart(LocalTime.of(21, 30));
|
||||
when(storeService.findById(10L)).thenReturn(store);
|
||||
ServiceType serviceType = new ServiceType();
|
||||
serviceType.setName("洗澡");
|
||||
serviceType.setDurationMinutes(60);
|
||||
appointment.setServiceType("洗澡");
|
||||
when(storeService.findByIdForUpdate(10L)).thenReturn(store);
|
||||
when(serviceTypeService.resolveForStore(10L, "洗澡")).thenReturn(serviceType);
|
||||
when(bookingCapacityService.evaluateAppointment(any(), eq(60), eq(1), anyList(), anyList()))
|
||||
.thenReturn(new BookingCapacityService.CapacityResult(true, null, 0));
|
||||
when(appointmentMapper.save(any(Appointment.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||
|
||||
var result = appointmentService.createBooking(appointment);
|
||||
@ -142,6 +152,7 @@ class AppointmentServiceTest {
|
||||
assertEquals(200, result.get("code"));
|
||||
verify(storeCustomerService).touchBooking(eq(10L), eq(88L), eq(7L), any(LocalDateTime.class));
|
||||
verify(appointmentMapper).save(appointment);
|
||||
assertEquals(60, appointment.getDurationMinutes());
|
||||
verify(businessEventService).recordAppointmentCreated(eq(appointment), isNull(), isNull());
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,64 @@
|
||||
package com.petstore.service;
|
||||
|
||||
import com.petstore.entity.Appointment;
|
||||
import com.petstore.entity.ScheduleBlock;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class BookingCapacityServiceTest {
|
||||
private final BookingCapacityService service = new BookingCapacityService();
|
||||
private final LocalDateTime day = LocalDateTime.of(2026, 8, 2, 9, 0);
|
||||
|
||||
@Test
|
||||
void longServiceChecksEveryCoveredBucket() {
|
||||
Appointment existing = appointment(day.plusMinutes(60), 60);
|
||||
var result = service.evaluateAppointment(day, 120, 1, List.of(existing), List.of());
|
||||
assertFalse(result.available());
|
||||
assertEquals("接待容量已满", result.reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void parallelCapacityAllowsSecondButRejectsThird() {
|
||||
Appointment first = appointment(day, 60);
|
||||
Appointment second = appointment(day, 60);
|
||||
assertTrue(service.evaluateAppointment(day, 60, 2, List.of(first), List.of()).available());
|
||||
assertFalse(service.evaluateAppointment(day, 60, 2, List.of(first, second), List.of()).available());
|
||||
}
|
||||
|
||||
@Test
|
||||
void walkInConsumesOnePlaceAndBlackoutClosesAllPlaces() {
|
||||
ScheduleBlock walkIn = block(day, 60, "walk_in");
|
||||
ScheduleBlock blackout = block(day.plusMinutes(60), 30, "blocked");
|
||||
assertTrue(service.evaluateAppointment(day, 60, 2, List.of(), List.of(walkIn)).available());
|
||||
var blocked = service.evaluateAppointment(day.plusMinutes(30), 60, 10, List.of(), List.of(blackout));
|
||||
assertFalse(blocked.available());
|
||||
assertEquals("门店暂停预约", blocked.reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void touchingIntervalsDoNotOverlap() {
|
||||
Appointment existing = appointment(day, 60);
|
||||
assertTrue(service.evaluateAppointment(day.plusMinutes(60), 30, 1, List.of(existing), List.of()).available());
|
||||
}
|
||||
|
||||
private Appointment appointment(LocalDateTime start, int minutes) {
|
||||
Appointment appointment = new Appointment();
|
||||
appointment.setAppointmentTime(start);
|
||||
appointment.setDurationMinutes(minutes);
|
||||
return appointment;
|
||||
}
|
||||
|
||||
private ScheduleBlock block(LocalDateTime start, int minutes, String type) {
|
||||
ScheduleBlock block = new ScheduleBlock();
|
||||
block.setSlotStart(start);
|
||||
block.setDurationMinutes(minutes);
|
||||
block.setBlockType(type);
|
||||
return block;
|
||||
}
|
||||
}
|
||||
@ -65,7 +65,7 @@ class BusinessEventServiceTest {
|
||||
assertEquals(BusinessEventService.APPOINTMENT_CREATED, event.getEventType());
|
||||
assertEquals(30L, event.getStoreCustomerId());
|
||||
assertEquals("admin", event.getSource());
|
||||
assertEquals("{\"bookingOrigin\":\"admin\"}", event.getMetadataJson());
|
||||
assertEquals("{\"bookingOrigin\":\"admin\",\"durationMinutes\":60}", event.getMetadataJson());
|
||||
assertFalse(event.getMetadataJson().contains("phone"));
|
||||
assertFalse(event.getMetadataJson().contains("token"));
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user