From b6138475f073881199abdef8d2681dcb92d1ab66 Mon Sep 17 00:00:00 2001 From: malei <> Date: Sat, 1 Aug 2026 22:56:31 +0800 Subject: [PATCH] feat: add real booking capacity --- .../20260801_create_booking_capacity.sql | 54 +++++++++ db/migrations/README.md | 2 + .../controller/AdminStoreController.java | 1 + .../controller/AppointmentController.java | 9 +- .../controller/ScheduleController.java | 12 +- .../controller/ServiceTypeController.java | 33 +++++- .../petstore/controller/StoreController.java | 8 +- .../java/com/petstore/entity/Appointment.java | 17 +++ .../com/petstore/entity/ScheduleBlock.java | 12 +- .../java/com/petstore/entity/ServiceType.java | 4 + src/main/java/com/petstore/entity/Store.java | 4 + .../java/com/petstore/mapper/StoreMapper.java | 10 ++ .../petstore/service/AppointmentService.java | 85 ++++++++++---- .../service/AppointmentSlotSupport.java | 11 ++ .../service/BookingCapacityService.java | 108 ++++++++++++++++++ .../service/BookingDurationSupport.java | 28 +++++ .../service/BusinessEventService.java | 5 +- .../com/petstore/service/ScheduleService.java | 86 +++++++++----- .../petstore/service/ServiceTypeService.java | 64 +++++++++-- .../petstore/service/StoreBookingWindow.java | 6 + .../com/petstore/service/StoreService.java | 20 ++++ .../controller/ScheduleControllerTest.java | 4 +- .../controller/ServiceTypeControllerTest.java | 14 +-- .../mapper/BookingCapacityMigrationTest.java | 65 +++++++++++ .../service/AppointmentServiceTest.java | 13 ++- .../service/BookingCapacityServiceTest.java | 64 +++++++++++ .../service/BusinessEventServiceTest.java | 2 +- 27 files changed, 656 insertions(+), 85 deletions(-) create mode 100644 db/migrations/20260801_create_booking_capacity.sql create mode 100644 src/main/java/com/petstore/service/BookingCapacityService.java create mode 100644 src/main/java/com/petstore/service/BookingDurationSupport.java create mode 100644 src/test/java/com/petstore/mapper/BookingCapacityMigrationTest.java create mode 100644 src/test/java/com/petstore/service/BookingCapacityServiceTest.java diff --git a/db/migrations/20260801_create_booking_capacity.sql b/db/migrations/20260801_create_booking_capacity.sql new file mode 100644 index 0000000..da8d7b8 --- /dev/null +++ b/db/migrations/20260801_create_booking_capacity.sql @@ -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; diff --git a/db/migrations/README.md b/db/migrations/README.md index 88ebd1a..9f9cb4f 100644 --- a/db/migrations/README.md +++ b/db/migrations/README.md @@ -14,4 +14,6 @@ `20260801_create_business_event.sql` 必须在客户主档迁移之后执行:建立不可变 `t_business_event`,回填预约创建、报告提交、可确认的服务完成和留资提交。脚本末尾四个验证计数必须为 0;脚本不回填无法从数据库可靠恢复的报告打开与服务开始事件。 +`20260801_create_booking_capacity.sql` 必须在业务事件迁移之后执行:为服务项目、预约快照和手动占用补充时长,为门店补充并发接待数。历史预约按服务名称采用稳定默认值回填;脚本末尾四个验证计数必须为 0。 + 正式迁移框架仍属于 Phase 0 后续任务;接入前不得把本目录误当成自动执行目录。 diff --git a/src/main/java/com/petstore/controller/AdminStoreController.java b/src/main/java/com/petstore/controller/AdminStoreController.java index 7fc34d2..b775036 100644 --- a/src/main/java/com/petstore/controller/AdminStoreController.java +++ b/src/main/java/com/petstore/controller/AdminStoreController.java @@ -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); } } diff --git a/src/main/java/com/petstore/controller/AppointmentController.java b/src/main/java/com/petstore/controller/AppointmentController.java index f46ff91..73e18ae 100644 --- a/src/main/java/com/petstore/controller/AppointmentController.java +++ b/src/main/java/com/petstore/controller/AppointmentController.java @@ -23,18 +23,21 @@ public class AppointmentController { private final UserService userService; /** - * 门店某日可预约时段(半小时一档,每档最多一单)。 + * 门店某日可预约开始时段;按服务时长与门店并发接待数计算。 * date 格式:yyyy-MM-dd。公开接口,无需登录。 */ @GetMapping("/available-slots") - public Map availableSlots(@RequestParam Long storeId, @RequestParam String date) { + public Map 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); } /** diff --git a/src/main/java/com/petstore/controller/ScheduleController.java b/src/main/java/com/petstore/controller/ScheduleController.java index fe2cf00..f4a9047 100644 --- a/src/main/java/com/petstore/controller/ScheduleController.java +++ b/src/main/java/com/petstore/controller/ScheduleController.java @@ -38,7 +38,7 @@ public class ScheduleController { } /** - * 手动占用半小时档。仅 boss/staff;storeId 与 createdByUserId 从上下文派生。 + * 手动占用一个或多个连续半小时档。仅 boss/staff;storeId 与 createdByUserId 从上下文派生。 *
slotStart:ISO 本地时间,如 2026-04-17T10:00:00 *
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 匹配)。 */ diff --git a/src/main/java/com/petstore/controller/ServiceTypeController.java b/src/main/java/com/petstore/controller/ServiceTypeController.java index 38c5c71..272f80e 100644 --- a/src/main/java/com/petstore/controller/ServiceTypeController.java +++ b/src/main/java/com/petstore/controller/ServiceTypeController.java @@ -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("服务时长格式不正确"); + } + } } diff --git a/src/main/java/com/petstore/controller/StoreController.java b/src/main/java/com/petstore/controller/StoreController.java index 70221ed..2fc89c6 100644 --- a/src/main/java/com/petstore/controller/StoreController.java +++ b/src/main/java/com/petstore/controller/StoreController.java @@ -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", "店铺不存在"); } diff --git a/src/main/java/com/petstore/entity/Appointment.java b/src/main/java/com/petstore/entity/Appointment.java index 8420832..c197fb6 100644 --- a/src/main/java/com/petstore/entity/Appointment.java +++ b/src/main/java/com/petstore/entity/Appointment.java @@ -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; diff --git a/src/main/java/com/petstore/entity/ScheduleBlock.java b/src/main/java/com/petstore/entity/ScheduleBlock.java index d5eea67..5f4a3db 100644 --- a/src/main/java/com/petstore/entity/ScheduleBlock.java +++ b/src/main/java/com/petstore/entity/ScheduleBlock.java @@ -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:到店占用(未走线上预约)
* blocked:暂停预约(外出等,线上不可再约该档) diff --git a/src/main/java/com/petstore/entity/ServiceType.java b/src/main/java/com/petstore/entity/ServiceType.java index 97e3c18..da03da0 100644 --- a/src/main/java/com/petstore/entity/ServiceType.java +++ b/src/main/java/com/petstore/entity/ServiceType.java @@ -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; diff --git a/src/main/java/com/petstore/entity/Store.java b/src/main/java/com/petstore/entity/Store.java index aec2dd9..0d183ff 100644 --- a/src/main/java/com/petstore/entity/Store.java +++ b/src/main/java/com/petstore/entity/Store.java @@ -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; diff --git a/src/main/java/com/petstore/mapper/StoreMapper.java b/src/main/java/com/petstore/mapper/StoreMapper.java index 800842b..231cc60 100644 --- a/src/main/java/com/petstore/mapper/StoreMapper.java +++ b/src/main/java/com/petstore/mapper/StoreMapper.java @@ -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 findByInviteCodeAndDeletedFalse(String inviteCode); Optional findByIdAndDeletedFalse(Long id); List findAllByDeletedFalse(); + + /** 创建预约/占用时串行化同一门店的容量判定,避免并发超卖。 */ + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT s FROM Store s WHERE s.id = :id AND s.deleted = false") + Optional findByIdAndDeletedFalseForUpdate(@Param("id") Long id); } diff --git a/src/main/java/com/petstore/service/AppointmentService.java b/src/main/java/com/petstore/service/AppointmentService.java index 4f53567..f2cad8b 100644 --- a/src/main/java/com/petstore/service/AppointmentService.java +++ b/src/main/java/com/petstore/service/AppointmentService.java @@ -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 getByUserId(Long customerUserId) { @@ -89,7 +91,7 @@ public class AppointmentService { /** * 某门店某日:半小时预约档列表(已占用 / 已过 / 可约)。 */ - public Map availableSlots(Long storeId, LocalDate date) { + public Map 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 occupiedRaw = appointmentMapper.findOccupiedAppointmentTimes(storeId, dayStart, dayEnd); - Set occupied = new HashSet<>(); - for (LocalDateTime t : occupiedRaw) { - occupied.add(AppointmentSlotSupport.alignToHalfHour(t)); - } - List blockSlots = scheduleBlockMapper.findOccupiedSlotStarts(storeId, dayStart, dayEnd); - for (LocalDateTime t : blockSlots) { - occupied.add(AppointmentSlotSupport.alignToHalfHour(t)); - } + List appointments = appointmentMapper.findActiveByStoreAndDateRange(storeId, dayStart, dayEnd); + List blocks = + scheduleBlockMapper.findByStoreIdAndSlotStartGreaterThanEqualAndSlotStartBeforeAndDeletedFalseOrderBySlotStartAsc( + storeId, dayStart, dayEnd); LocalDateTime now = LocalDateTime.now(); List> 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 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 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 activeAppointments = appointmentMapper.findActiveByStoreAndDateRange( + appointment.getStoreId(), dayStart, dayEnd); + List 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()); diff --git a/src/main/java/com/petstore/service/AppointmentSlotSupport.java b/src/main/java/com/petstore/service/AppointmentSlotSupport.java index b1a97db..d18c3af 100644 --- a/src/main/java/com/petstore/service/AppointmentSlotSupport.java +++ b/src/main/java/com/petstore/service/AppointmentSlotSupport.java @@ -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)); + } + /** * 在给定可预约时间窗口内,生成某日所有半点起始时刻(含首尾)。 */ diff --git a/src/main/java/com/petstore/service/BookingCapacityService.java b/src/main/java/com/petstore/service/BookingCapacityService.java new file mode 100644 index 0000000..0a67497 --- /dev/null +++ b/src/main/java/com/petstore/service/BookingCapacityService.java @@ -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 appointments, + List 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 appointments, + List 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 appointments, + List 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 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()); + } +} diff --git a/src/main/java/com/petstore/service/BookingDurationSupport.java b/src/main/java/com/petstore/service/BookingDurationSupport.java new file mode 100644 index 0000000..47969b6 --- /dev/null +++ b/src/main/java/com/petstore/service/BookingDurationSupport.java @@ -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; + } +} diff --git a/src/main/java/com/petstore/service/BusinessEventService.java b/src/main/java/com/petstore/service/BusinessEventService.java index e52de53..372dcc2 100644 --- a/src/main/java/com/petstore/service/BusinessEventService.java +++ b/src/main/java/com/petstore/service/BusinessEventService.java @@ -55,6 +55,9 @@ public class BusinessEventService { && appointment.getCreatedByUserId().equals(appointment.resolvedCustomerUserId()) ? "customer" : "admin"; + Map 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 )); } diff --git a/src/main/java/com/petstore/service/ScheduleService.java b/src/main/java/com/petstore/service/ScheduleService.java index b7421ff..ac020b0 100644 --- a/src/main/java/com/petstore/service/ScheduleService.java +++ b/src/main/java/com/petstore/service/ScheduleService.java @@ -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 appointments = appointmentMapper.findActiveByStoreAndDateRange(storeId, rangeStart, rangeEnd); - Map apptBySlot = new LinkedHashMap<>(); - for (Appointment a : appointments) { - LocalDateTime slot = AppointmentSlotSupport.alignToHalfHour(a.getAppointmentTime()); - if (slot != null) { - apptBySlot.putIfAbsent(slot, a); - } - } - List blocks = scheduleBlockMapper.findByStoreIdAndSlotStartGreaterThanEqualAndSlotStartBeforeAndDeletedFalseOrderBySlotStartAsc( storeId, rangeStart, rangeEnd); - Map blockBySlot = new LinkedHashMap<>(); - for (ScheduleBlock b : blocks) { - LocalDateTime slot = AppointmentSlotSupport.alignToHalfHour(b.getSlotStart()); - if (slot != null) { - blockBySlot.putIfAbsent(slot, b); - } - } - LocalDateTime now = LocalDateTime.now(); + int capacity = StoreService.normalizeCapacity(store.getBookingCapacity()); List> rows = new ArrayList<>(); for (LocalDateTime slotStart : AppointmentSlotSupport.allSlotStartsOnDay(date, window)) { Map 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 slotAppointments = appointments.stream() + .filter(a -> bookingCapacityService.overlaps( + a.getAppointmentTime(), a.getEndTime(), slotStart, slotEnd)) + .toList(); + List 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 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 appointments = appointmentMapper.findActiveByStoreAndDateRange(storeId, dayStart, dayEnd); + List 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); diff --git a/src/main/java/com/petstore/service/ServiceTypeService.java b/src/main/java/com/petstore/service/ServiceTypeService.java index 9dcfaf2..a2d755d 100644 --- a/src/main/java/com/petstore/service/ServiceTypeService.java +++ b/src/main/java/com/petstore/service/ServiceTypeService.java @@ -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 getByStoreId(Long storeId) { - return serviceTypeMapper.findByStoreIdOrStoreIdIsNull(storeId); + List raw = serviceTypeMapper.findByStoreIdOrStoreIdIsNull(storeId); + Map 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 defaults = serviceTypeMapper.findByStoreIdOrStoreIdIsNull(null); + List defaults = serviceTypeMapper.findByStoreIdIsNull(); + Map 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 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 的倍数"); } } } diff --git a/src/main/java/com/petstore/service/StoreBookingWindow.java b/src/main/java/com/petstore/service/StoreBookingWindow.java index 7b4c1cd..707052f 100644 --- a/src/main/java/com/petstore/service/StoreBookingWindow.java +++ b/src/main/java/com/petstore/service/StoreBookingWindow.java @@ -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); + } } diff --git a/src/main/java/com/petstore/service/StoreService.java b/src/main/java/com/petstore/service/StoreService.java index 9244616..cc58dba 100644 --- a/src/main/java/com/petstore/service/StoreService.java +++ b/src/main/java/com/petstore/service/StoreService.java @@ -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; + } } diff --git a/src/test/java/com/petstore/controller/ScheduleControllerTest.java b/src/test/java/com/petstore/controller/ScheduleControllerTest.java index 602142a..b68520e 100644 --- a/src/test/java/com/petstore/controller/ScheduleControllerTest.java +++ b/src/test/java/com/petstore/controller/ScheduleControllerTest.java @@ -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 body = Map.of("slotStart", "2026-07-06T10:00:00", "blockType", "blocked"); Map 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 diff --git a/src/test/java/com/petstore/controller/ServiceTypeControllerTest.java b/src/test/java/com/petstore/controller/ServiceTypeControllerTest.java index d0521e3..41b6a8b 100644 --- a/src/test/java/com/petstore/controller/ServiceTypeControllerTest.java +++ b/src/test/java/com/petstore/controller/ServiceTypeControllerTest.java @@ -60,11 +60,11 @@ class ServiceTypeControllerTest { st.setId(1L); st.setStoreId(10L); st.setName("新服务"); - when(serviceTypeService.create(10L, "新服务")).thenReturn(st); - Map body = Map.of("name", "新服务"); + when(serviceTypeService.create(10L, "新服务", 90)).thenReturn(st); + Map body = Map.of("name", "新服务", "durationMinutes", 90); Map 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 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 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 body = Map.of("id", "5", "name", "改名"); + when(serviceTypeService.update(5L, "改名", 120)).thenReturn(own); + Map body = Map.of("id", "5", "name", "改名", "durationMinutes", 120); Map result = controller.update(body); assertEquals(200, result.get("code")); } diff --git a/src/test/java/com/petstore/mapper/BookingCapacityMigrationTest.java b/src/test/java/com/petstore/mapper/BookingCapacityMigrationTest.java new file mode 100644 index 0000000..e50989c --- /dev/null +++ b/src/test/java/com/petstore/mapper/BookingCapacityMigrationTest.java @@ -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)); + } + } +} diff --git a/src/test/java/com/petstore/service/AppointmentServiceTest.java b/src/test/java/com/petstore/service/AppointmentServiceTest.java index 4400b57..c3706cb 100644 --- a/src/test/java/com/petstore/service/AppointmentServiceTest.java +++ b/src/test/java/com/petstore/service/AppointmentServiceTest.java @@ -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()); } diff --git a/src/test/java/com/petstore/service/BookingCapacityServiceTest.java b/src/test/java/com/petstore/service/BookingCapacityServiceTest.java new file mode 100644 index 0000000..61d7b2a --- /dev/null +++ b/src/test/java/com/petstore/service/BookingCapacityServiceTest.java @@ -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; + } +} diff --git a/src/test/java/com/petstore/service/BusinessEventServiceTest.java b/src/test/java/com/petstore/service/BusinessEventServiceTest.java index 8f276e8..ed491bd 100644 --- a/src/test/java/com/petstore/service/BusinessEventServiceTest.java +++ b/src/test/java/com/petstore/service/BusinessEventServiceTest.java @@ -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")); }