Skip to content

PRD — Dominio 05: scheduling

Define cuándo se puede atender. Modela horarios de sede, horarios del profesional por sede, bloqueos excepcionales y ausencias. Calcula ventanas de disponibilidad basadas únicamente en horarios y bloqueos — no conoce bookings. No reserva ni cobra — eso es booking.


1. Propósito

Responder con autoridad a dos preguntas:

  1. ¿Está abierto? Dada una sede y un momento, ¿la sede está operativa (no cerrada, no bloqueada)?
  2. ¿Cuándo puede trabajar? Dado un profesional y un rango de fechas, ¿qué ventanas de tiempo tiene disponibles según su horario, ausencias y bloqueos de la sede?

El cálculo de ventanas es derivado (no se materializan slots). scheduling expone una API interna que, dadas las entradas, calcula al vuelo.

scheduling no conoce bookings, no conoce precios, no crea reservas, no cobra. Es una capa de horarios pura. La conversión de ventanas de horario a slots reservables (restando bookings existentes) es responsabilidad de booking.

2. Entidades

EntidadResponsabilidad
BranchScheduleHorario base recurrente de la sede (una fila por día de la semana, o rangos).
ProfessionalScheduleHorario recurrente de un profesional en una sede (ligado a ProfessionalMembership.id).
BranchBlockCierre excepcional de la sede (festivo, corte de agua, remodelación).
ProfessionalTimeOffAusencia puntual del profesional en una sede (vacaciones, médico, personal).
ScheduleExceptionOverride puntual del horario normal del profesional para un día (hora extra fuera de su franja, o corte anticipado).

No hay tabla de "slots disponibles" — se calculan en tiempo real a partir de estas entidades. Los bookings vigentes no son conocidos por este dominio.

Relaciones clave

Branch ──< BranchSchedule
Branch ──< BranchBlock

ProfessionalMembership ──< ProfessionalSchedule
ProfessionalMembership ──< ProfessionalTimeOff
ProfessionalMembership ──< ScheduleException
  • Todos los horarios por sede cuelgan de ProfessionalMembership.id, no del Account. Eso permite que María tenga 40h/semana en Glamour Chapinero y 10h/semana en su branch virtual sin mezclarlas.
  • Al archivarse la membresía (membership.ended), todos los horarios/ausencias asociados se archivan en cascada.

Representación de horarios recurrentes

  • BranchSchedule: filas { branch_id, day_of_week (0-6), open_time, close_time } permitiendo múltiples filas por día (ej. lun 9-13 + 15-19 para cubrir almuerzo).
  • ProfessionalSchedule: análogo — { professional_membership_id, day_of_week, start_time, end_time }, múltiples filas por día.
  • Tiempos guardados como TIME local de la sede (no UTC). La conversión a UTC sucede en el cálculo de disponibilidad usando Branch.timezone.

Representación de excepciones

  • BranchBlock: { branch_id, start_at, end_at, reason } con TIMESTAMPTZ. Cubre festivos y cierres.
  • ProfessionalTimeOff: { professional_membership_id, start_at, end_at, reason } con TIMESTAMPTZ.
  • ScheduleException: { professional_membership_id, date, start_time?, end_time?, mode ∈ {add, replace, remove} }:
    • add añade una franja ese día además de la normal.
    • replace sustituye la franja del día por la indicada.
    • remove elimina la franja ese día (el profesional no trabaja ese día pese al horario recurrente).

3. Historias de usuario

  • U-1. Como Carlos (dueño), quiero definir el horario de mi sede de Chapinero: lunes a sábado de 9:00 a 19:00, domingo cerrado.
  • U-2. Como Carlos, quiero bloquear el 1 de mayo (festivo) para que nadie pueda reservar ese día.
  • U-3. Como Carlos, quiero bloquear la sede de Usaquén del 15 al 17 de marzo por remodelación — cualquier reserva futura en esos días se cancela.
  • U-4. Como María (profesional), quiero definir mi horario por cada sede donde trabajo: lun-vie 9-17 en Glamour, sáb-dom 10-14 en mi branch virtual (domicilio).
  • U-5. Como María, quiero marcar vacaciones del 20 al 27 de diciembre — nadie puede reservarme en ninguna sede esos días.
  • U-6. Como María, quiero habilitar excepcionalmente el viernes 5 a las 20:00 (fuera de mi horario) para atender a una clienta VIP.
  • U-7. Como Laura (colaboradora con schedule_manager en Usaquén), quiero poder editar el horario de la sede aunque no sea la dueña.
  • U-8. Como Sofía (cliente), quiero ver qué slots de 45 min están disponibles para reservar "Corte con secado" con María en Chapinero la próxima semana.
  • U-9. Como Sofía, no quiero ver ni poder reservar un slot que choque con un booking existente o con una ausencia.
  • U-10. Como Carlos, al terminar la asociación de María, quiero que sus horarios en mi sede desaparezcan automáticamente (no me quedan filas huérfanas).

4. Requerimientos funcionales

Horarios de sede (BranchSchedule, BranchBlock)

  • RF-1 PUT /branches/{id}/schedule reemplaza el horario semanal completo. Body: lista de { day_of_week, open_time, close_time }. Valida:
    • open_time < close_time (sin cruce de medianoche en MVP).
    • No hay filas superpuestas del mismo day_of_week.
    • Requiere can_manage_schedule sobre la branch (incluye owner y delegados con schedule_manager).
  • RF-2 GET /branches/{id}/schedule es público.
  • RF-3 POST /branches/{id}/blocks crea un BranchBlock. Body: { start_at, end_at, reason }. Mismo permiso que RF-1.
    • Emite evento branch_block.created { branch_id, start_at, end_at }booking consume para decidir qué hacer con reservas futuras dentro del rango.
  • RF-4 DELETE /branches/{id}/blocks/{blockId} solo si start_at > now() (no se borran bloques que ya empezaron). Emite branch_block.deleted.
  • RF-5 GET /branches/{id}/blocks?from=&to= es público (ventana max 90 días).

Horarios del profesional (ProfessionalSchedule, ProfessionalTimeOff, ScheduleException)

  • RF-6 PUT /memberships/{id}/schedule reemplaza el horario semanal del profesional en esa sede. Valida:
    • Caller es el profesional dueño de la membership o tiene can_manage_schedule sobre la branch.
    • Filas no se superponen dentro del mismo day_of_week.
    • Horario cae dentro del BranchSchedule (warning si no, no error — el profesional puede trabajar fuera del horario oficial si el dueño lo aprobó por contrato). MVP: warning, no block.
  • RF-7 GET /memberships/{id}/schedule requiere can_read sobre la branch.
  • RF-8 POST /memberships/{id}/time-off crea ProfessionalTimeOff. Body: { start_at, end_at, reason? }. Caller: el profesional o schedule_manager.
    • Cubre rangos que pueden ser de horas (media tarde al médico) o días (vacaciones).
  • RF-9 DELETE /time-off/{id} solo si start_at > now().
  • RF-10 POST /memberships/{id}/exceptions crea ScheduleException para una fecha específica. Body: { date, mode, start_time?, end_time? }.
    • mode=remove: no trabaja ese día (equivale a time-off de un día pero más explícito si es a futuro predecible).
    • mode=add: trabaja la franja indicada además del horario normal.
    • mode=replace: trabaja solo esa franja ese día (sustituye el horario normal).
  • RF-11 GET /memberships/{id}/time-off?from=&to= y GET /memberships/{id}/exceptions?from=&to= requieren can_read sobre la branch.

Cálculo de ventanas de horario (API interna)

  • RF-12 API interna GetScheduleWindows(professionalMembershipID, from, to) → []TimeRange.
    • Devuelve ventanas de tiempo en UTC donde el profesional está disponible según horario y bloqueos. No resta bookings — eso es responsabilidad de booking.
    • Consume: BranchSchedule, BranchBlock, ProfessionalSchedule, ProfessionalTimeOff, ScheduleException.
    • No tiene endpoint HTTP propio — es una llamada in-process entre módulos Go.
  • RF-13 El cálculo aplica, en orden:
    1. Resolver BranchService → obtener branch_id, professional_membership_id.
    2. Rechazar si BranchService.status != approved o active != true → retorna [].
    3. Obtener BranchSchedule de la sede → convertir a intervalos absolutos en UTC dentro de [from, to] usando Branch.timezone.
    4. Restar BranchBlock que intersectan.
    5. Intersectar con ProfessionalSchedule absolutizado (aplicando ScheduleException del profesional).
    6. Restar ProfessionalTimeOff.
    7. Retornar las ventanas resultantes. ← fin de responsabilidad de scheduling
  • RF-14 IsOpenAt(branchID, at time.Time) → bool — API interna que responde si la sede está operativa en un momento dado (sin bloqueos activos). Usada por booking como precondición antes de crear un hold.

Eventos de entrada (consumidos)

  • RF-15 Consume membership.ended (de accounts):
    • Archiva ProfessionalSchedule, ProfessionalTimeOff, ScheduleException de esa ProfessionalMembership (soft delete, archived_at).
    • Emite availability.invalidated { branch_id } para invalidar caches/índices de discovery.
  • RF-16 Consume branch_services.bulk_archived (de catalog): no requiere acción directa en datos propios, pero invalida cualquier cache interna de disponibilidad para esos branch_service_id.

Eventos de salida (emitidos)

  • RF-17 branch_block.created / branch_block.deletedbooking (cancelar reservas dentro del rango o reactivar) + notifications.
  • RF-18 professional_time_off.created / professional_time_off.deletedbooking + notifications.
  • RF-19 availability.invalidated { branch_id, professional_membership_id? }discovery invalida cache; notifications no lo usa.

5. Reglas de negocio

  • RN-1 Todos los cálculos de disponibilidad usan Branch.timezone como fuente de verdad para convertir horarios locales a UTC. Cambiar el timezone de una branch es una operación restringida (solo owner) y dispara availability.invalidated.
  • RN-2 Horarios no pueden cruzar medianoche en una sola fila (MVP). Para trabajar hasta 02:00 se modelan dos filas (lun 20:00-23:59 + mar 00:00-02:00).
  • RN-3 slot_step_minutes global = 5 minutos (alineado con catalog.RN-4). Define los anclajes permitidos para el inicio de un slot. duration_minutes del servicio puede ser cualquier múltiplo de 5, pero el inicio siempre cae en múltiplos de 5 min dentro de la hora.
  • RN-4 Un slot solo se incluye si cabe completo dentro de la franja disponible (sin solaparse con el fin de la franja, un block, time-off, o booking).
  • RN-5 lead_time_minutes por Branch (default 60): los slots que empiezan en menos de ese tiempo desde now() no se devuelven. Configurable por sede (MVP: un solo valor por sede).
  • RN-6 max_advance_days por Branch (default 60): slots más allá de ese horizonte no se devuelven.
  • RN-7 buffer_minutes_between_bookings por ProfessionalMembership (default 0): tiempo de reposo/limpieza tras cada booking. Se resta antes y después de cada booking vigente.
  • RN-8 La verificación de que un profesional no tiene bookings solapados en otras sedes es responsabilidad de booking — scheduling no conoce bookings. booking aplica esta regla al calcular disponibilidad y al crear holds (ver booking RN-3b).
  • RN-9 BranchBlock y ProfessionalTimeOff no se solapan entre sí (el sistema valida y previene duplicados idénticos, pero permite overlaps parciales — se resuelven por unión al calcular).
  • RN-10 ScheduleException.mode=replace anula completamente el horario del día; si se quiere conservar parte, se modela con varios add/remove o editando el horario base.
  • RN-11 Al archivar una membresía, horarios/ausencias se archivan, no se borran (preservan auditoría). Pero no cuentan en cálculos de disponibilidad futura.
  • RN-12 Un BranchBlock que se crea cubriendo bookings futuros no cancela bookings automáticamente desde scheduling. Solo emite el evento; booking decide la política.

6. Flujos críticos

Definir horario de sede (Carlos → Chapinero):

PUT /branches/chap/schedule
  body: [
    { day_of_week: 1, open_time: "09:00", close_time: "13:00" },
    { day_of_week: 1, open_time: "15:00", close_time: "19:00" },
    ... (mar-sáb igual, dom sin filas)
  ]
  authz.Check(carlos, can_manage_schedule, branch:chap) → true
  BEGIN TX
  DELETE FROM BranchSchedule WHERE branch_id=chap
  INSERT ... (filas nuevas)
  COMMIT
  Emite availability.invalidated { branch_id: chap }
  → 200

Cálculo de ventanas de horario (llamada interna desde booking):

scheduling.GetScheduleWindows(m_maria_chap, from=2026-04-22T00:00Z, to=2026-04-29T00:00Z)

  Carga BranchService → branch=chap, membership=m_maria_chap
  Valida approved+active → retorna [] si no
  Carga BranchSchedule(chap) → franjas semanales
  Expande a UTC en ventana [22-29 abr] usando tz=America/Bogota
  Resta BranchBlocks(chap) en ventana
  Intersecta con ProfessionalSchedule(m_maria_chap) → lun-vie 9-17
  Aplica ScheduleException para cada día en ventana
  Resta ProfessionalTimeOff(m_maria_chap) en ventana
  → retorna []TimeRange (ventanas puras, sin filtro de bookings)

(booking recibe las ventanas y resta sus propios bookings → slots finales)

Vacaciones de María cancelan reservas futuras:

POST /memberships/m_maria_chap/time-off
  body: { start_at: "2026-12-20T00:00Z", end_at: "2026-12-28T00:00Z", reason: "vacaciones" }
  authz: caller==maria (owner del membership) → OK
  INSERT ProfessionalTimeOff
  Emite professional_time_off.created { membership_id, start_at, end_at }
  → 201

(booking consume evento → selecciona reservas confirmadas de María con start dentro del rango
 → aplica política (cancelar + notificar + reembolsar))

Excepción: atender fuera del horario normal:

POST /memberships/m_maria_chap/exceptions
  body: { date: "2026-04-24", mode: "add", start_time: "20:00", end_time: "22:00" }
  Valida mode=add → requiere start_time+end_time
  Valida franja no solapa con horario existente de ese día
  INSERT ScheduleException
  → 201
  (en el cálculo de disponibilidad del viernes 24, la franja 20-22 se suma a la normal 9-17)

Terminación de asociación → cascada:

(accounts emite: membership.ended { membership_id: m_maria_chap })
  ↓ scheduling consume
  BEGIN TX
  UPDATE ProfessionalSchedule SET archived_at=now() WHERE membership_id=m_maria_chap
  UPDATE ProfessionalTimeOff   SET archived_at=now() WHERE membership_id=m_maria_chap
  UPDATE ScheduleException     SET archived_at=now() WHERE membership_id=m_maria_chap
  COMMIT
  Emite availability.invalidated { branch_id: chap, professional_membership_id: m_maria_chap }

7. Dependencias

DominioTipo
authzConsumidor para checks (can_manage_schedule, can_read).
accountsConsume membership.ended. Lee Branch.timezone, ProfessionalMembership.id.
catalogLee BranchService para extraer duration_minutes, branch_id, professional_membership_id al calcular.
bookingConsume eventos branch_block.created/deleted y professional_time_off.created para aplicar políticas de cancelación. scheduling no lee datos de booking.
discoveryConsume availability.invalidated para invalidar índices/caches de disponibilidad.
notificationsAvisa cambios relevantes (bloqueos que afectan reservas, vacaciones creadas).

scheduling no conoce bookings ni precios. Su única salida de datos es hacia booking (que llama sus APIs internas) y hacia discovery (eventos de invalidación).

8. Fuera de alcance (MVP)

  • Recurrencias complejas tipo "primer lunes de cada mes" — fase 3. MVP: solo semanal.
  • Calendarios externos (Google Calendar/Outlook sync) — fase 4+.
  • Zonas horarias del profesional distintas a la de la sede (ej. profesional remoto) — fuera de roadmap MVP.
  • Overbooking controlado (aceptar 2 reservas solapadas asumiendo no-shows) — fase 4+.
  • Tiempos por tipo de servicio (corte 30min, pero balayage 120min sin solapar parte del proceso con otra cliente) — fase 3.
  • Slots sugeridos / "próximo disponible" con ranking (algoritmo de hueco óptimo) — fase 3.
  • Lead time y max_advance por servicio (no solo por sede) — fase 3.
  • Buffer distinto por servicio (ej. balayage necesita 15min limpieza, corte 0) — fase 3.
  • Cambios de horario que requieren aprobación del dueño (workflow similar a catalog) — fase 4+. MVP: el profesional edita su horario libremente; el dueño puede terminar la asociación si no le sirve.
  • Cierre programado (p.ej. "cierro todos los domingos de julio") en una sola operación — MVP se modela con múltiples blocks.

9. Métricas

  • p95 de GET /availability para ventana de 7 días < 300ms (con cache caliente) / < 600ms (cold).
  • 0 slots devueltos que, al reservar, den conflicto (race condition entre cálculo y booking).
  • 0 ProfessionalSchedule activos apuntando a memberships no-activas (consistencia tras membership.ended).
  • ≥ 99.5% de cálculos de disponibilidad correctos respecto a bookings + time-off + blocks (validación por muestreo con property tests).
  • < 2% de rechazos al crear un booking por slot "no válido" dado por el mismo endpoint 30s antes (señal de TTL de cache demasiado alto).
  • ≥ 95% de sedes activas con BranchSchedule definido (sin horario → no aparece en discovery).

Documentación interna — BeautyHub