Especificación funcional-técnica de agenda — PatitasLimpias QA20
Documento: Arquitectura de agenda y reglas críticas de disponibilidad Versión: 1.0.0 Fecha: 20 de agosto de 2026 Estado: Vigente — implementado y desplegado en este repositorio Responsable del servicio: QA Tester Tank — patitaslimpias-qa20@tankmail.lat Ámbito: reservas de baño y peluquería canina por sede, recurso (lavador) y servicio
Este documento no describe un sistema por construir: describe el sistema que corre en este repositorio. Cada regla, estado y validación enunciada aquí tiene su implementación indicada entre paréntesis (archivo y función), de modo que producto, desarrollo y QA verifiquen contra el código y no contra una intención.
1. Objetivo y alcance
1.1 Objetivo
Definir la lógica técnica de disponibilidad en tiempo real por sede, recurso (lavador) y servicio, con una garantía dura: nunca pueden existir dos reservas activas para el mismo lavador en la misma franja horaria. La especificación cubre además buffers, cancelaciones, reagendamientos, control de concurrencia y trazabilidad completa de los estados de la cita.
1.2 Dentro del alcance
- Cálculo de disponibilidad en tiempo real (sin caché).
- Modelo de datos de agenda: sedes, horarios, servicios, lavadores, habilidades,
turnos, bloqueos, clientes, mascotas, citas, ocupación y eventos.
- Máquina de estados de la cita, con 9 estados y transiciones cerradas.
- Reglas de negocio verificables (20), casos de prueba (15), escenarios de
concurrencia (5) y criterios de aceptación de QA (10).
- Prevención de doble reserva a nivel de motor de base de datos.
1.3 Fuera del alcance de la versión 1.0.0
- Cobro en línea. El sitio no procesa pagos; el valor se paga en la sede.
- Notificaciones automáticas por correo o WhatsApp. La app genera enlaces de
compartición (wa.me, mailto:) que dispara la persona, no el servidor.
- Recursos físicos distintos del lavador (tinas, secadores) como restricción
independiente. Ver §12, evolución prevista.
- Reservas recurrentes y listas de espera.
1.4 Glosario
| Término | Definición operativa |
|---|---|
| Franja / slot | Unidad atómica de agenda: 15 minutos. SLOT_MIN = 15. |
| Recurso | Lavador. Es el único recurso que se reserva de forma exclusiva en v1.0.0. |
| Buffer | Minutos posteriores al servicio reservados al mismo lavador para alistar el puesto. |
| Ocupación | Fila en cita_slots: un lavador, una fecha, una franja. |
| Cita activa | Cita en estado pendiente, confirmada o en_proceso. Ocupa agenda. |
| Ventana de reserva | Días hacia adelante en los que se puede reservar (por defecto 30). |
| Antelación mínima | Minutos que deben faltar para poder reservar una franja de hoy (por defecto 60). |
2. Arquitectura
2.1 Diagrama de arquitectura
NAVEGADOR (cliente final) NAVEGADOR (operación)
┌──────────────────────────────────┐ ┌───────────────────────────────┐
│ / landing SSR │ │ /panel login/setup │
│ /reservar wizard 3 pasos │ │ /panel/agenda día por sede │
│ /cita/[token] enlace público │ │ /panel/citas/[id] detalle │
│ │ │ /panel/recursos config │
└───────────────┬──────────────────┘ └───────────────┬───────────────┘
│ fetch JSON │ server actions
│ (no-store) │ (cookie de sesión)
▼ ▼
┌───────────────────────────────────────────────────────────────────────────┐
│ CAPA HTTP — Next.js App Router │
│ │
│ GET /api/disponibilidad franjas libres (público, solo horas) │
│ POST /api/citas alta de reserva │
│ server actions /cita/... cancelar y reagendar con token │
│ server actions /panel/... transiciones, config; exigen sesión │
│ GET /health liveness del pipeline │
└───────────────────────────────┬───────────────────────────────────────────┘
│ (toda decisión ocurre aquí abajo)
▼
┌───────────────────────────────────────────────────────────────────────────┐
│ MOTOR DE AGENDA — lib/agenda.ts │
│ │
│ disponibilidad() horario sede ∩ turno lavador − bloqueos │
│ − ocupación − antelación − ventana │
│ crearCita() BEGIN IMMEDIATE → recálculo → SAVEPOINT por lavador │
│ reagendarCita() libera franjas propias → crea sucesora enlazada │
│ cambiarEstado() valida contra TRANSICIONES → registra evento │
│ expirarPendientes() barrido perezoso de pendientes vencidas │
└───────────────────────────────┬───────────────────────────────────────────┘
│ SQL (node:sqlite, síncrono)
▼
┌───────────────────────────────────────────────────────────────────────────┐
│ SQLite en volumen persistente — lib/db.ts │
│ │
│ cita_slots(lavador_id, fecha, slot_min) ◄── PK = barrera anti-doble │
│ citas · cita_eventos · bloqueos · horarios · catálogos · ajustes │
│ migraciones numeradas aplicadas al arranque (migrations/*.sql) │
└───────────────────────────────────────────────────────────────────────────┘2.2 Decisiones de arquitectura y su motivo
| # | Decisión | Motivo |
|---|---|---|
| A-1 | La franja mínima es de 15 minutos y toda duración debe ser múltiplo de 15. | Permite representar la ocupación como filas discretas y convertir el anti-solapamiento en una restricción de clave primaria. Sin discretizar, el anti-solapamiento requiere comparaciones de rango y bloqueos explícitos. |
| A-2 | La ocupación se materializa en cita_slots en vez de derivarse de citas. | Un índice único sobre (lavador, fecha, franja) hace que la doble reserva sea imposible aunque falle la validación previa. La barrera es del motor, no del código. |
| A-3 | El buffer se reserva como franjas propias marcadas tipo='buffer'. | El alistamiento consume agenda real. Si el buffer no ocupara franjas, dos citas seguidas dejarían al lavador sin tiempo entre una y otra. |
| A-4 | Las horas se guardan como fecha (YYYY-MM-DD) + inicio_min (minutos desde medianoche) en hora local de la sede. | Colombia no aplica horario de verano, así que la hora local es estable. Guardar UTC obligaría a convertir en cada consulta y volvería ilegibles las consultas de agenda. La zona horaria queda registrada por sede (sedes.zona_horaria) para cuando deje de ser homogénea. |
| A-5 | La disponibilidad no se cachea nunca. | Una franja libre en caché es una promesa que el sistema no puede cumplir. El costo de recalcular es una consulta indexada por día. |
| A-6 | Reagendar crea una cita sucesora en vez de mutar la original. | Conserva el historial completo: qué se prometió primero, cuándo se movió y hacia dónde. La fila original queda en estado reagendada y enlaza con la nueva por reagendada_desde_id. |
| A-7 | El panel de operación exige sesión; no existe lectura pública de datos de clientes. | Nombre, teléfono y correo son datos personales. La única lectura sin sesión es la de la propia cita, mediante un token aleatorio de 16 caracteres. |
| A-8 | La expiración de citas pendientes es un barrido perezoso, no un cron. | El despliegue es un contenedor sin planificador. El barrido se ejecuta en cada consulta de disponibilidad y al abrir la agenda, que es cuando el resultado importa. |
2.3 Componentes y responsabilidad
| Componente | Archivo | Responsabilidad | No hace |
|---|---|---|---|
| Motor de agenda | lib/agenda.ts | Disponibilidad, alta, reagendamiento, transiciones, bloqueos, expiración. | No formatea, no autentica, no responde HTTP. |
| Configuración | lib/configuracion.ts | Alta y edición de sedes, servicios, lavadores, turnos y parámetros. | No toca citas ni ocupación. |
| Acceso | lib/auth.ts | Operador único inicial, sesiones con cookie httpOnly, scrypt con sal por usuario. | No expone datos de clientes. |
| Persistencia | lib/db.ts (provisto) | Conexión SQLite, migraciones al arranque. | — |
| API pública | app/api/disponibilidad, app/api/citas | Traducción HTTP ↔ motor. | No decide disponibilidad. |
| Acciones de cita | app/cita/[token]/acciones.ts | Cancelar y reagendar con el token como credencial. | No confía en lo que el navegador afirme. |
| Acciones de panel | app/panel/acciones.ts | Transiciones y configuración; primera línea de cada acción: exigir sesión. | — |
3. Modelo de datos
3.1 Diagrama entidad-relación
sedes 1───N horarios_sede servicios 1───N lavador_servicios N───1 lavadores
│ │ │
│ 1 │ 1 │ 1
│ │ │
N N N
lavadores 1───N horarios_lavador citas ────────────────────────────────► │
│ │ ▲ │
│ 1 │ │ reagendada_desde_id (auto-FK) │
N │ └──────────────────────────────────┘
bloqueos │
├──1 clientes 1───N mascotas
├──N cita_slots (ocupación 15 min)
└──N cita_eventos (trazabilidad)
ajustes (clave/valor) operadores 1───N sesiones_panel3.2 Entidades clave
1. `sedes` — punto de atención.
| Columna | Tipo | Regla |
|---|---|---|
id | INTEGER PK | |
nombre | TEXT NOT NULL | 2–80 caracteres. |
direccion, ciudad | TEXT NULL | Opcionales; si faltan, la UI omite el bloque. |
zona_horaria | TEXT NOT NULL | Por defecto America/Bogota. Define qué es "hoy" y "ahora" para esa sede. |
activa | INTEGER NOT NULL | 0 = no aparece en el sitio ni ofrece franjas. Nunca se borra: hay citas históricas que la referencian. |
2. `horarios_sede` — franja de apertura por día de semana.
| Columna | Tipo | Regla |
|---|---|---|
sede_id | FK sedes | |
dia_semana | INTEGER | 0 = domingo … 6 = sábado. |
abre_min, cierra_min | INTEGER | Minutos desde medianoche. cierra_min > abre_min. |
UNIQUE (sede_id, dia_semana). La ausencia de fila = sede cerrada ese día. |
3. `servicios` — catálogo transversal a las sedes.
| Columna | Tipo | Regla |
|---|---|---|
duracion_min | INTEGER | Múltiplo de 15, entre 15 y 480. |
buffer_min | INTEGER | Múltiplo de 15, entre 0 y 120. |
precio_centavos | INTEGER | Entero en centavos. Se muestra siempre con código ISO 4217 (COP 40.000). |
moneda | TEXT | COP en v1.0.0. |
tamano | TEXT | todos, pequeno, mediano o grande. Restringe qué mascota puede reservarlo. |
activo | INTEGER | 0 = fuera del catálogo, sin borrar historia. |
4. `lavadores` — el recurso que se reserva en exclusiva.
| Columna | Tipo | Regla |
|---|---|---|
sede_id | FK sedes | Un lavador pertenece a una sola sede. |
nombre | TEXT NOT NULL | 2–80 caracteres. |
activo | INTEGER | 0 = no recibe nuevas citas; las ya agendadas siguen visibles. |
5. `lavador_servicios` — habilidades. PK (lavador_id, servicio_id). Un servicio se ofrece en una sede si y solo si al menos un lavador activo de esa sede lo tiene asignado.
6. `horarios_lavador` — turno por día. UNIQUE (lavador_id, dia_semana). La ausencia de fila = ese lavador no trabaja ese día.
7. `bloqueos` — ausencias, mantenimiento, cierres parciales.
| Columna | Regla |
|---|---|
sede_id | Obligatorio. |
lavador_id | NULL = bloqueo de toda la sede. |
fecha, inicio_min, fin_min | fin_min > inicio_min. |
motivo | Texto libre, hasta 120 caracteres. |
8. `citas` — la reserva.
| Columna | Regla |
|---|---|
token | UNIQUE, 16 caracteres base64url aleatorios (randomBytes(12)). Credencial del enlace público. |
sede_id, lavador_id, servicio_id, cliente_id, mascota_id | FK obligatorias. |
fecha, inicio_min | Hora local de la sede. |
duracion_min, buffer_min, precio_centavos, moneda | Copiados del servicio al momento de reservar. Cambiar el catálogo no reescribe citas existentes. |
estado | Ver §4. |
reagendada_desde_id | FK a citas. Enlaza la cadena de reagendamientos. |
version | Entero incremental; sube en cada transición (bloqueo optimista). |
creada_en, actualizada_en | Marcas de tiempo UTC. |
9. `cita_slots` — ocupación materializada. La entidad crítica.
| Columna | Regla |
|---|---|
lavador_id, fecha, slot_min | PRIMARY KEY compuesta. Es la barrera anti doble reserva. |
cita_id | FK a la cita dueña de la franja. |
tipo | servicio o buffer. |
10. `cita_eventos` — bitácora inmutable.
| Columna | Regla |
|---|---|
tipo | creada, transicion, reagendada, nota. |
estado_anterior, estado_nuevo | Ambos extremos de la transición. |
actor | cliente, operacion o sistema. |
detalle | Texto explicativo (motivo, destino del reagendamiento, causa de expiración). |
creado_en | UTC. Nunca se actualiza ni se borra. |
11. `clientes` y 12. `mascotas` — identidad mínima. Del cliente se guarda nombre y al menos un canal de contacto (correo o teléfono). De la mascota, nombre, talla, raza opcional y notas de cuidado.
13. `ajustes` — parámetros operativos clave/valor: granularidad_min, antelacion_minima_min, ventana_reserva_dias, cancelacion_sin_costo_horas, reagendamiento_limite_horas, max_reagendamientos.
14. `operadores` y 15. `sesiones_panel` — acceso del equipo.
3.3 Índices
| Índice | Propósito |
|---|---|
cita_slots PK (lavador_id,fecha,slot_min) | Anti-solapamiento y consulta de ocupación del día. |
idx_cita_slots_cita | Liberar todas las franjas de una cita en una sentencia. |
idx_citas_dia (sede_id,fecha,inicio_min) | Agenda del día ordenada. |
idx_citas_lavador (lavador_id,fecha) | Carga por lavador. |
idx_bloqueos_dia (sede_id,fecha) | Bloqueos aplicables al día consultado. |
idx_cita_eventos_cita (cita_id,id) | Historial en orden. |
3.4 Política de migraciones
Las migraciones son numeradas y aditivas (migrations/001_init.sql, …). Nunca se edita una migración aplicada, nunca se ejecuta DROP ni DELETE de datos de usuario. Para desactivar entidades se usa la bandera activo/activa. Los datos de demostración viven exclusivamente en migrations/demo-seed.sql, que solo corre con DEMO_SEED=1 (entornos QA).
4. Máquina de estados de la cita
4.1 Estados (9)
| # | Estado | Significado | ¿Ocupa agenda? | ¿Terminal? |
|---|---|---|---|---|
| 1 | pendiente | Reservada por el cliente, sin confirmar por la sede. La franja ya está bloqueada. | Sí | No |
| 2 | confirmada | La sede confirmó la cita. | Sí | No |
| 3 | en_proceso | El servicio comenzó. | Sí | No |
| 4 | completada | El servicio terminó. | Sí (histórico) | Sí |
| 5 | cancelada_cliente | Cancelada por el cliente desde su enlace. | No — libera | Sí |
| 6 | cancelada_sede | Cancelada por operación. | No — libera | Sí |
| 7 | no_show | El cliente no se presentó. | Sí (histórico) | Sí |
| 8 | reagendada | Cerrada porque se movió; enlaza con la sucesora. | No — libera | Sí |
| 9 | expirada | Pendiente que nunca se confirmó y cuya hora pasó. | No — libera | Sí |
4.2 Diagrama de transiciones
┌──────────────┐
reserva ─────►│ pendiente │
└──┬───┬───┬───┘
confirmar │ │ │ +15 min tras la hora, sin confirmar
┌─────────┘ │ └──────────────────────────► expirada
▼ │
┌──────────────┐ │ cancelar (cliente|sede) ─────► cancelada_*
│ confirmada │ │ reagendar ────────────────────► reagendada ──► (sucesora)
└──┬───┬───┬───┘
iniciar │ │ │ no_show ─────────────────────────────► no_show
│ │ └─ cancelar (cliente|sede) ────────────► cancelada_*
│ └───── reagendar ───────────────────────────► reagendada ──► (sucesora)
▼
┌──────────────┐ completar
│ en_proceso │───────────────────────────────────────► completada
└──────┬───────┘
└───────── cancelar (solo sede) ────────────────► cancelada_sede4.3 Matriz de transiciones permitidas
lib/agenda.ts → TRANSICIONES. Cualquier par no listado se rechaza con mensaje explícito («No se puede pasar de X a Y») y no deja rastro de cambio.
| Desde \ Hacia | pendiente | confirmada | en_proceso | completada | cancelada_cliente | cancelada_sede | no_show | reagendada | expirada |
|---|---|---|---|---|---|---|---|---|---|
| pendiente | — | ✅ | ❌ | ❌ | ✅ | ✅ | ❌ | ✅ | ✅ (sistema) |
| confirmada | ❌ | — | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | ❌ |
| en_proceso | ❌ | ❌ | — | ✅ | ❌ | ✅ | ❌ | ❌ | ❌ |
| completada | ❌ | ❌ | ❌ | — | ❌ | ❌ | ❌ | ❌ | ❌ |
| cancelada_cliente | ❌ | ❌ | ❌ | ❌ | — | ❌ | ❌ | ❌ | ❌ |
| cancelada_sede | ❌ | ❌ | ❌ | ❌ | ❌ | — | ❌ | ❌ | ❌ |
| no_show | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | — | ❌ | ❌ |
| reagendada | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | — | ❌ |
| expirada | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | — |
4.4 Efecto sobre las franjas
| Transición | Efecto en cita_slots | Motivo |
|---|---|---|
→ cancelada_cliente / cancelada_sede | Libera todas las franjas de la cita. | La hora vuelve al mercado de inmediato. |
→ expirada | Libera. | Nunca se confirmó; retener la hora no aporta. |
→ reagendada | Libera (antes de insertar las de la sucesora). | La hora vieja queda disponible en el mismo instante en que se ocupa la nueva. |
→ completada / no_show | Conserva. | El tiempo se consumió: la agenda histórica debe mostrar al lavador ocupado en esa franja. |
5. Cálculo de disponibilidad en tiempo real
5.1 Entradas
disponibilidad({ sedeId, servicioId, fecha, excluirCitaId? }) — lib/agenda.ts.
5.2 Algoritmo
1. Validar fecha (formato y existencia real del día).
2. Sede existe y activa → si no: sin franjas, motivo explícito.
3. Servicio existe y activo → si no: sin franjas, motivo explícito.
4. delta = fecha − hoy(sede)
delta < 0 → "Esa fecha ya pasó."
delta > ventana_reserva_dias → "Solo puedes reservar hasta con N días…"
5. Horario de la sede para ese día de semana
sin fila → "La sede no atiende los <día>."
6. requerido = duracion_min + buffer_min
slotsRequeridos = ceil(requerido / 15)
7. Candidatos = lavadores activos de la sede
∩ que tienen el servicio asignado
∩ con turno definido ese día de semana
vacío → "No hay lavadores disponibles para ese servicio los <día>."
8. Cargar en memoria, para esa fecha y esos lavadores:
- ocupación (cita_slots), excluyendo la cita propia si se está reagendando
- bloqueos de la sede (lavador_id NULL) y de cada lavador
- carga del día por lavador (conteo de franjas ocupadas) para el balanceo
9. minimoInicio = (delta == 0) ? ahora + antelacion_minima_min : 0
10. Para cada inicio desde abre_min hasta cierra_min − requerido, paso 15:
si inicio < minimoInicio → descartar
libres = []
para cada lavador candidato:
si inicio < turno.inicio o inicio+requerido > turno.fin → siguiente
si [inicio, inicio+requerido) solapa un bloqueo aplicable → siguiente
si alguna de las slotsRequeridos franjas está ocupada → siguiente
libres.push(lavador)
si libres no vacío:
ordenar libres por (carga del día ASC, id ASC) ← balanceo determinista
emitir { inicio_min, etiqueta HH:MM, capacidad: libres.length, lavadores: libres }5.3 Salida
Lista ordenada de franjas con su capacidad (cuántos lavadores podrían tomarla). La API pública no expone la identidad de los lavadores, solo la capacidad: quien consulta horas libres no necesita saber quién está libre.
5.4 Propiedades garantizadas
- Toda franja ofrecida cabe completa —servicio y buffer— dentro del horario
de la sede y del turno del lavador que la tomaría.
- Ninguna franja ofrecida solapa una ocupación existente ni un bloqueo.
- La respuesta refleja el estado del instante: no hay caché intermedia
(cache-control: no-store, dynamic = "force-dynamic").
- Ofrecer una franja no la reserva. La reserva es el único acto que ocupa
agenda, y revalida todo (§6).
6. Alta de la reserva y prevención de doble reserva
6.1 Secuencia
cliente API /api/citas crearCita() SQLite
│ POST {sede, servicio, fecha, hora, datos} │
├───────────────────────►│ │
│ │ valida formato, límites, consentimiento │
│ ├──────────────────────►│ │
│ │ │ BEGIN IMMEDIATE ──────────►│ (lock de escritura)
│ │ │ disponibilidad(...) ──────►│
│ │ │ franja ausente → ROLLBACK, 409
│ │ │ upsert cliente + mascota ─►│
│ │ │ para cada lavador libre: │
│ │ │ SAVEPOINT intento ──────►│
│ │ │ INSERT cita ────────────►│
│ │ │ INSERT N cita_slots ────►│
│ │ │ conflicto de PK → ROLLBACK TO intento, siguiente lavador
│ │ │ evento 'creada' ────────►│
│ │ │ COMMIT ───────────────────►│
│◄───────────────────────┤ 201 { token, url } │ │6.2 Las tres barreras
| Barrera | Dónde | Qué evita |
|---|---|---|
| 1. Presentación | El navegador solo muestra franjas devueltas por la API. | Que el usuario elija una hora obviamente imposible. No es una garantía: la vista envejece. |
| 2. Revalidación transaccional | disponibilidad() se vuelve a ejecutar dentro de BEGIN IMMEDIATE. | Que una franja tomada hace 40 segundos se confirme igual. |
| 3. Restricción del motor | PK (lavador_id,fecha,slot_min) en cita_slots. | La doble reserva, incluso si 1 y 2 fallaran por un error de código. |
La barrera 3 es la que convierte la promesa en garantía: no depende de que el código esté bien escrito, sino de que el motor rechace la fila.
El código de error distingue dónde se detectó el choque: no_disponible cuando lo vio la revalidación de la barrera 2, conflicto cuando lo rechazó la clave primaria de la barrera 3. Para el visitante ambos son la misma respuesta HTTP 409 y el mismo camino: volver a elegir hora con la disponibilidad fresca.
6.3 Reintento por lavador
Cuando una franja tiene capacidad 2 y dos clientes la piden a la vez, el primero toma al lavador con menos carga. El segundo, al chocar con la PK, no falla: ROLLBACK TO SAVEPOINT y reintenta con el siguiente lavador libre. Solo si se agotan todos los candidatos se responde 409 con el mensaje «Alguien acaba de tomar esa hora».
7. Buffers
| Regla | Detalle |
|---|---|
| Definición | servicios.buffer_min, múltiplo de 15, de 0 a 120 minutos. |
| Momento | Posterior al servicio, nunca previo. |
| Ocupación | Se materializa como franjas tipo='buffer' del mismo lavador. |
| Efecto en la oferta | Una franja se ofrece solo si inicio + duracion + buffer cabe en el horario de la sede y en el turno del lavador. |
| Efecto en la vista del cliente | El cliente ve la hora de inicio y fin del servicio; el buffer se declara aparte («+15 min de alistamiento en agenda»). |
| Congelamiento | El buffer se copia a la cita al reservar. Cambiar el catálogo después no altera las citas ya creadas. |
Ejemplo. Servicio de 75 min + buffer de 15 = 90 min = 6 franjas. Una reserva a las 10:00 ocupa 10:00, 10:15, 10:30, 10:45, 11:00 (servicio) y 11:15 (buffer). La siguiente hora ofrecible para ese lavador es 11:30.
8. Cancelación y reagendamiento
8.1 Cancelación
| Aspecto | Cliente (enlace público) | Operación (panel) |
|---|---|---|
| Estados de origen | pendiente, confirmada | pendiente, confirmada, en_proceso |
| Estado destino | cancelada_cliente | cancelada_sede |
| Franjas | Se liberan de inmediato | Se liberan de inmediato |
| Registro | Evento con motivo y marca de cancelación tardía si faltan menos de cancelacion_sin_costo_horas | Evento con motivo |
| Reversión | No existe. Cancelar es terminal; para volver hay que reservar de nuevo. | Igual |
8.2 Reagendamiento
| Aspecto | Cliente | Operación |
|---|---|---|
| Límite temporal | Hasta reagendamiento_limite_horas (4 h) antes del inicio | Sin límite temporal |
| Límite de veces | max_reagendamientos (2) por cadena | Sin límite |
| Franja destino | Debe estar libre; se valida dentro de la transacción | Igual — operación tampoco puede sobrescribir a otro cliente |
| Lavador | Se prefiere conservar el mismo si sigue libre; si no, se asigna otro candidato | Igual |
| Estado resultante | La sucesora hereda confirmada si la original lo estaba; si no, nace pendiente | Igual |
| Historial | Original → reagendada + evento; sucesora → evento creada con origen | Igual |
| Enlace público | El token viejo redirige al vigente siguiendo reagendada_desde_id | — |
8.3 Bloqueos y citas ya reservadas
Crear un bloqueo no cancela las citas que caen dentro. El sistema las devuelve como conflictos y avisa a operación («N cita(s) ya reservada(s) caen dentro del bloqueo y siguen en pie»). Cancelar automáticamente citas de clientes reales por una acción de configuración sería una pérdida silenciosa de compromisos ya adquiridos.
9. Reglas de negocio (20)
| ID | Regla | Verificación |
|---|---|---|
| RN-01 | Un lavador no puede tener dos citas activas que ocupen la misma franja de 15 min. | PK de cita_slots. Intento de violación → error de restricción → 409. |
| RN-02 | Toda cita ocupa ceil((duracion + buffer)/15) franjas consecutivas del mismo lavador. | Conteo de filas en cita_slots por cita_id. |
| RN-03 | Una franja se ofrece solo si el servicio más su buffer caben dentro del horario de la sede. | inicio + requerido <= cierra_min. |
| RN-04 | Una franja se ofrece solo si cabe dentro del turno del lavador que la tomaría. | inicio >= turno.inicio && inicio + requerido <= turno.fin. |
| RN-05 | Entre varios lavadores libres para una franja gana el de menor carga del día; a igual carga, el de menor id. | Orden determinista, reproducible en pruebas. |
| RN-06 | La disponibilidad nunca se sirve desde caché. | no-store en la API; force-dynamic en las páginas. |
| RN-07 | La disponibilidad se revalida dentro de la transacción de alta; lo que vio el navegador no decide. | crearCita() llama a disponibilidad() tras BEGIN IMMEDIATE. |
| RN-08 | Para el día de hoy solo se ofrecen franjas que empiecen al menos antelacion_minima_min (60) después de ahora. | Comparación contra la hora local de la sede. |
| RN-09 | No se reserva más allá de ventana_reserva_dias (30) ni en fechas pasadas. | Rechazo con motivo explícito. |
| RN-10 | Un servicio se ofrece en una sede solo si un lavador activo de esa sede lo tiene asignado. | serviciosDeSede() cruza habilidades. |
| RN-11 | Al reagendar, las franjas de la propia cita no cuentan como ocupadas. | excluirCitaId en el cálculo; permite mover 10:00 → 10:15. |
| RN-12 | Al reagendar se conserva el mismo lavador si sigue libre en el destino. | Orden de candidatos con el original primero. |
| RN-13 | Un servicio con talla definida solo admite mascotas de esa talla. | Validación en crearCita() con mensaje que nombra el servicio. |
| RN-14 | Toda transición de estado debe existir en la matriz TRANSICIONES; si no, se rechaza sin efectos. | Rechazo previo a cualquier escritura. |
| RN-15 | Cancelar, expirar y reagendar liberan las franjas de inmediato. | DELETE FROM cita_slots WHERE cita_id = ? dentro de la transacción. |
| RN-16 | Completar y marcar no-asistió no liberan franjas: el tiempo se consumió. | La agenda histórica muestra al lavador ocupado. |
| RN-17 | Toda transición deja un evento inmutable con actor, estados y marca de tiempo. | cita_eventos no se actualiza ni se borra. |
| RN-18 | Una cita pendiente cuya hora pasó hace más de 15 min se marca expirada y libera su franja. | Barrido perezoso en disponibilidad y agenda. |
| RN-19 | Un bloqueo nuevo no cancela citas existentes; se reportan como conflictos a operación. | Lista de conflictos en el aviso del panel. |
| RN-20 | La cita congela precio, duración y buffer del servicio al momento de reservar. | Cambiar el catálogo no reescribe citas ya creadas. |
9.1 Reglas de datos personales y acceso
| ID | Regla |
|---|---|
| RD-01 | Ninguna ruta pública lista clientes, correos o teléfonos. La lectura de datos personales exige sesión de panel o el token de la propia cita. |
| RD-02 | El token de cita es aleatorio (96 bits), no correlativo, y las páginas de cita se sirven con noindex. |
| RD-03 | El cliente debe aceptar explícitamente la Política de Privacidad para poder agendar (acepta === true validado en el servidor). |
| RD-04 | Cada cita exige al menos un canal de contacto: correo o teléfono. |
10. Casos de prueba de disponibilidad y conflicto (15)
Datos base de los casos: sede abierta de 08:00 a 18:00; servicio S60 de 60 min con buffer de 15 (5 franjas); lavador L1 con turno 08:00–16:00; lavador L2 con turno 10:00–18:00; ambos con S60 asignado; granularidad 15 min; antelación 60 min; ventana 30 días.
| # | Caso | Precondición | Acción | Resultado esperado (inequívoco) |
|---|---|---|---|---|
| CP-01 | Oferta básica | Agenda vacía, fecha futura | Consultar disponibilidad de S60 | Franjas de 08:00 a 16:45; ninguna después de 16:45 (16:45+75 = 18:00). Capacidad 2 entre 10:00 y 14:45; 1 fuera de ese rango. |
| CP-02 | Anti-solapamiento simple | L1 ocupado 10:00–11:15 (S60+buffer), L2 inexistente | Consultar 10:00, 10:15, 10:30, 10:45, 11:00 | Ninguna de esas cinco franjas se ofrece. La primera ofrecida es 11:15. |
| CP-03 | Buffer que impide la franja contigua | L1 con cita 10:00–11:00 y buffer hasta 11:15; L2 ausente ese día | Intentar reservar 11:00 | Rechazado: 11:00 no aparece en la oferta. Sí aparece 11:15. |
| CP-04 | Cierre de sede | Sede cierra 18:00 | Consultar 17:00 para S60 (75 min requeridos) | 17:00 no se ofrece (17:00+75 = 18:15 > 18:00). Última ofrecida: 16:45. |
| CP-05 | Fuera del turno del lavador | Solo L1 (hasta 16:00) tiene S60 | Consultar 15:15 | No se ofrece (15:15+75 = 16:30 > 16:00). Última ofrecida para L1: 14:45. |
| CP-06 | Bloqueo de lavador | Bloqueo L1 12:00–13:00; L2 libre | Consultar 11:30 y 12:00 | Ambas se ofrecen con capacidad 1 (solo L2). Ninguna con capacidad 2. |
| CP-07 | Bloqueo de sede completa | Bloqueo con lavador_id NULL, 12:00–13:00 | Consultar 11:30 y 12:00 | Ninguna de las dos se ofrece: el bloqueo aplica a todos los lavadores. |
| CP-08 | Antelación mínima | Hoy, hora local 09:10, antelación 60 | Consultar hoy | La primera franja ofrecida es 10:15 (primer múltiplo de 15 ≥ 10:10). 10:00 no se ofrece. |
| CP-09 | Fecha pasada | Fecha = ayer | Consultar | Cero franjas, motivo «Esa fecha ya pasó.» |
| CP-10 | Fuera de ventana | Fecha = hoy + 31, ventana 30 | Consultar | Cero franjas, motivo que menciona los 30 días. |
| CP-11 | Día sin horario de sede | Domingo sin fila en horarios_sede | Consultar domingo | Cero franjas, motivo «La sede no atiende los domingo.» |
| CP-12 | Servicio sin lavador habilitado | S60 no asignado a ningún lavador activo de la sede | Consultar | Cero franjas, motivo «No hay lavadores disponibles para ese servicio los <día>.» Además, el servicio no aparece en el selector de esa sede. |
| CP-13 | Reserva de franja ya tomada (vista envejecida) | Cliente A tomó la última capacidad de las 10:00; el navegador de B aún la muestra | B envía POST /api/citas para 10:00 | HTTP 409 y ninguna fila nueva en citas ni en cita_slots. El cuerpo trae codigo: "no_disponible" («Esa franja ya no está disponible. Elige otra hora.») cuando la revalidación interna ya ve la franja ocupada, o codigo: "conflicto" («Alguien acaba de tomar esa hora…») cuando el choque se detecta en la clave primaria. Ambos son 409 y ambos devuelven al paso 2 con las horas actualizadas. |
| CP-14 | Reagendar sobre sí misma | Cita a las 10:00, se mueve a 10:15 (solapa consigo misma) | Reagendar a 10:15 | Aceptado: se liberan primero las franjas propias. Original → reagendada; sucesora en 10:15 con reagendada_desde_id apuntando a la original. |
| CP-15 | Liberación por cancelación | L1 sin capacidad libre a las 10:00; la única cita se cancela | Consultar 10:00 tras la cancelación | La franja vuelve a ofrecerse en la misma consulta siguiente, con capacidad incrementada en 1. |
10.1 Casos complementarios de estado
| # | Caso | Acción | Resultado esperado |
|---|---|---|---|
| CE-01 | Transición ilegal | Marcar completada una cita pendiente | Rechazo con «No se puede pasar de "Pendiente de confirmar" a "Completada"». Estado y franjas sin cambios. |
| CE-02 | Cancelar una cita completada | Cancelar desde el panel | Rechazo: completada es terminal. |
| CE-03 | Reagendar dentro del límite | Cliente reagenda faltando 2 h (límite 4 h) | Rechazo con el número de horas de la política. Operación sí puede. |
| CE-04 | Tope de reagendamientos | Cadena con 2 reagendamientos previos | Tercer intento del cliente rechazado; el mensaje indica el máximo. |
| CE-05 | Expiración | Cita pendiente de ayer | Al consultar disponibilidad hoy queda expirada, con evento de actor sistema, y sus franjas liberadas. |
11. Matriz de escenarios de concurrencia (5)
Mecanismo base: BEGIN IMMEDIATE serializa a los escritores del archivo SQLite; la PK de cita_slots es la barrera final; SAVEPOINT permite reintentar con otro lavador sin abortar la transacción completa.
| # | Escenario | Secuencia | Comportamiento esperado | Garantía que lo sostiene |
|---|---|---|---|---|
| C-01 | Dos clientes, misma franja, un solo lavador libre | A y B envían POST para 10:00 casi simultáneamente | El primero en obtener el lock crea la cita. El segundo revalida dentro de su transacción, ya no ve la franja y recibe 409 con mensaje de franja tomada. Se crea exactamente una cita y cita_slots tiene una sola fila por franja. | BEGIN IMMEDIATE + revalidación interna (RN-07) |
| C-02 | Dos clientes, misma franja, dos lavadores libres | A y B envían POST para 10:00 | Ambas reservas se crean: A toma el lavador de menor carga, B toma el otro. Capacidad de esa franja pasa de 2 → 0. Ninguna colisión visible para el usuario. | Balanceo determinista (RN-05) + reintento con SAVEPOINT |
| C-03 | Reserva vs. reagendamiento hacia la misma franja | A reserva 11:00 mientras B mueve su cita a 11:00; único lavador | Gana quien obtiene primero el lock de escritura. El perdedor recibe 409/«franja no disponible» y su cita original queda intacta (en el reagendamiento, la liberación de franjas propias y la inserción de las nuevas ocurren en la misma transacción: o pasa todo, o no pasa nada). | Atomicidad de la transacción de reagendamiento |
| C-04 | Cancelación concurrente con nueva reserva sobre la franja liberada | B cancela su cita de 15:00 mientras A intenta reservar 15:00 | Si la cancelación confirma primero, A obtiene la franja normalmente. Si A llega primero, ve la franja ocupada y recibe 409; tras la cancelación, un reintento suyo tiene éxito. Nunca quedan dos citas activas sobre la misma franja ni una franja huérfana. | Serialización de escritores + DELETE de franjas dentro de la transacción de cancelación |
| C-05 | Doble envío del mismo formulario (doble clic / reintento de red) | El navegador de A envía dos veces el mismo POST | La primera crea la cita. La segunda no encuentra libre la franja (la ocupó la primera) y responde 409 si no quedan lavadores; si quedaba otro lavador libre, se crea una segunda cita legítima para el mismo cliente en la misma hora con otro lavador. La UI deshabilita el botón mientras hay envío en curso para evitarlo. | Barrera de PK; mitigación de UI en el envío |
Nota de honestidad técnica sobre C-05. En v1.0.0 no existe clave de idempotencia por envío. La barrera impide la doble reserva del *mismo lavador*, no que un cliente termine con dos citas legítimas en lavadores distintos si fuerza dos envíos. La mitigación actual es de interfaz (botón bloqueado durante el envío). La solución completa —token de idempotencia por intento— está listada en §12.
12. Evolución prevista (no implementado en v1.0.0)
| Tema | Descripción | Motivo de aplazamiento |
|---|---|---|
| Idempotencia de alta | Token por intento de reserva, único en base, para colapsar reenvíos del mismo formulario. | Requiere migración adicional; el riesgo actual es acotado y visible. |
| Recursos físicos | Tinas y secadores como recursos con su propia exclusividad. | El cuello de botella observado es la persona, no el equipo. |
| Notificaciones automáticas | Recordatorios por correo/WhatsApp. | Exige proveedor externo; hoy el compartir es una acción del usuario. |
| Lista de espera | Avisar cuando se libera una franja. | Depende de notificaciones. |
| Horarios partidos | Más de un bloque por día (mañana/tarde) en horarios_sede. | Hoy se resuelve con un bloqueo intermedio. |
13. Criterios de aceptación para QA (10)
| # | Criterio | Cómo se verifica | Aprueba si |
|---|---|---|---|
| QA-01 | Imposibilidad de doble reserva del mismo lavador | Reservar toda la capacidad de una franja y forzar un POST adicional a esa hora | Respuesta 409, y SELECT COUNT(*) FROM cita_slots GROUP BY lavador_id, fecha, slot_min HAVING COUNT(*) > 1 devuelve cero filas |
| QA-02 | El buffer ocupa agenda | Reservar S60 (60+15) a las 10:00 | Se crean 5 filas en cita_slots (4 servicio + 1 buffer) y la siguiente franja ofrecida para ese lavador es 11:15 |
| QA-03 | Ninguna franja ofrecida se sale del horario | Consultar disponibilidad en una sede que cierra a las 18:00 | Ninguna franja cumple inicio + duracion + buffer > cierra_min |
| QA-04 | Antelación mínima respetada | Consultar el día de hoy | Ninguna franja ofrecida empieza antes de ahora + antelacion_minima_min |
| QA-05 | Cancelar libera la hora | Cancelar una cita y volver a consultar esa fecha | La franja reaparece en la consulta inmediatamente siguiente y cita_slots no tiene filas de esa cita |
| QA-06 | Reagendar conserva historial | Reagendar una cita desde el enlace público | Existen dos filas en citas (original reagendada, sucesora activa con reagendada_desde_id), el token viejo redirige al nuevo y hay eventos en ambas |
| QA-07 | Transiciones ilegales rechazadas | Intentar pendiente → completada y completada → cancelada_sede | Ambas rechazadas con mensaje que nombra los estados; ningún cambio de estado ni evento registrado |
| QA-08 | Trazabilidad completa | Recorrer una cita por creación, confirmación, inicio y cierre | cita_eventos tiene un registro por transición con actor, estado anterior, estado nuevo y marca de tiempo; el historial es visible en el panel y en el enlace del cliente |
| QA-09 | Sin lectura pública de datos personales | Recorrer las rutas del sitio sin sesión | Ninguna página o API sin autenticar lista nombres, correos o teléfonos; /panel/* redirige a login; la cita solo es accesible con su token |
| QA-10 | Concurrencia sin corrupción | Ejecutar los cinco escenarios de §11 | En todos los casos: una cita por franja y lavador, sin franjas huérfanas (cita_slots sin cita activa) y sin citas activas sin franjas |
13.1 Consultas de verificación
-- QA-01: doble reserva (debe devolver 0 filas)
SELECT lavador_id, fecha, slot_min, COUNT(*) AS n
FROM cita_slots GROUP BY 1,2,3 HAVING n > 1;
-- QA-10a: franjas huérfanas (debe devolver 0 filas)
SELECT s.* FROM cita_slots s JOIN citas c ON c.id = s.cita_id
WHERE c.estado NOT IN ('pendiente','confirmada','en_proceso','completada','no_show');
-- QA-10b: citas activas sin franjas (debe devolver 0 filas)
SELECT c.id FROM citas c
WHERE c.estado IN ('pendiente','confirmada','en_proceso')
AND NOT EXISTS (SELECT 1 FROM cita_slots s WHERE s.cita_id = c.id);
-- QA-08: trazabilidad de una cita
SELECT id, tipo, estado_anterior, estado_nuevo, actor, detalle, creado_en
FROM cita_eventos WHERE cita_id = ? ORDER BY id;14. Trazabilidad de los requisitos
| Bloque exigido | Sección | Implementación |
|---|---|---|
| Arquitectura | §2 | lib/agenda.ts, lib/db.ts, rutas de app/ |
| Modelo de datos (15 entidades) | §3 | migrations/001_init.sql |
| Reglas de disponibilidad | §5, RN-03…RN-10 | disponibilidad() |
| Anti-solapamiento por recurso | §6, RN-01, RN-02 | PK de cita_slots |
| Buffers | §7, RN-02, RN-03 | servicios.buffer_min, franjas tipo='buffer' |
| Cancelación | §8.1, RN-15 | cancelarCita() |
| Reagendamiento | §8.2, RN-11, RN-12 | reagendarCita() |
| Concurrencia | §11, RN-07 | BEGIN IMMEDIATE, SAVEPOINT, PK |
| Trazabilidad | §4.4, RN-17 | cita_eventos, panel y enlace público |
15. Historia de versiones
| Versión | Fecha | Cambios | Autor |
|---|---|---|---|
| 1.0.0 | 20 de agosto de 2026 | Versión inicial vigente. Arquitectura de agenda, modelo de 15 entidades, máquina de 9 estados, 20 reglas de negocio, 15 casos de prueba de disponibilidad y conflicto, 5 casos complementarios de estado, 5 escenarios de concurrencia y 10 criterios de aceptación de QA. Implementada y desplegada junto a este documento. | QA Tester Tank |
Registro de versión. Este archivo se publica en el repositorio como docs/ESPECIFICACION-AGENDA-v1.0.0.md y se sirve en línea en /especificacion desde el mismo contenido, de modo que la versión leída por producto, desarrollo y QA sea siempre la desplegada. Cualquier cambio de reglas exige una versión nueva del documento en la misma entrega que el cambio de código.