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:
- ¿Está abierto? Dada una sede y un momento, ¿la sede está operativa (no cerrada, no bloqueada)?
- ¿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
| Entidad | Responsabilidad |
|---|---|
BranchSchedule | Horario base recurrente de la sede (una fila por día de la semana, o rangos). |
ProfessionalSchedule | Horario recurrente de un profesional en una sede (ligado a ProfessionalMembership.id). |
BranchBlock | Cierre excepcional de la sede (festivo, corte de agua, remodelación). |
ProfessionalTimeOff | Ausencia puntual del profesional en una sede (vacaciones, médico, personal). |
ScheduleException | Override 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 delAccount. 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
TIMElocal de la sede (no UTC). La conversión a UTC sucede en el cálculo de disponibilidad usandoBranch.timezone.
Representación de excepciones
BranchBlock:{ branch_id, start_at, end_at, reason }conTIMESTAMPTZ. Cubre festivos y cierres.ProfessionalTimeOff:{ professional_membership_id, start_at, end_at, reason }conTIMESTAMPTZ.ScheduleException:{ professional_membership_id, date, start_time?, end_time?, mode ∈ {add, replace, remove} }:addañade una franja ese día además de la normal.replacesustituye la franja del día por la indicada.removeelimina 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_manageren 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}/schedulereemplaza 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_schedulesobre la branch (incluye owner y delegados conschedule_manager).
- RF-2
GET /branches/{id}/schedulees público. - RF-3
POST /branches/{id}/blockscrea unBranchBlock. Body:{ start_at, end_at, reason }. Mismo permiso que RF-1.- Emite evento
branch_block.created { branch_id, start_at, end_at }—bookingconsume para decidir qué hacer con reservas futuras dentro del rango.
- Emite evento
- RF-4
DELETE /branches/{id}/blocks/{blockId}solo sistart_at > now()(no se borran bloques que ya empezaron). Emitebranch_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}/schedulereemplaza el horario semanal del profesional en esa sede. Valida:- Caller es el profesional dueño de la membership o tiene
can_manage_schedulesobre 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.
- Caller es el profesional dueño de la membership o tiene
- RF-7
GET /memberships/{id}/schedulerequierecan_readsobre la branch. - RF-8
POST /memberships/{id}/time-offcreaProfessionalTimeOff. Body:{ start_at, end_at, reason? }. Caller: el profesional oschedule_manager.- Cubre rangos que pueden ser de horas (media tarde al médico) o días (vacaciones).
- RF-9
DELETE /time-off/{id}solo sistart_at > now(). - RF-10
POST /memberships/{id}/exceptionscreaScheduleExceptionpara 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=yGET /memberships/{id}/exceptions?from=&to=requierencan_readsobre 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.
- Devuelve ventanas de tiempo en UTC donde el profesional está disponible según horario y bloqueos. No resta bookings — eso es responsabilidad de
- RF-13 El cálculo aplica, en orden:
- Resolver
BranchService→ obtenerbranch_id,professional_membership_id. - Rechazar si
BranchService.status != approvedoactive != true→ retorna[]. - Obtener
BranchSchedulede la sede → convertir a intervalos absolutos en UTC dentro de[from, to]usandoBranch.timezone. - Restar
BranchBlockque intersectan. - Intersectar con
ProfessionalScheduleabsolutizado (aplicandoScheduleExceptiondel profesional). - Restar
ProfessionalTimeOff. - Retornar las ventanas resultantes. ← fin de responsabilidad de scheduling
- Resolver
- 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 porbookingcomo precondición antes de crear un hold.
Eventos de entrada (consumidos)
- RF-15 Consume
membership.ended(deaccounts):- Archiva
ProfessionalSchedule,ProfessionalTimeOff,ScheduleExceptionde esaProfessionalMembership(soft delete,archived_at). - Emite
availability.invalidated { branch_id }para invalidar caches/índices dediscovery.
- Archiva
- RF-16 Consume
branch_services.bulk_archived(decatalog): no requiere acción directa en datos propios, pero invalida cualquier cache interna de disponibilidad para esosbranch_service_id.
Eventos de salida (emitidos)
- RF-17
branch_block.created/branch_block.deleted—booking(cancelar reservas dentro del rango o reactivar) +notifications. - RF-18
professional_time_off.created/professional_time_off.deleted—booking+notifications. - RF-19
availability.invalidated { branch_id, professional_membership_id? }—discoveryinvalida cache;notificationsno lo usa.
5. Reglas de negocio
- RN-1 Todos los cálculos de disponibilidad usan
Branch.timezonecomo fuente de verdad para convertir horarios locales a UTC. Cambiar el timezone de una branch es una operación restringida (solo owner) y disparaavailability.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_minutesglobal = 5 minutos (alineado concatalog.RN-4). Define los anclajes permitidos para el inicio de un slot.duration_minutesdel 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_minutesporBranch(default 60): los slots que empiezan en menos de ese tiempo desdenow()no se devuelven. Configurable por sede (MVP: un solo valor por sede). - RN-6
max_advance_daysporBranch(default 60): slots más allá de ese horizonte no se devuelven. - RN-7
buffer_minutes_between_bookingsporProfessionalMembership(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.bookingaplica esta regla al calcular disponibilidad y al crear holds (verbookingRN-3b). - RN-9
BranchBlockyProfessionalTimeOffno 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=replaceanula completamente el horario del día; si se quiere conservar parte, se modela con variosadd/removeo 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
BranchBlockque se crea cubriendo bookings futuros no cancela bookings automáticamente desdescheduling. Solo emite el evento;bookingdecide 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 }
→ 200Cá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
| Dominio | Tipo |
|---|---|
authz | Consumidor para checks (can_manage_schedule, can_read). |
accounts | Consume membership.ended. Lee Branch.timezone, ProfessionalMembership.id. |
catalog | Lee BranchService para extraer duration_minutes, branch_id, professional_membership_id al calcular. |
booking | Consume eventos branch_block.created/deleted y professional_time_off.created para aplicar políticas de cancelación. scheduling no lee datos de booking. |
discovery | Consume availability.invalidated para invalidar índices/caches de disponibilidad. |
notifications | Avisa 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 /availabilitypara 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
ProfessionalScheduleactivos apuntando a memberships no-activas (consistencia trasmembership.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
BranchScheduledefinido (sin horario → no aparece en discovery).