Fix employee time conflicts

This commit is contained in:
2026-04-06 00:18:49 -06:00
parent b70afd66aa
commit 9ea5efe44e
4 changed files with 73 additions and 44 deletions

View File

@@ -36,4 +36,7 @@ public interface AppointmentRepository extends JpaRepository<Appointment, Long>
"LOWER(a.service.serviceName) LIKE LOWER(CONCAT('%', :q, '%')) OR " +
"LOWER(p.petName) LIKE LOWER(CONCAT('%', :q, '%')))")
Page<Appointment> searchAppointmentsByCustomer(@Param("customerId") Long customerId, @Param("q") String query, Pageable pageable);
@Query("SELECT a FROM Appointment a JOIN FETCH a.service WHERE a.employee.employeeId = :employeeId AND a.appointmentDate = :date AND LOWER(a.appointmentStatus) <> 'cancelled'")
List<Appointment> findByEmployeeEmployeeIdAndAppointmentDate(@Param("employeeId") Long employeeId, @Param("date") LocalDate date);
}

View File

@@ -110,14 +110,10 @@ public class AppointmentService {
com.petshop.backend.entity.Service service = serviceRepository.findById(request.getServiceId())
.orElseThrow(() -> new ResourceNotFoundException("Service not found with id: " + request.getServiceId()));
validateStoreAccess(store.getStoreId(), authenticatedUser);
validateAvailability(store, service, request.getAppointmentDate(), request.getAppointmentTime(), null);
boolean hasPetIds = request.getPetIds() != null && !request.getPetIds().isEmpty();
boolean hasCustomerPetIds = request.getCustomerPetIds() != null && !request.getCustomerPetIds().isEmpty();
if (!hasPetIds && !hasCustomerPetIds) {
throw new IllegalArgumentException("Please specify at least one pet.");
}
@@ -125,6 +121,9 @@ public class AppointmentService {
Set<CustomerPet> customerPets = hasCustomerPetIds ? fetchCustomerPets(request.getCustomerPetIds(), customer.getCustomerId()) : new HashSet<>();
Employee employee = resolveAppointmentEmployee(request.getEmployeeId(), store.getStoreId());
validateStoreAccess(store.getStoreId(), authenticatedUser);
validateAvailability(employee, service, request.getAppointmentDate(), request.getAppointmentTime(), null);
Appointment appointment = new Appointment();
appointment.setCustomer(customer);
appointment.setStore(store);
@@ -158,14 +157,10 @@ public class AppointmentService {
com.petshop.backend.entity.Service service = serviceRepository.findById(request.getServiceId())
.orElseThrow(() -> new ResourceNotFoundException("Service not found with id: " + request.getServiceId()));
validateStoreAccess(store.getStoreId(), authenticatedUser);
validateAvailability(store, service, request.getAppointmentDate(), request.getAppointmentTime(), id);
boolean hasPetIds = request.getPetIds() != null && !request.getPetIds().isEmpty();
boolean hasCustomerPetIds = request.getCustomerPetIds() != null && !request.getCustomerPetIds().isEmpty();
if (!hasPetIds && !hasCustomerPetIds) {
throw new IllegalArgumentException("Please specify at least one pet.");
}
@@ -173,6 +168,9 @@ public class AppointmentService {
Set<CustomerPet> customerPets = hasCustomerPetIds ? fetchCustomerPets(request.getCustomerPetIds(), customer.getCustomerId()) : new HashSet<>();
Employee employee = resolveAppointmentEmployee(request.getEmployeeId(), store.getStoreId());
validateStoreAccess(store.getStoreId(), authenticatedUser);
validateAvailability(employee, service, request.getAppointmentDate(), request.getAppointmentTime(), id);
appointment.setCustomer(customer);
appointment.setStore(store);
appointment.setService(service);
@@ -201,7 +199,6 @@ public class AppointmentService {
}
@Transactional(readOnly = true)
public List<String> checkAvailability(Long storeId, Long serviceId, LocalDate date) {
storeRepository.findById(storeId)
.orElseThrow(() -> new ResourceNotFoundException("Store not found with id: " + storeId));
@@ -209,16 +206,10 @@ public class AppointmentService {
com.petshop.backend.entity.Service service = serviceRepository.findById(serviceId)
.orElseThrow(() -> new ResourceNotFoundException("Service not found with id: " + serviceId));
//--------------------------------------------------------------------------
// CHANGED: filter by serviceId too
List<Appointment> existingAppointments = appointmentRepository
.findByStoreAndDate(storeId, date)
.stream()
.filter(a -> a.getService().getServiceId().equals(serviceId))
List<Employee> assignableEmployees = employeeStoreRepository.findActiveByStoreStoreIdOrderByEmployeeEmployeeIdAsc(storeId).stream()
.filter(es -> isAssignableEmployee(es.getEmployee()))
.map(EmployeeStore::getEmployee)
.collect(Collectors.toList());
// -------------------------------------------------------
List<String> availableSlots = new ArrayList<>();
LocalTime startTime = LocalTime.of(9, 0);
@@ -227,7 +218,13 @@ public class AppointmentService {
LocalTime currentTime = startTime;
while (!currentTime.isAfter(latestStart)) {
if (isSlotAvailable(existingAppointments, service, currentTime, null)) {
final LocalTime slotTime = currentTime;
boolean anyEmployeeAvailable = assignableEmployees.stream().anyMatch(emp -> {
List<Appointment> empAppointments = appointmentRepository.findByEmployeeEmployeeIdAndAppointmentDate(emp.getEmployeeId(), date);
return isSlotAvailable(empAppointments, service, slotTime, null);
});
if (anyEmployeeAvailable) {
availableSlots.add(currentTime.toString());
}
currentTime = currentTime.plusMinutes(30);
@@ -343,15 +340,11 @@ public class AppointmentService {
}
//------------------------------------
private void validateAvailability(StoreLocation store, com.petshop.backend.entity.Service service, LocalDate date, LocalTime time, Long appointmentIdToIgnore) {
// Filter by same service only - different services can run at same time
private void validateAvailability(Employee employee, com.petshop.backend.entity.Service service, LocalDate date, LocalTime time, Long appointmentIdToIgnore) {
List<Appointment> existingAppointments = appointmentRepository
.findByStoreAndDate(store.getStoreId(), date)
.stream()
.filter(a -> a.getService().getServiceId().equals(service.getServiceId()))
.collect(Collectors.toList());
.findByEmployeeEmployeeIdAndAppointmentDate(employee.getEmployeeId(), date);
if (!isSlotAvailable(existingAppointments, service, time, appointmentIdToIgnore)) {
throw new IllegalArgumentException("Appointment time is not available for the selected store and service");
throw new IllegalArgumentException("The selected employee is already booked for this time slot");
}
}

View File

@@ -0,0 +1,28 @@
-- V17: Normalize legacy appointmentPet data into customer_pet and appointment_customer_pet
-- Step 1: Ensure a customer_pet exists for every pet linked in appointmentPet
-- Note: pet species and breed might be null in pet table, but we copy them over if present
INSERT INTO customer_pet (customer_id, pet_name, species, breed)
SELECT DISTINCT a.customerId, p.petName, p.petSpecies, p.petBreed
FROM appointmentPet ap
JOIN appointment a ON a.appointmentId = ap.appointmentId
JOIN pet p ON p.petId = ap.petId
WHERE NOT EXISTS (
SELECT 1 FROM customer_pet cp
WHERE cp.customer_id = a.customerId AND cp.pet_name = p.petName
);
-- Step 2: Link the appointment to the customer_pet
INSERT INTO appointment_customer_pet (appointment_id, customer_pet_id)
SELECT ap.appointmentId, cp.customer_pet_id
FROM appointmentPet ap
JOIN appointment a ON a.appointmentId = ap.appointmentId
JOIN pet p ON p.petId = ap.petId
JOIN customer_pet cp ON cp.customer_id = a.customerId AND cp.pet_name = p.petName
WHERE NOT EXISTS (
SELECT 1 FROM appointment_customer_pet acp
WHERE acp.appointment_id = ap.appointmentId AND acp.customer_pet_id = cp.customer_pet_id
);
-- Step 3: Remove the old legacy relationships so it strictly uses the new ones
DELETE FROM appointmentPet;

View File

@@ -130,29 +130,34 @@ class AppointmentServiceTest {
}
@Test
void checkAvailabilityAllowsDifferentServicesAtSameTime() {
void checkAvailabilityAllowsConcurrentAppointmentsIfAnotherEmployeeFree() {
Employee employee2 = new Employee();
employee2.setEmployeeId(8L);
employee2.setUserId(8L);
employee2.setFirstName("Bob");
employee2.setIsActive(true);
User staffUser2 = new User();
staffUser2.setId(8L);
staffUser2.setRole(User.Role.STAFF);
staffUser2.setActive(true);
when(userRepository.findById(8L)).thenReturn(Optional.of(staffUser2));
Appointment existing = appointment(1L, date, LocalTime.of(10, 0), grooming, store);
when(storeRepository.findById(1L)).thenReturn(Optional.of(store));
when(serviceRepository.findById(2L)).thenReturn(Optional.of(nailTrim));
when(appointmentRepository.findByStoreAndDate(1L, date)).thenReturn(List.of(existing));
when(employeeStoreRepository.findActiveByStoreStoreIdOrderByEmployeeEmployeeIdAsc(1L))
.thenReturn(List.of(new EmployeeStore(employee, store), new EmployeeStore(employee2, store)));
when(appointmentRepository.findByEmployeeEmployeeIdAndAppointmentDate(7L, date)).thenReturn(List.of(existing));
when(appointmentRepository.findByEmployeeEmployeeIdAndAppointmentDate(8L, date)).thenReturn(List.of());
List<String> slots = appointmentService.checkAvailability(1L, 2L, date);
assertTrue(slots.contains("10:00"));
}
@Test
void checkAvailabilityBlocksSameServiceAtSameTime() {
Appointment existing = appointment(1L, date, LocalTime.of(10, 0), grooming, store);
when(storeRepository.findById(1L)).thenReturn(Optional.of(store));
when(serviceRepository.findById(1L)).thenReturn(Optional.of(grooming));
when(appointmentRepository.findByStoreAndDate(1L, date)).thenReturn(List.of(existing));
List<String> slots = appointmentService.checkAvailability(1L, 1L, date);
assertFalse(slots.contains("10:00"));
}
@Test
void createAppointmentRejectsCustomerPetOwnedByDifferentCustomerForStaff() {
setAuthentication(7L, User.Role.STAFF);
@@ -172,7 +177,7 @@ class AppointmentServiceTest {
when(serviceRepository.findById(1L)).thenReturn(Optional.of(grooming));
when(employeeStoreRepository.findActiveByStoreStoreIdOrderByEmployeeEmployeeIdAsc(1L))
.thenReturn(List.of(new EmployeeStore(employee, store)));
when(appointmentRepository.findByStoreAndDate(1L, date)).thenReturn(List.of());
when(appointmentRepository.findByEmployeeEmployeeIdAndAppointmentDate(7L, date)).thenReturn(List.of());
when(customerPetRepository.findById(22L)).thenReturn(Optional.of(otherCustomerPet));
var request = new com.petshop.backend.dto.appointment.AppointmentRequest();
@@ -204,7 +209,7 @@ class AppointmentServiceTest {
when(serviceRepository.findById(1L)).thenReturn(Optional.of(grooming));
when(employeeStoreRepository.findActiveByStoreStoreIdOrderByEmployeeEmployeeIdAsc(1L))
.thenReturn(List.of(new EmployeeStore(employee, store)));
when(appointmentRepository.findByStoreAndDate(1L, date)).thenReturn(List.of());
when(appointmentRepository.findByEmployeeEmployeeIdAndAppointmentDate(7L, date)).thenReturn(List.of());
when(customerPetRepository.findById(22L)).thenReturn(Optional.of(otherCustomerPet));
var request = new com.petshop.backend.dto.appointment.AppointmentRequest();
@@ -245,7 +250,7 @@ class AppointmentServiceTest {
when(storeRepository.findById(1L)).thenReturn(Optional.of(store));
when(serviceRepository.findById(1L)).thenReturn(Optional.of(grooming));
when(employeeRepository.findById(8L)).thenReturn(Optional.of(adminEmployee));
when(appointmentRepository.findByStoreAndDate(1L, date)).thenReturn(List.of());
when(appointmentRepository.findByEmployeeEmployeeIdAndAppointmentDate(7L, date)).thenReturn(List.of());
when(customerPetRepository.findById(11L)).thenReturn(Optional.of(customerPet));
when(employeeStoreRepository.findActiveByStoreStoreIdOrderByEmployeeEmployeeIdAsc(1L))
.thenReturn(List.of(new EmployeeStore(adminEmployee, store), new EmployeeStore(employee, store)));
@@ -282,7 +287,7 @@ class AppointmentServiceTest {
when(storeRepository.findById(1L)).thenReturn(Optional.of(store));
when(serviceRepository.findById(1L)).thenReturn(Optional.of(grooming));
when(employeeRepository.findById(7L)).thenReturn(Optional.of(employee));
when(appointmentRepository.findByStoreAndDate(1L, date)).thenReturn(List.of());
when(appointmentRepository.findByEmployeeEmployeeIdAndAppointmentDate(7L, date)).thenReturn(List.of());
when(customerPetRepository.findById(11L)).thenReturn(Optional.of(customerPet));
when(employeeStoreRepository.findActiveByStoreStoreIdOrderByEmployeeEmployeeIdAsc(1L))
.thenReturn(List.of(new EmployeeStore(employee, store)));