Skip to content

Conversation

@lucasramallo
Copy link

@lucasramallo lucasramallo commented Oct 29, 2025

Documentação Completa do Novo Modelo de Agendamento

Objetivo: Substituir a tabela historico_consultas (obsoleta) por um modelo temporal + materializado com histórico de regras, edição individual e alta performance.


1. Visão Geral do Domínio

Conceito Descrição
Cadastro Anual Âncora do paciente por ano. Apenas um ativo por ano.
Agendamento Regra de recorrência: frequência (7, 15, 30 dias) + horário.
Agendamento Gerado Consulta materializada. Pode ser sobrescrita (remarcar) ou cancelada.
Falta Registro de ausência em uma consulta gerada.

Modelo de Dados (ER)

erDiagram
    cadastro_anual ||--o{ agendamento : "possui"
    agendamento ||--o{ agendamento_gerado : "gera"
    agendamento_gerado ||--o{ falta : "pode ter"
    
    profissional {
        UUID id PK
    }
    
    cadastro_anual {
        UUID id PK
        UUID paciente_id
        Integer ano
        LocalDate data_inicio
        LocalDate data_fim
        UUID profissional_id FK
    }
    
    agendamento {
        UUID id PK
        UUID cadastro_anual_id FK
        Integer frequencia_dias
        LocalTime hora_consulta
        Boolean ativo
        LocalDate data_inicio
        LocalDate data_fim
    }
    
    agendamento_gerado {
        UUID id PK
        UUID agendamento_id FK
        LocalDateTime data_hora_agendada
        LocalDateTime data_hora_sobrescrita
        Boolean realizada
        Boolean cancelada
        String motivo_cancelamento
        UUID paciente_id
    }
    
    falta {
        UUID id PK
        UUID agendamento_gerado_id FK
        LocalDate data_falta
        String justificativa
        Boolean notificado
    }
Loading

4. Fluxos Principais

4.1 Edição de Agendamento (Frequência ou Horário)

1. Inativa regra atual → data_fim_regra = hoje - 1
2. Cria novo agendame com:
   - frequencia_dias = nova ou antiga
   - hora_consulta = nova ou antiga
   - data_inicio = hoje
3. Gera consultas futuras (a partir de hoje)
4. Remove consultas antigas futuras

4.2 Edição Individual

Ação Efeito
Remarcar data_hora_sobrescrita = nova data
Cancelar cancelada = true, motivo_cancelamento
Marcar como realizada realizada = true

Não afeta a regra

4.3 Renovação Anual

1. Cria novo cadastro_anual (novo ano)
2. Copia regra ativa do ano anterior
3. Gera todas as consultas do novo ano

5. Performance & Estratégia

Camada Estratégia
Banco Índices em (paciente_id, data_hora_agendada), (agendamento_id)
Materialização Sob demanda (generateAppointments)
Paginação Pageable + Slice

EXEMPLO DE IMPLEMENTAÇÃO PARA SERVIR DE BASE – Implementação Java + JPA + PostgreSQL


1. Entidades

Professional.java

// src/main/java/br/org/apae/api/appointment/domain/model/Professional.java
package br.org.apae.api.appointment.domain.model;

import jakarta.persistence.*;
import org.hibernate.annotations.*;
import java.util.UUID;

@Entity
@Table(name = "profissional")
public class Professional {

    @Id 
    @GeneratedValue
    private UUID id;

    @Column(name = "nome", nullable = false, length = 150)
    private String name;

    @Column(name = "numero_registro", length = 20)
    private String registrationNumber;

    public Professional() {}

    public Professional(String name, String registrationNumber) {
        this.name = name;
        this.registrationNumber = registrationNumber;
    }

    public UUID getId() { return id; }
    public void setId(UUID id) { this.id = id; }

    public String getName() { return name; }
    public void setName(String name) { this.name = name; }

    public String getRegistrationNumber() { return registrationNumber; }
    public void setRegistrationNumber(String registrationNumber) { 
        this.registrationNumber = registrationNumber; 
    }
}

AnnualRegistration.java

// src/main/java/br/org/apae/api/appointment/domain/model/AnnualRegistration.java
package br.org.apae.api.appointment.domain.model;

import jakarta.persistence.*;
import org.hibernate.annotations.*;
import java.time.LocalDate;
import java.util.UUID;

@Entity
@Table(name = "cadastro_anual",
       uniqueConstraints = @UniqueConstraint(columnNames = {"paciente_id", "ano"}),
       indexes = {
           @Index(name = "idx_cadastro_anual_paciente_ano", columnList = "paciente_id,ano"),
           @Index(name = "idx_cadastro_anual_datas", columnList = "data_inicio,data_fim")
       })
public class AnnualRegistration {

    @Id 
    @GeneratedValue
    private UUID id;

    @Column(name = "paciente_id", nullable = false)
    private UUID patientId;

    @Column(name = "ano", nullable = false)
    private Integer year;

    @Column(name = "data_inicio", nullable = false)
    private LocalDate startDate;

    @Column(name = "data_fim")
    private LocalDate endDate;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "profissional_id", nullable = false)
    private Professional professional;

    public AnnualRegistration() {}

    public AnnualRegistration(UUID patientId, Integer year, LocalDate startDate, 
                              LocalDate endDate, Professional professional) {
        this.patientId = patientId;
        this.year = year;
        this.startDate = startDate;
        this.endDate = endDate;
        this.professional = professional;
    }

    public UUID getId() { return id; }
    public void setId(UUID id) { this.id = id; }

    public UUID getPatientId() { return patientId; }
    public void setPatientId(UUID patientId) { this.patientId = patientId; }

    public Integer getYear() { return year; }
    public void setYear(Integer year) { this.year = year; }

    public LocalDate getStartDate() { return startDate; }
    public void setStartDate(LocalDate startDate) { this.startDate = startDate; }

    public LocalDate getEndDate() { return endDate; }
    public void setEndDate(LocalDate endDate) { this.endDate = endDate; }

    public Professional getProfessional() { return professional; }
    public void setProfessional(Professional professional) { 
        this.professional = professional; 
    }
}

Appointment.java

// src/main/java/br/org/apae/api/appointment/domain/model/Appointment.java
package br.org.apae.api.appointment.domain.model;

import jakarta.persistence.*;
import org.hibernate.annotations.*;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.UUID;

@Entity
@Table(name = "agendamento",
       indexes = {
           @Index(name = "idx_agendamento_cadastro", columnList = "cadastro_anual_id"),
           @Index(name = "idx_agendamento_ativo", columnList = "ativo,data_fim_regra")
       })
public class Appointment {

    @Id 
    @GeneratedValue
    private UUID id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "cadastro_anual_id", nullable = false)
    private AnnualRegistration annualRegistration;

    @Column(name = "frequencia_dias", nullable = false)
    private Integer frequencyDays;

    @Column(name = "hora_consulta", nullable = false)
    private LocalTime appointmentTime;

    @Column(name = "ativo", nullable = false)
    private Boolean active = true;

    @Column(name = "data_inicio", nullable = false)
    private LocalDate startDate;

    @Column(name = "data_fim")
    private LocalDate endDate;

    public Appointment() {}

    public Appointment(AnnualRegistration annualRegistration, Integer frequencyDays, 
                       LocalTime appointmentTime, LocalDate ruleStartDate) {
        this.annualRegistration = annualRegistration;
        this.frequencyDays = frequencyDays;
        this.appointmentTime = appointmentTime;
        this.ruleStartDate = ruleStartDate;
        this.active = true;
    }

    @PrePersist
    private void prePersist() {
        if (active == null) active = true;
    }

    public UUID getId() { return id; }
    public void setId(UUID id) { this.id = id; }

    public AnnualRegistration getAnnualRegistration() { return annualRegistration; }
    public void setAnnualRegistration(AnnualRegistration annualRegistration) { 
        this.annualRegistration = annualRegistration; 
    }

    public Integer getFrequencyDays() { return frequencyDays; }
    public void setFrequencyDays(Integer frequencyDays) { 
        this.frequencyDays = frequencyDays; 
    }

    public LocalTime getAppointmentTime() { return appointmentTime; }
    public void setAppointmentTime(LocalTime appointmentTime) { 
        this.appointmentTime = appointmentTime; 
    }

    public Boolean getActive() { return active; }
    public void setActive(Boolean active) { this.active = active; }

    public LocalDate getRuleStartDate() { return ruleStartDate; }
    public void setRuleStartDate(LocalDate ruleStartDate) { 
        this.ruleStartDate = ruleStartDate; 
    }

    public LocalDate getRuleEndDate() { return ruleEndDate; }
    public void setRuleEndDate(LocalDate ruleEndDate) { 
        this.ruleEndDate = ruleEndDate; 
    }
}

GeneratedAppointment.java

// src/main/java/br/org/apae/api/appointment/domain/model/GeneratedAppointment.java
package br.org.apae.api.appointment.domain.model;

import jakarta.persistence.*;
import org.hibernate.annotations.*;
import java.time.LocalDateTime;
import java.util.UUID;

@Entity
@Table(name = "agendamento_gerado",
       indexes = {
           @Index(name = "idx_gerado_agendamento", columnList = "agendamento_id"),
           @Index(name = "idx_gerado_data", columnList = "data_hora_agendada"),
           @Index(name = "idx_gerado_paciente", columnList = "paciente_id,data_hora_agendada")
       })
public class GeneratedAppointment {

    @Id 
    @GeneratedValue
    private UUID id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "agendamento_id", nullable = false)
    private Appointment appointment;

    @Column(name = "data_hora_agendada", nullable = false)
    private LocalDateTime scheduledDateTime;

    @Column(name = "data_hora_sobrescrita")
    private LocalDateTime overriddenDateTime;

    @Column(name = "realizada", nullable = false)
    private Boolean performed = false;

    @Column(name = "cancelada", nullable = false)
    private Boolean cancelled = false;

    @Column(name = "motivo_cancelamento", length = 500)
    private String cancellationReason;

    @Column(name = "paciente_id", nullable = false, updatable = false)
    private UUID patientId;

    @PrePersist 
    @PreUpdate
    private void ensurePatientId() {
        if (appointment != null && appointment.getAnnualRegistration() != null) {
            this.patientId = appointment.getAnnualRegistration().getPatientId();
        }
    }

    public LocalDateTime getEffectiveDateTime() {
        return overriddenDateTime != null ? overriddenDateTime : scheduledDateTime;
    }

    public GeneratedAppointment() {}

    public GeneratedAppointment(Appointment appointment, LocalDateTime scheduledDateTime) {
        this.appointment = appointment;
        this.scheduledDateTime = scheduledDateTime;
        this.performed = false;
        this.cancelled = false;
    }

    public UUID getId() { return id; }
    public void setId(UUID id) { this.id = id; }

    public Appointment getAppointment() { return appointment; }
    public void setAppointment(Appointment appointment) { this.appointment = appointment; }

    public LocalDateTime getScheduledDateTime() { return scheduledDateTime; }
    public void setScheduledDateTime(LocalDateTime scheduledDateTime) { 
        this.scheduledDateTime = scheduledDateTime; 
    }

    public LocalDateTime getOverriddenDateTime() { return overriddenDateTime; }
    public void setOverriddenDateTime(LocalDateTime overriddenDateTime) { 
        this.overriddenDateTime = overriddenDateTime; 
    }

    public Boolean getPerformed() { return performed; }
    public void setPerformed(Boolean performed) { this.performed = performed; }

    public Boolean getCancelled() { return cancelled; }
    public void setCancelled(Boolean cancelled) { this.cancelled = cancelled; }

    public String getCancellationReason() { return cancellationReason; }
    public void setCancellationReason(String cancellationReason) { 
        this.cancellationReason = cancellationReason; 
    }

    public UUID getPatientId() { return patientId; }
    public void setPatientId(UUID patientId) { this.patientId = patientId; }
}

Absence.java

// src/main/java/br/org/apae/api/appointment/domain/model/Absence.java
package br.org.apae.api.appointment.domain.model;

import jakarta.persistence.*;
import org.hibernate.annotations.*;
import java.time.LocalDate;
import java.util.UUID;

@Entity
@Table(name = "falta",
       indexes = @Index(name = "idx_falta_gerado", columnList = "agendamento_gerado_id"))
public class Absence {

    @Id 
    @GeneratedValue
    private UUID id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "agendamento_gerado_id", nullable = false)
    private GeneratedAppointment generatedAppointment;

    @Column(name = "data_falta", nullable = false)
    private LocalDate absenceDate;

    @Column(name = "justificativa", length = 500)
    private String justification;

    @Column(name = "notificado", nullable = false)
    private Boolean notified = false;

    public Absence() {}

    public Absence(GeneratedAppointment generatedAppointment, LocalDate absenceDate, 
                   String justification) {
        this.generatedAppointment = generatedAppointment;
        this.absenceDate = absenceDate;
        this.justification = justification;
        this.notified = false;
    }

    public UUID getId() { return id; }
    public void setId(UUID id) { this.id = id; }

    public GeneratedAppointment getGeneratedAppointment() { return generatedAppointment; }
    public void setGeneratedAppointment(GeneratedAppointment generatedAppointment) { 
        this.generatedAppointment = generatedAppointment; 
    }

    public LocalDate getAbsenceDate() { return absenceDate; }
    public void setAbsenceDate(LocalDate absenceDate) { this.absenceDate = absenceDate; }

    public String getJustification() { return justification; }
    public void setJustification(String justification) { 
        this.justification = justification; 
    }

    public Boolean getNotified() { return notified; }
    public void setNotified(Boolean notified) { this.notified = notified; }
}

2. Repositórios

AnnualRegistrationRepository.java

package br.org.apae.api.appointment.domain.repository;

import br.org.apae.api.appointment.domain.model.AnnualRegistration;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
import java.util.UUID;

@Repository
public interface AnnualRegistrationRepository extends JpaRepository<AnnualRegistration, UUID> {
    Optional<AnnualRegistration> findByPatientIdAndYear(UUID patientId, Integer year);
}

AppointmentRepository.java

package br.org.apae.api.appointment.domain.repository;

import br.org.apae.api.appointment.domain.model.Appointment;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;

@Repository
public interface AppointmentRepository extends JpaRepository<Appointment, UUID> {
    List<Appointment> findByAnnualRegistrationIdAndActiveTrue(UUID registrationId);
    
    Optional<Appointment> findByAnnualRegistrationIdAndActiveTrueOrderByRuleStartDateDesc(
        UUID registrationId);
}

GeneratedAppointmentRepository.java

package br.org.apae.api.appointment.domain.repository;

import br.org.apae.api.appointment.domain.model.GeneratedAppointment;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
import java.util.Optional;
import java.util.UUID;

@Repository
public interface GeneratedAppointmentRepository extends JpaRepository<GeneratedAppointment, UUID> {

    Page<GeneratedAppointment> findByPatientIdAndScheduledDateTimeBetween(
        UUID patientId, LocalDateTime start, LocalDateTime end, Pageable pageable);

    Optional<GeneratedAppointment> findByAppointmentIdAndScheduledDateTime(
        UUID appointmentId, LocalDateTime dateTime);

    @Modifying
    @Query("DELETE FROM GeneratedAppointment ga WHERE ga.appointment.id = :appointmentId " +
           "AND ga.scheduledDateTime >= :cutoff")
    void deleteFutureByAppointmentId(UUID appointmentId, LocalDateTime cutoff);
}

AbsenceRepository.java

package br.org.apae.api.appointment.domain.repository;

import br.org.apae.api.appointment.domain.model.Absence;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.UUID;

@Repository
public interface AbsenceRepository extends JpaRepository<Absence, UUID> {}

3. Service Principal

AppointmentService.java

// src/main/java/br/org/apae/api/appointment/application/internal/AppointmentService.java
package br.org.apae.api.appointment.application.internal;

import br.org.apae.api.appointment.domain.model.*;
import br.org.apae.api.appointment.domain.repository.*;
import jakarta.transaction.Transactional;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import java.time.*;
import java.util.*;

@Service
@Transactional
public class AppointmentService {

    private final AnnualRegistrationRepository registrationRepo;
    private final AppointmentRepository appointmentRepo;
    private final GeneratedAppointmentRepository generatedRepo;
    private final AbsenceRepository absenceRepo;

    public AppointmentService(AnnualRegistrationRepository registrationRepo,
                              AppointmentRepository appointmentRepo,
                              GeneratedAppointmentRepository generatedRepo,
                              AbsenceRepository absenceRepo) {
        this.registrationRepo = registrationRepo;
        this.appointmentRepo = appointmentRepo;
        this.generatedRepo = generatedRepo;
        this.absenceRepo = absenceRepo;
    }

    public List<GeneratedAppointment> generateAppointments(UUID registrationId, 
                                                           LocalDate start, 
                                                           LocalDate end) {
        AnnualRegistration registration = registrationRepo.findById(registrationId)
            .orElseThrow(() -> new IllegalArgumentException("Cadastro anual não encontrado"));

        Appointment activeRule = appointmentRepo
            .findByAnnualRegistrationIdAndActiveTrue(registrationId)
            .stream().findFirst()
            .orElseThrow(() -> new IllegalStateException("Nenhuma regra ativa"));

        LocalDateTime startDt = start.atStartOfDay();
        LocalDateTime endDt = end.atTime(23, 59, 59);

        List<LocalDateTime> dates = calculateRecurrence(
            activeRule.getRuleStartDate(),
            activeRule.getFrequencyDays(),
            activeRule.getAppointmentTime(),
            start, 
            end
        );

        List<GeneratedAppointment> generated = new ArrayList<>();
        for (LocalDateTime dt : dates) {
            if (dt.isBefore(startDt) || dt.isAfter(endDt)) continue;

            GeneratedAppointment existing = generatedRepo
                .findByAppointmentIdAndScheduledDateTime(activeRule.getId(), dt)
                .orElse(null);

            if (existing == null) {
                existing = new GeneratedAppointment(activeRule, dt);
                generatedRepo.save(existing);
            }
            generated.add(existing);
        }
        return generated;
    }

    private List<LocalDateTime> calculateRecurrence(LocalDate ruleStart, 
                                                     int frequencyDays, 
                                                     LocalTime time,
                                                     LocalDate queryStart, 
                                                     LocalDate queryEnd) {
        List<LocalDateTime> result = new ArrayList<>();
        LocalDate date = ruleStart.isBefore(queryStart) ? queryStart : ruleStart;

        while (!date.isAfter(queryEnd)) {
            result.add(date.atTime(time));
            date = date.plusDays(frequencyDays);
        }
        return result;
    }

    public Appointment updateRule(UUID ruleId, Integer newFrequency, LocalTime newTime) {
        Appointment current = appointmentRepo.findById(ruleId)
            .orElseThrow(() -> new IllegalArgumentException("Regra não encontrada"));

        if (!current.getActive()) {
            throw new IllegalStateException("Apenas regras ativas podem ser editadas");
        }

        LocalDate editDate = LocalDate.now();

        // Inativa regra atual
        current.setActive(false);
        current.setRuleEndDate(editDate.minusDays(1));
        appointmentRepo.save(current);

        // Cria nova regra
        Appointment newRule = new Appointment(
            current.getAnnualRegistration(),
            newFrequency != null ? newFrequency : current.getFrequencyDays(),
            newTime != null ? newTime : current.getAppointmentTime(),
            editDate
        );
        newRule = appointmentRepo.save(newRule);

        // Gera novos agendamentos e remove antigos
        generateAppointments(
            current.getAnnualRegistration().getId(), 
            editDate, 
            editDate.plusYears(1)
        );
        generatedRepo.deleteFutureByAppointmentId(current.getId(), editDate.atStartOfDay());

        return newRule;
    }

    public GeneratedAppointment reschedule(UUID appointmentId, LocalDateTime newDateTime) {
        GeneratedAppointment appt = generatedRepo.findById(appointmentId)
            .orElseThrow(() -> new IllegalArgumentException("Agendamento não encontrado"));
        appt.setOverriddenDateTime(newDateTime);
        return generatedRepo.save(appt);
    }

    public GeneratedAppointment markAsPerformed(UUID appointmentId) {
        GeneratedAppointment appt = generatedRepo.findById(appointmentId)
            .orElseThrow(() -> new IllegalArgumentException("Agendamento não encontrado"));
        appt.setPerformed(true);
        return generatedRepo.save(appt);
    }

    public GeneratedAppointment cancel(UUID appointmentId, String reason) {
        GeneratedAppointment appt = generatedRepo.findById(appointmentId)
            .orElseThrow(() -> new IllegalArgumentException("Agendamento não encontrado"));
        appt.setCancelled(true);
        appt.setCancellationReason(reason);
        return generatedRepo.save(appt);
    }

    public Page<GeneratedAppointment> listByPatient(UUID patientId, 
                                                     LocalDate start, 
                                                     LocalDate end, 
                                                     Pageable pageable) {
        LocalDateTime s = start.atStartOfDay();
        LocalDateTime e = end.atTime(23, 59, 59);
        return generatedRepo.findByPatientIdAndScheduledDateTimeBetween(patientId, s, e, pageable);
    }

    public AnnualRegistration renewRegistration(UUID patientId, 
                                                Integer newYear, 
                                                Professional professional) {
        if (newYear <= Year.now().getValue()) {
            throw new IllegalArgumentException("Ano deve ser futuro");
        }

        AnnualRegistration previous = registrationRepo
            .findByPatientIdAndYear(patientId, newYear - 1)
            .orElseThrow(() -> new IllegalStateException("Cadastro anterior não encontrado"));

        AnnualRegistration newReg = new AnnualRegistration(
            patientId,
            newYear,
            LocalDate.of(newYear, 1, 1),
            LocalDate.of(newYear, 12, 31),
            professional
        );
        newReg = registrationRepo.save(newReg);

        // Copia regra ativa do ano anterior
        appointmentRepo.findByAnnualRegistrationIdAndActiveTrue(previous.getId())
            .stream().findFirst()
            .ifPresent(oldRule -> {
                Appointment newRule = new Appointment(
                    newReg,
                    oldRule.getFrequencyDays(),
                    oldRule.getAppointmentTime(),
                    newReg.getStartDate()
                );
                appointmentRepo.save(newRule);
                generateAppointments(newReg.getId(), newReg.getStartDate(), newReg.getEndDate());
            });

        return newReg;
    }
}

@coderabbitai
Copy link

coderabbitai bot commented Oct 29, 2025

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch 299-refactor-database-agendamentos

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants