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:
- Atualiza
agendamentossetstatus = 'CANCELADO'onderegra_id = XeDATE(data_hora_inicio) = '2024-01-23'. - 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
- UUID ou BIGINT? Use
BIGINT UNSIGNED AUTO_INCREMENTpara PKs internas. UUID só em chaves expostas na API pública. - Timezone: Guarde
DATETIMEem UTC (configuretime_zone = '+00:00'na sessão). Converta para America/Sao_Paulo só na camada de apresentação. - Soft delete: Não apague agendamentos.
status = 'CANCELADO'preserva histórico para relatórios de no-show e LTV de cliente. - Versionamento otimista: Coluna
version INT DEFAULT 0emagendamentos.UPDATE ... SET version = version + 1 WHERE id = ? AND version = ?. Evita lost update em edições concorrentes. - Auditoria: Tabela
agendamentos_auditcom triggerAFTER INSERT/UPDATE/DELETEregistrandoacao,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
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.