feat: add real booking capacity

This commit is contained in:
malei 2026-08-01 22:56:31 +08:00
parent b0149ff0eb
commit b6138475f0
27 changed files with 656 additions and 85 deletions

View 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;

View File

@ -14,4 +14,6 @@
`20260801_create_business_event.sql` 必须在客户主档迁移之后执行:建立不可变 `t_business_event`,回填预约创建、报告提交、可确认的服务完成和留资提交。脚本末尾四个验证计数必须为 0脚本不回填无法从数据库可靠恢复的报告打开与服务开始事件。
`20260801_create_booking_capacity.sql` 必须在业务事件迁移之后执行:为服务项目、预约快照和手动占用补充时长,为门店补充并发接待数。历史预约按服务名称采用稳定默认值回填;脚本末尾四个验证计数必须为 0。
正式迁移框架仍属于 Phase 0 后续任务;接入前不得把本目录误当成自动执行目录。

View File

@ -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);
}
}

View File

@ -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);
}
/**

View File

@ -38,7 +38,7 @@ public class ScheduleController {
}
/**
* 手动占用半小时档 boss/staffstoreId createdByUserId 从上下文派生
* 手动占用一个或多个连续半小时档 boss/staffstoreId createdByUserId 从上下文派生
* <br>slotStartISO 本地时间 2026-04-17T10:00:00
* <br>blockTypewalk_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 匹配)。 */

View File

@ -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);
return Map.of("code", 200, "data", created);
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);
return Map.of("code", 200, "data", updated);
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("服务时长格式不正确");
}
}
}

View File

@ -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", "店铺不存在");
}

View File

@ -25,6 +25,23 @@ public class Appointment {
private String petType;
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;

View File

@ -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;
/** 连续占用时长30480 分钟且必须为 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暂停预约外出等线上不可再约该档

View File

@ -21,6 +21,10 @@ public class ServiceType {
private Long storeId;
private String name;
/** 预计服务时长,按 30 分钟粒度配置。 */
@Column(name = "duration_minutes", nullable = false)
private Integer durationMinutes = 60;
@Column(name = "create_time")
private LocalDateTime createTime;

View File

@ -49,6 +49,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;

View File

@ -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);
}

View File

@ -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());

View File

@ -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));
}
/**
* 在给定可预约时间窗口内生成某日所有半点起始时刻含首尾
*/

View 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());
}
}

View File

@ -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;
}
}

View File

@ -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
));
}

View File

@ -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", "占用时长须为 30480 分钟,且为 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 (scheduleBlockMapper.existsByStoreIdAndSlotStartAndDeletedFalse(storeId, slot)) {
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() + ",无法增加到店占用");
}
}
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);

View File

@ -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("服务时长须为 30480 分钟,且为 30 的倍数");
}
}
}

View File

@ -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);
}
}

View File

@ -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("并发接待数须为 110");
}
return capacity;
}
}

View File

@ -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

View File

@ -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"));
}

View File

@ -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));
}
}
}

View File

@ -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());
}

View File

@ -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;
}
}

View File

@ -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"));
}