可用时间段 (Slots) API 逻辑

更新日期: 2026-02-04

相关功能: 013-appointment-booking, 014-mobile-booking

适用客户端: Mobile App (Flutter) + Web App (Next.js)

1. API 端点概览

端点 GET /api/public/booking/slots
位置 backend/api/public/booking.js:303
限流 publicSlotsLimiter

请求参数

参数 类型 必填 说明
store_id string 店铺 ID
service_id string 服务 ID(用于获取服务时长)
date string 日期 (YYYY-MM-DD 格式)
employee_id string 员工 ID(null 表示"任意技师")
excluded_slots string (JSON) 购物车中已占用的时间段(JSON 数组)

excluded_slots 格式

[
  {
    "employee_id": "emp-123",    // 特定技师占用
    "start_time": "09:00",
    "duration": 60
  },
  {
    "employee_id": null,          // "任意技师"占用(消耗一个可用名额)
    "start_time": "10:30",
    "duration": 45
  }
]
关键更新 (2026-02-04):
employee_id 现在支持 null 值,表示"任意技师"模式的购物车项目。 后端会将每个 employee_id: null 的项目计为"占用一个可用技师名额"。

1.5 员工服务能力过滤(前置条件)

Slots API 查询可用技师时,通过 employee_service_capabilities 表做第一层过滤。 只有 is_available = true 且关联了对应 service_id 的员工才会被纳入可用列表。

管理入口: 员工详情页 → 服务能力管理
API: GET /api/employees/:id/service-capabilities | PUT /api/employees/:id/service-capabilities

两层过滤机制

层级 过滤维度 数据来源 说明
1. 粗粒度 职位级别 job_titles.can_provide_service 标记该职位是否为服务提供者(如前台不提供美甲服务)
2. 细粒度 员工-服务关联 employee_service_capabilities 精确的多对多关联,指定每个员工可以提供哪些具体服务
flowchart TD A[客户端请求 Slots API] --> B{职位过滤} B -->|can_provide_service = true| C{服务能力过滤} B -->|can_provide_service = false| D[排除该员工] C -->|employee_service_capabilities 有匹配记录| E[纳入可用技师列表] C -->|无匹配记录| F[排除该员工] E --> G[继续排班/时间冲突检测] G --> H[返回可用时间段]
注意: 如果员工没有配置任何服务能力记录(employee_service_capabilities 为空), 则该员工对所有服务均可用(向后兼容行为)。只有当存在至少一条记录时,才启用细粒度过滤。

2. 完整数据流

sequenceDiagram participant UI as SelectTimePage participant CB as CartBloc participant BB as BookingBloc participant UC as GetAvailableSlots participant Repo as BookingRepository participant API as ApiClient participant BE as Backend /slots UI->>CB: 获取购物车状态 CB-->>UI: CartState (items) Note over UI: 构建 excludedSlots
(仅当 employeeId == null) UI->>BB: LoadTimeSlots(excludedSlots) BB->>UC: call(excludedSlots) UC->>Repo: getAvailableSlots(excludedSlots) Repo->>API: getAvailableSlots(excludedSlots) Note over API: JSON.encode(excludedSlots) API->>BE: GET /slots?excluded_slots=... Note over BE: 解析 excluded_slots
计算可用时间 BE-->>API: { availableSlots: [...] } API-->>Repo: Response Repo-->>UC: DaySlots UC-->>BB: Result BB-->>UI: BookingState(daySlots)

3. 前端逻辑 (Flutter)

3.1 构建 excludedSlots(2026-02-04 更新)

位置: lib/features/shopping_cart/presentation/pages/select_time_page.dart

关键变更: 现在包含所有购物车项目,包括"任意技师"项目(employeeId 为 null)。
void _loadTimeSlots() {
  final dateStr = DateFormat('yyyy-MM-dd').format(_selectedDate);
  final employeeId = widget.employeeId == 'any' ? null : widget.employeeId;

  // 仅当选择"任意技师"时才需要传递 excludedSlots
  List<ExcludedSlot>? excludedSlots;

  if (employeeId == null) {
    final cartState = getIt<CartBloc>().state;

    // 包含所有同一日期的购物车项目(包括"任意技师"项目)
    // - 特定技师项目: 直接占用该技师的时间
    // - 任意技师项目: 消耗一个可用技师名额
    final cartItemsForDate = cartState.items.where((item) =>
        item.selectedDate == dateStr &&
        item.selectedTimeSlot != null
        // 注意:不再过滤 employeeId != null,允许 null 值
    ).toList();

    if (cartItemsForDate.isNotEmpty) {
      excludedSlots = cartItemsForDate.map((item) => ExcludedSlot(
        employeeId: item.employeeId, // null 表示"任意技师"
        startTime: item.selectedTimeSlot!,
        duration: item.serviceDuration,
      )).toList();
    }
  }

  getIt<BookingBloc>().add(
    LoadTimeSlots(
      storeId: widget.storeId,
      serviceId: widget.serviceId,
      date: dateStr,
      employeeId: employeeId,
      excludedSlots: excludedSlots,
    ),
  );
}

3.2 ExcludedSlot 类定义(2026-02-04 更新)

位置: lib/features/booking/domain/repositories/booking_repository.dart

/// 购物车中已占用的时间段,用于排除冲突时间
///
/// [employeeId] 为 null 表示"任意技师"模式的购物车项目,
/// 后端会将其计为"占用一个可用时间槽"
class ExcludedSlot {
  final String? employeeId; // null 表示"任意技师"
  final String startTime;   // HH:MM 格式
  final int duration;       // 分钟

  ExcludedSlot({
    this.employeeId,        // nullable for "any technician"
    required this.startTime,
    required this.duration,
  });

  Map<String, dynamic> toJson() => {
    'employee_id': employeeId, // null will be serialized as JSON null
    'start_time': startTime,
    'duration': duration,
  };
}

4. 后端逻辑

4.1 解析 excluded_slots

// 解析购物车中已占用的时间段
let cartExcludedSlots = [];
if (excluded_slots) {
  try {
    cartExcludedSlots = JSON.parse(excluded_slots);
    if (!Array.isArray(cartExcludedSlots)) {
      cartExcludedSlots = [];
    }
  } catch (e) {
    logger.warn('Invalid excluded_slots format', { excluded_slots });
    cartExcludedSlots = [];
  }
}

4.2 时间冲突检测函数

// 辅助函数:检查时间是否被购物车项目占用
const isTimeBlockedByCart = (startTime, employeeId) => {
  for (const cartItem of cartExcludedSlots) {
    // 只检查同一员工
    if (cartItem.employee_id !== employeeId) continue;

    const cartStart = cartItem.start_time; // "09:00"
    const cartDuration = cartItem.duration || 60; // 分钟

    // 计算购物车项目的时间范围
    const [cartHour, cartMin] = cartStart.split(':').map(Number);
    const cartStartMinutes = cartHour * 60 + cartMin;
    const cartEndMinutes = cartStartMinutes + cartDuration;

    // 计算当前槽位的时间范围
    const [slotHour, slotMin] = startTime.split(':').map(Number);
    const slotStartMinutes = slotHour * 60 + slotMin;
    const slotEndMinutes = slotStartMinutes + durationMinutes;

    // 检查重叠:如果两个时间段有交集,则被占用
    if (slotStartMinutes < cartEndMinutes && slotEndMinutes > cartStartMinutes) {
      return true;
    }
  }
  return false;
};

4.3 指定员工模式

flowchart TD A[查询员工可用时间] --> B[从 employee_available_start_times 表获取] B --> C{遍历每个时间槽} C --> D{isTimeBlockedByCart?} D -->|是| E[排除该时间槽] D -->|否| F[保留该时间槽] E --> C F --> C C --> G[返回过滤后的可用时间]
if (employee_id) {
  // 查询指定员工的可用槽位
  const slotsResult = await pool.query(
    `SELECT TO_CHAR(datetime AT TIME ZONE $3, 'HH24:MI') as start_time
     FROM tenant_qqnails.employee_available_start_times
     WHERE employee_id = $1
       AND datetime::date = $2
       AND is_available = true
       AND max_duration_minutes >= $4
     ORDER BY datetime`,
    [employee_id, date, timezone, durationMinutes]
  );

  // 过滤掉被购物车占用的时间
  availableSlots = slotsResult.rows.filter(
    slot => !isTimeBlockedByCart(slot.start_time, employee_id)
  );
}

4.4 任意技师模式(关键逻辑)

核心算法 (2026-02-04 更新):
可用名额 = 该时间可用的员工数 - 被特定技师占用的员工数 - "任意技师"购物车项目数
如果 可用名额 > 0,则该时间点可选。

4.4.1 算法流程图

flowchart TD A[查询所有员工可用时间] --> B[按时间分组] B --> C{遍历每个时间点} C --> D[统计该时间点可用员工列表] D --> E[过滤掉被特定购物车项目占用的员工] E --> F[统计"任意技师"购物车项目数量] F --> G{可用员工数 - 任意技师数 > 0?} G -->|是| H[保留该时间点] G -->|否| I[排除该时间点] H --> C I --> C C --> J[返回可用时间列表]

4.4.2 时间重叠检测函数

// 辅助函数:检查时间段是否重叠
const isTimeOverlap = (slot1Start, slot1Duration, slot2Start, slot2Duration) => {
  const [h1, m1] = slot1Start.split(':').map(Number);
  const [h2, m2] = slot2Start.split(':').map(Number);
  const start1 = h1 * 60 + m1;
  const end1 = start1 + slot1Duration;
  const start2 = h2 * 60 + m2;
  const end2 = start2 + slot2Duration;
  return start1 < end2 && end1 > start2;
};

4.4.3 特定技师占用检测

// 检查时间是否被特定员工的购物车项目占用
const isTimeBlockedByCart = (startTime, employeeId) => {
  for (const cartItem of cartExcludedSlots) {
    // 只检查特定员工的占用(employee_id 不为 null)
    if (cartItem.employee_id === null || cartItem.employee_id === undefined) continue;
    if (cartItem.employee_id !== employeeId) continue;

    const cartDuration = cartItem.duration || 60;
    if (isTimeOverlap(startTime, durationMinutes, cartItem.start_time, cartDuration)) {
      return true;
    }
  }
  return false;
};

4.4.4 "任意技师"项目计数

// 计算某时间点被"任意技师"购物车项目占用的数量
const countAnyTechnicianBlockedSlots = (startTime) => {
  let count = 0;
  for (const cartItem of cartExcludedSlots) {
    // 只计算"任意技师"项目(employee_id 为 null)
    if (cartItem.employee_id !== null && cartItem.employee_id !== undefined) continue;

    const cartDuration = cartItem.duration || 60;
    if (isTimeOverlap(startTime, durationMinutes, cartItem.start_time, cartDuration)) {
      count++;
    }
  }
  return count;
};

4.4.5 完整实现

// 任意技师模式:计算每个时间点的剩余可用名额
const slotsResult = await pool.query(
  `SELECT
    eat.employee_id,
    TO_CHAR(eat.datetime AT TIME ZONE $3, 'HH24:MI') as start_time
  FROM tenant_qqnails.employee_available_start_times eat
  JOIN tenant_qqnails.employees e ON e.id = eat.employee_id
  WHERE e.center_id = $1
    AND e.is_active = true
    AND eat.datetime::date = $2
    AND eat.is_available = true
    AND eat.max_duration_minutes >= $4
  ORDER BY start_time`,
  [store_id, date, timezone, durationMinutes]
);

// 按时间分组
const timeToEmployees = new Map();
for (const row of slotsResult.rows) {
  if (!timeToEmployees.has(row.start_time)) {
    timeToEmployees.set(row.start_time, []);
  }
  timeToEmployees.get(row.start_time).push(row.employee_id);
}

// 计算每个时间点的剩余可用名额
const availableTimes = [];
for (const [startTime, employees] of timeToEmployees) {
  // 过滤掉被特定购物车项目占用的员工
  const unblockedEmployees = employees.filter(
    empId => !isTimeBlockedByCart(startTime, empId)
  );

  // 统计"任意技师"购物车项目数量
  const anyTechnicianCount = countAnyTechnicianBlockedSlots(startTime);

  // 剩余可用名额 = 未被特定占用的员工数 - "任意技师"项目数
  const remainingAvailable = unblockedEmployees.length - anyTechnicianCount;

  if (remainingAvailable > 0) {
    availableTimes.push(startTime);
  }
}

availableSlots = availableTimes.map(t => ({ start_time: t }));

5. 场景示例

场景 1: 单一员工店铺 + 特定技师购物车项目

条件
店铺员工 只有 Test Admin 一人
购物车项目 Test Admin 在 9:00-10:00 (60分钟)
新服务选择 "任意技师",时长 60 分钟
sequenceDiagram participant User as 用户 participant Cart as 购物车 participant API as Slots API User->>Cart: 添加服务 A (Test Admin, 9:00) Cart-->>User: 购物车包含 1 项 User->>API: 请求可用时间
employee_id=null (任意技师)
excluded_slots=[{emp: TestAdmin, 9:00, 60min}] Note over API: 9:00 时间点:
可用员工: [Test Admin]
被特定占用: [Test Admin]
未被占用: []
任意技师数: 0
剩余: 0 - 0 = 0
→ 排除 Note over API: 10:00 时间点:
可用员工: [Test Admin]
被特定占用: []
未被占用: [Test Admin]
任意技师数: 0
剩余: 1 - 0 = 1
→ 保留 API-->>User: 可用时间: 10:00, 10:05...

场景 2: 单一员工店铺 + "任意技师"购物车项目(核心场景)

这是修复的核心场景!
之前的 bug: "任意技师"购物车项目没有被传递给后端,导致冲突未检测到。
条件
店铺员工 只有 Test Admin 一人
购物车项目 "任意技师" 在 9:00-10:00 (60分钟)
新服务选择 "任意技师",时长 60 分钟
sequenceDiagram participant User as 用户 participant Cart as 购物车 participant API as Slots API User->>Cart: 添加服务 A (任意技师, 9:00) Cart-->>User: 购物车包含 1 项 User->>API: 请求可用时间
employee_id=null (任意技师)
excluded_slots=[{emp: null, 9:00, 60min}] Note over API: 9:00 时间点:
可用员工: [Test Admin]
被特定占用: []
未被占用: [Test Admin] (1人)
任意技师数: 1
剩余: 1 - 1 = 0
→ 排除 ✓ Note over API: 10:00 时间点:
可用员工: [Test Admin]
被特定占用: []
未被占用: [Test Admin] (1人)
任意技师数: 0
剩余: 1 - 0 = 1
→ 保留 API-->>User: 可用时间: 10:00, 10:05...
结果: 9:00 时间段不可选!虽然 Test Admin 没有被"特定占用",但购物车中已有一个"任意技师"项目占用了 9:00, 消耗了唯一的可用名额 (1 - 1 = 0)。

场景 3: 多员工店铺 + 混合购物车项目

条件
店铺员工 Alice, Bob, Carol(3人)
购物车项目 1 Alice 在 9:00-10:00 (特定技师)
购物车项目 2 "任意技师" 在 9:00-10:00
新服务选择 "任意技师",时长 60 分钟
excluded_slots = [
  { employee_id: "alice-id", start_time: "09:00", duration: 60 },
  { employee_id: null, start_time: "09:00", duration: 60 }
]
flowchart LR subgraph "9:00 时间点计算" A[可用员工: Alice, Bob, Carol] --> B[Alice 被特定占用] B --> C[未被占用: Bob, Carol = 2人] C --> D[任意技师数: 1] D --> E[剩余: 2 - 1 = 1 > 0] E --> F[✓ 保留 9:00] end
结果: 9:00 仍然可选!虽然 Alice 被特定占用,且有一个"任意技师"项目, 但还剩 Bob 或 Carol 其中一人可用 (2 - 1 = 1 > 0)。

6. Mobile 端相关文件 (Flutter)

层级 文件路径 说明
UI guest_mobile_app/.../select_time_page.dart 时间选择页面,构建 excludedSlots
Event guest_mobile_app/.../booking_event.dart LoadTimeSlots 事件定义
BLoC guest_mobile_app/.../booking_bloc.dart _onLoadTimeSlots 处理器
UseCase guest_mobile_app/.../get_available_slots.dart GetAvailableSlots 用例
Repository Interface guest_mobile_app/.../booking_repository.dart ExcludedSlot 类定义
Repository Impl guest_mobile_app/.../booking_repository_impl.dart Repository 实现
API Client guest_mobile_app/.../api_client.dart getAvailableSlots 方法
Backend backend/api/public/booking.js:303 /slots 端点实现

7. Web 端实现 (Next.js)

Web 端预约系统使用相同的后端 API,前端实现位于 frontend/web_app/ 目录。

7.1 API Hook

位置: src/hooks/api/useBookingApi.ts

// ExcludedSlot 类型定义
export interface ExcludedSlot {
  employee_id: string;
  start_time: string; // HH:MM 格式
  duration: number;   // 分钟
}

// useTimeSlots hook 支持 excludedSlots 参数
export function useTimeSlots(
  storeId: string,
  serviceId: string,
  date: string,
  options?: {
    employeeId?: string | null;
    excludedSlots?: ExcludedSlot[];
  }
) {
  const { employeeId, excludedSlots } = options || {};

  const queryParams = new URLSearchParams({
    store_id: storeId,
    service_id: serviceId,
    date: date,
  });

  if (employeeId) {
    queryParams.append('employee_id', employeeId);
  }

  if (excludedSlots && excludedSlots.length > 0) {
    queryParams.append('excluded_slots', JSON.stringify(excludedSlots));
  }

  return useQuery({
    queryKey: ['slots', storeId, serviceId, date, employeeId, excludedSlots],
    queryFn: () => fetchWithRetry(`${API_BASE_URL}/api/public/booking/slots?${queryParams}`),
    staleTime: 2 * 60 * 1000,
    enabled: !!storeId && !!serviceId && !!date,
  });
}

7.2 使用示例

// 基本用法(不带 excludedSlots)
const { data } = useTimeSlots(storeId, serviceId, date);

// 带 excludedSlots 参数(用于多服务预约场景)
const { data } = useTimeSlots(storeId, serviceId, date, {
  employeeId: null, // "任意技师"模式
  excludedSlots: [
    { employee_id: 'emp-123', start_time: '09:00', duration: 60 },
    { employee_id: 'emp-456', start_time: '10:30', duration: 45 },
  ],
});

7.3 Web 端相关文件

文件路径 说明
src/hooks/api/useBookingApi.ts React Query hooks,包含 useTimeSlots
src/stores/booking-store.ts Zustand store,管理预约流程状态
src/app/[locale]/[tenant]/booking/[store]/time/page.tsx 时间选择页面
注意 (2026-02-04): Web 端如需实现购物车功能,应使用与 Mobile 端相同的逻辑:

8. 算法总结

核心公式

可用名额 = 未被特定占用的员工数 - "任意技师"购物车项目数

如果 可用名额 > 0,该时间点可选。

8.1 为什么这个算法有效?

  1. 特定技师项目:直接从可用员工列表中移除该员工
  2. "任意技师"项目:虽然不知道最终分配给谁,但确定会消耗一个可用名额
  3. 剩余名额:如果 > 0,说明还有技师可以服务新项目

8.2 边界情况

场景 计算 结果
1 员工,0 购物车项目 1 - 0 = 1 ✓ 可选
1 员工,1 "任意技师"项目 1 - 1 = 0 ✗ 不可选
2 员工,1 特定 + 1 "任意技师" (2 - 1) - 1 = 0 ✗ 不可选
3 员工,1 特定 + 1 "任意技师" (3 - 1) - 1 = 1 ✓ 可选