Gestão de agendamentos recorrentes para salão de beleza e cabeleireiro feminino em São Paulo com MySQL

Como estruturar banco de dados MySQL para agendamentos recorrentes em salões de beleza. Evite conflitos, otimize consultas e garanta integridade dos dados.

Você abre a agenda da semana e vê três clientes marcadas no mesmo horário para a mesma cabeleireira. Duas são recorrentes — toda terça às 10h — e uma foi encaixada ontem pela recepção. O sistema não avisou. A profissional chega, vê o conflito, e o dia começa com estresse desnecessário.

Esse cenário é comum em salões de beleza de São Paulo que cresceram sem revisar a estrutura de dados por trás do agendamento. Planilhas e sistemas genéricos não resolvem recorrência com exceções, conflitos de recurso e consultas de disponibilidade em milissegundos. O banco de dados precisa modelar o problema real, não apenas guardar datas.

A boa notícia: MySQL 8 tem recursos nativos para isso — constraints, particionamento, CTEs recursivas, índices compostos — e uma arquitetura bem desenhada elimina a classe inteira de bugs de agendamento.

O problema real não é guardar datas, é modelar recorrência com exceções

Muitos sistemas criam uma linha por ocorrência futura. Se a cliente vem toda semana por um ano, são 52 linhas. Multiplique por 200 clientes recorrentes e a tabela incha. Pior: quando a profissional tira férias ou tem um feriado, alguém precisa apagar ou editar dezenas de linhas manualmente. Erro humano é certo.

A separação conceitual resolve: regra de recorrência (o padrão) ≠ ocorrência materializada (o agendamento real no calendário). A regra diz “toda terça às 10h, cliente Maria, profissional Ana, até 31/12”. A ocorrência é a linha concreta de 14/01, 21/01, 28/01. Um job diário lê as regras ativas, pula exceções, e insere as próximas 6 semanas.

Essa abordagem mantém a tabela de agendamentos enxuta (apenas o horizonte operacional), permite consultas rápidas de disponibilidade e isola alterações: cancela-se uma ocorrência sem tocar na regra, muda-se a regra sem reescrever histórico.

Tabelas centrais: profissionais, regras, exceções, agendamentos

CREATE TABLE profissionais (
  id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
  nome VARCHAR(120) NOT NULL,
  ativo BOOLEAN DEFAULT TRUE,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE horarios_trabalho (
  profissional_id BIGINT UNSIGNED NOT NULL,
  dia_semana TINYINT UNSIGNED NOT NULL, -- 1=dom ... 7=sab
  inicio TIME NOT NULL,
  fim TIME NOT NULL,
  PRIMARY KEY (profissional_id, dia_semana),
  FOREIGN KEY (profissional_id) REFERENCES profissionais(id)
);

CREATE TABLE regras_recorrencia (
  id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
  cliente_id BIGINT UNSIGNED NOT NULL,
  profissional_id BIGINT UNSIGNED NOT NULL,
  servico_id BIGINT UNSIGNED NOT NULL,
  frequencia ENUM('SEMANAL','QUINZENAL','MENSAL') NOT NULL,
  dia_semana TINYINT UNSIGNED NULL, -- para SEMANAL/QUINZENAL
  dia_mes TINYINT UNSIGNED NULL,      -- para MENSAL
  intervalo SMALLINT UNSIGNED DEFAULT 1, -- a cada N semanas/meses
  data_inicio DATE NOT NULL,
  data_fim DATE NULL,
  ativo BOOLEAN DEFAULT TRUE,
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  INDEX idx_prof_ativo (profissional_id, ativo),
  INDEX idx_cliente_ativo (cliente_id, ativo)
);

CREATE TABLE excecoes_recorrencia (
  id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
  regra_id BIGINT UNSIGNED NOT NULL,
  data_excecao DATE NOT NULL,
  tipo ENUM('PULAR','ALTERAR_HORARIO','ALTERAR_PROFISSIONAL') NOT NULL,
  novo_horario_inicio TIME NULL,
  novo_horario_fim TIME NULL,
  novo_profissional_id BIGINT UNSIGNED NULL,
  UNIQUE KEY uk_regra_data (regra_id, data_excecao),
  FOREIGN KEY (regra_id) REFERENCES regras_recorrencia(id) ON DELETE CASCADE
);

CREATE TABLE agendamentos (
  id BIGINT UNSIGNED PRIMARY KEY AUTO_INCREMENT,
  regra_id BIGINT UNSIGNED NULL, -- NULL = avulso
  cliente_id BIGINT UNSIGNED NOT NULL,
  profissional_id BIGINT UNSIGNED NOT NULL,
  servico_id BIGINT UNSIGNED NOT NULL,
  data_hora_inicio DATETIME NOT NULL,
  data_hora_fim DATETIME NOT NULL,
  status ENUM('AGENDADO','CONFIRMADO','EM_ATENDIMENTO','CONCLUIDO','CANCELADO','NAO_COMPARECEU') DEFAULT 'AGENDADO',
  origem ENUM('RECORRENCIA','AVULSO','REPOSICAO') DEFAULT 'AVULSO',
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  UNIQUE KEY uk_prof_horario (profissional_id, data_hora_inicio, data_hora_fim),
  INDEX idx_prof_data (profissional_id, data_hora_inicio),
  INDEX idx_cliente_data (cliente_id, data_hora_inicio),
  INDEX idx_status_data (status, data_hora_inicio)
) PARTITION BY RANGE (TO_DAYS(data_hora_inicio)) (
  PARTITION p2024_01 VALUES LESS THAN (TO_DAYS('2024-02-01')),
  PARTITION p2024_02 VALUES LESS THAN (TO_DAYS('2024-03-01')),
  PARTITION p2024_03 VALUES LESS THAN (TO_DAYS('2024-04-01')),
  PARTITION p_future VALUES LESS THAN MAXVALUE
);

A constraint uk_prof_horario impede sobreposição exata de horário para o mesmo profissional. Para intervalos que se tocam (fim 10:00, início 10:00), a aplicação deve normalizar ou usar trigger BEFORE INSERT para validar NOT EXISTS (SELECT 1 FROM agendamentos WHERE profissional_id = NEW.profissional_id AND data_hora_inicio < NEW.data_hora_fim AND data_hora_fim > NEW.data_hora_inicio).

Job de materialização: CTE recursiva para expandir regras

O coração da automação é uma query que gera ocorrências futuras. MySQL 8 suporta CTE recursiva — ideal para expandir uma regra SEMANAL em N linhas sem loop procedural.

WITH RECURSIVE datas_geradas AS (
  SELECT 
    r.id AS regra_id,
    r.cliente_id,
    r.profissional_id,
    r.servico_id,
    r.data_inicio AS data_ocorrencia,
    r.frequencia,
    r.dia_semana,
    r.intervalo,
    r.data_fim
  FROM regras_recorrencia r
  WHERE r.ativo = TRUE
    AND (r.data_fim IS NULL OR r.data_fim >= CURDATE())
    AND r.data_inicio <= DATE_ADD(CURDATE(), INTERVAL 45 DAY)
  UNION ALL
  SELECT 
    d.regra_id,
    d.cliente_id,
    d.profissional_id,
    d.servico_id,
    CASE d.frequencia
      WHEN 'SEMANAL' THEN DATE_ADD(d.data_ocorrencia, INTERVAL d.intervalo WEEK)
      WHEN 'QUINZENAL' THEN DATE_ADD(d.data_ocorrencia, INTERVAL d.intervalo * 2 WEEK)
      WHEN 'MENSAL' THEN DATE_ADD(d.data_ocorrencia, INTERVAL d.intervalo MONTH)
    END,
    d.frequencia,
    d.dia_semana,
    d.intervalo,
    d.data_fim
  FROM datas_geradas d
  WHERE 
    (d.frequencia = 'SEMANAL' AND DATE_ADD(d.data_ocorrencia, INTERVAL d.intervalo WEEK) <= DATE_ADD(CURDATE(), INTERVAL 45 DAY))
    OR (d.frequencia = 'QUINZENAL' AND DATE_ADD(d.data_ocorrencia, INTERVAL d.intervalo * 2 WEEK) <= DATE_ADD(CURDATE(), INTERVAL 45 DAY))
    OR (d.frequencia = 'MENSAL' AND DATE_ADD(d.data_ocorrencia, INTERVAL d.intervalo MONTH) <= DATE_ADD(CURDATE(), INTERVAL 45 DAY))
    AND (d.data_fim IS NULL OR 
      CASE d.frequencia
        WHEN 'SEMANAL' THEN DATE_ADD(d.data_ocorrencia, INTERVAL d.intervalo WEEK)
        WHEN 'QUINZENAL' THEN DATE_ADD(d.data_ocorrencia, INTERVAL d.intervalo * 2 WEEK)
        WHEN 'MENSAL' THEN DATE_ADD(d.data_ocorrencia, INTERVAL d.intervalo MONTH)
      END <= d.data_fim)
)
SELECT 
  d.regra_id,
  d.cliente_id,
  d.profissional_id,
  d.servico_id,
  d.data_ocorrencia,
  s.duracao_minutos
FROM datas_geradas d
JOIN servicos s ON s.id = d.servico_id
WHERE NOT EXISTS (
  SELECT 1 FROM excecoes_recorrencia e
  WHERE e.regra_id = d.regra_id
    AND e.data_excecao = DATE(d.data_ocorrencia)
    AND e.tipo = 'PULAR'
)
  AND NOT EXISTS (
  SELECT 1 FROM agendamentos a
  WHERE a.regra_id = d.regra_id
    AND DATE(a.data_hora_inicio) = DATE(d.data_ocorrencia)
);

Essa query retorna as ocorrências que ainda não existem na tabela de agendamentos e não caem em exceções do tipo PULAR. O job insere em lote (INSERT … SELECT) dentro de uma transação. Rode diariamente às 03h via Event Scheduler do MySQL ou cron externo.

Consultas de disponibilidade em milissegundos

A recepção precisa saber “quais horários a Ana tem livre terça que vem?”. Com o índice (profissional_id, data_hora_inicio) e particionamento por data, o MySQL lê apenas a partição do mês e faz index range scan.

SELECT 
  a.data_hora_inicio,
  a.data_hora_fim
FROM agendamentos a
WHERE a.profissional_id = 12
  AND a.data_hora_inicio >= '2024-01-16 00:00:00'
  AND a.data_hora_inicio <  '2024-01-17 00:00:00'
  AND a.status NOT IN ('CANCELADO','NAO_COMPARECEU')
ORDER BY a.data_hora_inicio;

O plano de execução mostra partitions: p2024_01 e type: ref no índice composto. Para montar grade visual de 30 em 30 minutos, a aplicação faz o diff entre horário de trabalho da profissional (tabela horarios_trabalho) e os blocos ocupados. Cacheie o resultado por 60 segundos com Redis se o volume de consultas justificar.

Exceções, cancelamentos parciais e reposições

Cliente liga: “Terça que vem não posso, mas quero manter as outras”. A recepção clica “Cancelar apenas esta ocorrência”. O sistema:

  1. Atualiza agendamentos set status = 'CANCELADO' onde regra_id = X e DATE(data_hora_inicio) = '2024-01-23'.
  2. Insere em excecoes_recorrencia (regra_id, data_excecao, tipo) VALUES (X, ‘2024-01-23’, ‘PULAR’).

Passo 2 garante que o job não re-crie o agendamento no próximo ciclo. Se a cliente quiser reposicionar, crie agendamento avulso (regra_id = NULL, origem = 'REPOSICAO') no novo horário.

Para alterar horário de uma ocorrência só: tipo ALTERAR_HORARIO na exceção com novo_horario_inicio/fim. O job lê a exceção e materializa com o horário alterado. A regra original permanece intacta.

Monitoramento e manutenção: o que medir

Métrica Query / Fonte Alerta se
Agendamentos órfãos (regra_id aponta para regra inativa) SELECT COUNT(*) FROM agendamentos a LEFT JOIN regras_recorrencia r ON a.regra_id = r.id WHERE r.id IS NULL AND a.regra_id IS NOT NULL > 0
Conflitos não capturados (mesmo profissional, horário sobreposto) SELECT COUNT(*) FROM agendamentos a1 JOIN agendamentos a2 ON a1.profissional_id = a2.profissional_id AND a1.id < a2.id AND a1.data_hora_inicio < a2.data_hora_fim AND a1.data_hora_fim > a2.data_hora_inicio WHERE a1.status NOT IN ('CANCELADO','NAO_COMPARECEU') AND a2.status NOT IN ('CANCELADO','NAO_COMPARECEU') > 0
Job de materialização falhou Event Scheduler LAST_EXECUTED / log de erro > 24h sem execução
Tamanho partição atual INFORMATION_SCHEMA.PARTITIONS > 5M linhas
Índices não usados sys.schema_unused_indexes índices com 0 reads em 30 dias

Agende esses checks no events do MySQL ou no seu observabilidade (Prometheus + node_exporter + mysqld_exporter).

Integração com o fluxo do salão: da recepção ao financeiro

O banco não vive isolado. Quando agendamentos.status vai para CONCLUIDO, um trigger AFTER UPDATE pode disparar inserção em faturamento_itens (serviço, valor, profissional, comissão). Isso evita dupla digitação e garante que o financeiro espelhe a realidade operacional.

CREATE TRIGGER trg_agendamento_concluido
AFTER UPDATE ON agendamentos
FOR EACH ROW
BEGIN
  IF NEW.status = 'CONCLUIDO' AND OLD.status <> 'CONCLUIDO' THEN
    INSERT INTO faturamento_itens (agendamento_id, servico_id, profissional_id, valor, comissao_pct, data_lancamento)
    SELECT NEW.id, s.id, NEW.profissional_id, s.preco, s.comissao_pct, CURDATE()
    FROM servicos s WHERE s.id = NEW.servico_id;
  END IF;
END;

Para serviços que exigem preparo especial — como luzes no cabelo — o campo servico_id carrega a duração real e a necessidade de bloquear tempo extra antes/depois. A tabela servicos deve ter tempo_preparo_min, tempo_finalizacao_min e o job de materialização soma ao intervalo.

Padrões que evitam refatoração futura

  1. UUID ou BIGINT? Use BIGINT UNSIGNED AUTO_INCREMENT para PKs internas. UUID só em chaves expostas na API pública.
  2. Timezone: Guarde DATETIME em UTC (configure time_zone = '+00:00' na sessão). Converta para America/Sao_Paulo só na camada de apresentação.
  3. Soft delete: Não apague agendamentos. status = 'CANCELADO' preserva histórico para relatórios de no-show e LTV de cliente.
  4. Versionamento otimista: Coluna version INT DEFAULT 0 em agendamentos. UPDATE ... SET version = version + 1 WHERE id = ? AND version = ?. Evita lost update em edições concorrentes.
  5. Auditoria: Tabela agendamentos_audit com trigger AFTER INSERT/UPDATE/DELETE registrando acao, usuario_id, antes_json, depois_json, ts. Essencial para LGPD e rastreabilidade.

Esses padrões aparecem em projetos de boas práticas em arquitetura de banco de dados para otimizar sistemas MySQL e valem para qualquer sistema de agendamento crítico.

Fechamento: arquitetura que escala com o salão

Um salão com 5 profissionais e 200 clientes recorrentes roda leve nessa estrutura. O mesmo esquema suporta 50 profissionais e 5.000 clientes — basta adicionar partições mensais futuras e, se necessário, read replicas para consultas de relatório. O segredo é separar regra de ocorrência, proteger integridade com constraints e índices certos, e automatizar a materialização com CTE recursiva nativa do MySQL 8.

Comece modelando as tabelas acima em homologação. Rode o job de materialização por uma semana comparando com a agenda real. Ajuste exceções, valide conflitos, meça latência de consulta de disponibilidade. Quando a recepção parar de ligar para confirmar “tem conflito?”, você sabe que o banco está fazendo o trabalho pesado.

Para diagnósticos de queries lentas nessa estrutura, veja diagnóstico de slow queries MySQL e como ler EXPLAIN MySQL 8.

Checklist prático

0 de 9 concluídos

Perguntas frequentes

Como evitar conflitos de horário quando várias clientes agendam o mesmo profissional?

Use constraints UNIQUE compostas por profissional_id, data_hora_inicio e data_hora_fim na tabela de agendamentos. Transações com SELECT FOR UPDATE garantem atomicidade ao verificar disponibilidade e inserir o agendamento.

Qual a melhor forma de modelar recorrência sem duplicar linhas infinitamente?

Crie uma tabela de regras de recorrência (frequência, dia da semana, data fim) separada dos agendamentos concretos. Um job diário materializa apenas os próximos 30-60 dias, mantendo a tabela enxuta e consultas rápidas.

Como lidar com exceções na recorrência (feriados, férias da profissional)?

Adicione uma tabela de exceções referenciando a regra de recorrência e a data específica. O job de materialização consulta essa tabela e pula as datas listadas, sem alterar a regra original.

Índices compostos ajudam nas consultas de agenda do dia?

Sim. Um índice composto (profissional_id, data_hora_inicio) cobre a busca por agenda diária de um profissional. Para busca por cliente, adicione (cliente_id, data_hora_inicio). Evite índices redundantes que penalizam writes.

Vale a pena particionar a tabela de agendamentos por data?

Particionamento por RANGE em data_hora_inicio (mensal) acelera purgas de dados antigos e scans de intervalo. No MySQL 8, o partition pruning funciona bem para consultas de agenda semanal ou mensal.

Como garantir integridade quando cliente cancela apenas uma ocorrência da série?

Marque a ocorrência materializada com status 'cancelado' e registre a exceção na tabela de exceções da regra. Isso preserva o histórico e evita que o job re-crie o agendamento no próximo ciclo.