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