TripService.java
6,885 bytes
| 1 | package com.tasteprint.journey; |
|---|---|
| 2 | |
| 3 | import java.time.Clock; |
| 4 | import java.time.LocalDate; |
| 5 | import java.util.ArrayList; |
| 6 | import java.util.Comparator; |
| 7 | import java.util.HashSet; |
| 8 | import java.util.List; |
| 9 | import java.util.Locale; |
| 10 | import java.util.Optional; |
| 11 | import java.util.Set; |
| 12 | import java.util.UUID; |
| 13 | |
| 14 | import org.springframework.stereotype.Service; |
| 15 | import org.springframework.transaction.annotation.Transactional; |
| 16 | |
| 17 | import com.tasteprint.catalog.CatalogService; |
| 18 | import com.tasteprint.catalog.DestinationDetails; |
| 19 | import com.tasteprint.catalog.DishCategory; |
| 20 | import com.tasteprint.catalog.DishView; |
| 21 | import com.tasteprint.shared.NotFoundException; |
| 22 | import com.tasteprint.tasting.TastingService; |
| 23 | |
| 24 | @Service |
| 25 | public class TripService { |
| 26 | |
| 27 | private static final int MISSION_SIZE = 5; |
| 28 | |
| 29 | private final TripRepository trips; |
| 30 | private final TripMissionItemRepository missionItems; |
| 31 | private final CatalogService catalog; |
| 32 | private final TastingService tastings; |
| 33 | private final Clock clock; |
| 34 | |
| 35 | TripService(TripRepository trips, TripMissionItemRepository missionItems, |
| 36 | CatalogService catalog, TastingService tastings, Clock clock) { |
| 37 | this.trips = trips; |
| 38 | this.missionItems = missionItems; |
| 39 | this.catalog = catalog; |
| 40 | this.tastings = tastings; |
| 41 | this.clock = clock; |
| 42 | } |
| 43 | |
| 44 | @Transactional |
| 45 | public TripView create(UUID userId, SaveTripRequest request) { |
| 46 | String destinationCode = normalizeCountryCode(request.destinationCode()); |
| 47 | DestinationDetails destination = catalog.destination(destinationCode); |
| 48 | Trip trip = trips.save(new Trip( |
| 49 | UUID.randomUUID(), userId, destinationCode, request, clock.instant() |
| 50 | )); |
| 51 | |
| 52 | List<DishView> mission = chooseMission(destination.dishes(), tastings.triedDishSlugs(userId)); |
| 53 | for (int index = 0; index < mission.size(); index++) { |
| 54 | missionItems.save(new TripMissionItem(UUID.randomUUID(), trip.id(), mission.get(index).slug(), index + 1)); |
| 55 | } |
| 56 | return view(trip); |
| 57 | } |
| 58 | |
| 59 | @Transactional |
| 60 | public TripView update(UUID userId, UUID tripId, SaveTripRequest request) { |
| 61 | Trip trip = requiredOwned(tripId, userId); |
| 62 | String requestedCode = normalizeCountryCode(request.destinationCode()); |
| 63 | if (!trip.destinationCode().equals(requestedCode)) { |
| 64 | throw new IllegalArgumentException("Create a new trip to change the destination."); |
| 65 | } |
| 66 | trip.update(request, clock.instant()); |
| 67 | return view(trip); |
| 68 | } |
| 69 | |
| 70 | @Transactional |
| 71 | public void delete(UUID userId, UUID tripId) { |
| 72 | trips.delete(requiredOwned(tripId, userId)); |
| 73 | } |
| 74 | |
| 75 | @Transactional(readOnly = true) |
| 76 | public List<TripView> list(UUID userId) { |
| 77 | return trips.findByUserIdOrderByStartsOnAsc(userId).stream() |
| 78 | .map(this::view) |
| 79 | .sorted(Comparator.comparing((TripView trip) -> statusOrder(trip.status())) |
| 80 | .thenComparing(TripView::startsOn)) |
| 81 | .toList(); |
| 82 | } |
| 83 | |
| 84 | @Transactional(readOnly = true) |
| 85 | public TripView get(UUID userId, UUID tripId) { |
| 86 | return view(requiredOwned(tripId, userId)); |
| 87 | } |
| 88 | |
| 89 | @Transactional(readOnly = true) |
| 90 | public Optional<TripView> next(UUID userId) { |
| 91 | return list(userId).stream() |
| 92 | .filter(trip -> trip.status() != TripStatus.COMPLETED) |
| 93 | .findFirst(); |
| 94 | } |
| 95 | |
| 96 | @Transactional(readOnly = true) |
| 97 | public boolean hasAny(UUID userId) { |
| 98 | return trips.existsByUserId(userId); |
| 99 | } |
| 100 | |
| 101 | private TripView view(Trip trip) { |
| 102 | DestinationDetails destination = catalog.destination(trip.destinationCode()); |
| 103 | Set<String> completedDuringTrip = tastings.triedDishSlugsDuring( |
| 104 | trip.userId(), trip.destinationCode(), trip.startsOn(), trip.endsOn() |
| 105 | ); |
| 106 | List<MissionItemView> mission = missionItems.findByTripIdOrderByPositionAsc(trip.id()).stream() |
| 107 | .map(item -> new MissionItemView( |
| 108 | item.position(), catalog.dish(item.dishSlug()), completedDuringTrip.contains(item.dishSlug()) |
| 109 | )) |
| 110 | .toList(); |
| 111 | int coverage = weightedCoverage(destination.dishes(), completedDuringTrip); |
| 112 | int completed = (int) mission.stream().filter(MissionItemView::completed).count(); |
| 113 | return new TripView( |
| 114 | trip.id(), destination.destination(), trip.city(), trip.startsOn(), trip.endsOn(), |
| 115 | status(trip), coverage, completed, mission |
| 116 | ); |
| 117 | } |
| 118 | |
| 119 | private List<DishView> chooseMission(List<DishView> dishes, Set<String> alreadyTried) { |
| 120 | List<DishView> sorted = dishes.stream() |
| 121 | .sorted(Comparator |
| 122 | .comparing((DishView dish) -> alreadyTried.contains(dish.slug())) |
| 123 | .thenComparing(DishView::importance, Comparator.reverseOrder())) |
| 124 | .toList(); |
| 125 | List<DishView> selected = new ArrayList<>(); |
| 126 | Set<DishCategory> usedCategories = new HashSet<>(); |
| 127 | for (DishView dish : sorted) { |
| 128 | if (usedCategories.add(dish.category())) { |
| 129 | selected.add(dish); |
| 130 | } |
| 131 | if (selected.size() == MISSION_SIZE) { |
| 132 | break; |
| 133 | } |
| 134 | } |
| 135 | if (selected.size() < MISSION_SIZE) { |
| 136 | for (DishView dish : sorted) { |
| 137 | if (!selected.contains(dish)) { |
| 138 | selected.add(dish); |
| 139 | } |
| 140 | if (selected.size() == MISSION_SIZE) { |
| 141 | break; |
| 142 | } |
| 143 | } |
| 144 | } |
| 145 | return selected; |
| 146 | } |
| 147 | |
| 148 | private int weightedCoverage(List<DishView> dishes, Set<String> completed) { |
| 149 | int totalWeight = dishes.stream().mapToInt(DishView::importance).sum(); |
| 150 | int completedWeight = dishes.stream() |
| 151 | .filter(dish -> completed.contains(dish.slug())) |
| 152 | .mapToInt(DishView::importance) |
| 153 | .sum(); |
| 154 | return totalWeight == 0 ? 0 : (int) Math.round(completedWeight * 100.0 / totalWeight); |
| 155 | } |
| 156 | |
| 157 | private TripStatus status(Trip trip) { |
| 158 | LocalDate today = LocalDate.now(clock); |
| 159 | if (today.isBefore(trip.startsOn())) { |
| 160 | return TripStatus.UPCOMING; |
| 161 | } |
| 162 | if (today.isAfter(trip.endsOn())) { |
| 163 | return TripStatus.COMPLETED; |
| 164 | } |
| 165 | return TripStatus.ACTIVE; |
| 166 | } |
| 167 | |
| 168 | private int statusOrder(TripStatus status) { |
| 169 | return switch (status) { |
| 170 | case ACTIVE -> 0; |
| 171 | case UPCOMING -> 1; |
| 172 | case COMPLETED -> 2; |
| 173 | }; |
| 174 | } |
| 175 | |
| 176 | private Trip requiredOwned(UUID tripId, UUID userId) { |
| 177 | return trips.findByIdAndUserId(tripId, userId) |
| 178 | .orElseThrow(() -> new NotFoundException("Trip was not found.")); |
| 179 | } |
| 180 | |
| 181 | private String normalizeCountryCode(String countryCode) { |
| 182 | return countryCode.trim().toUpperCase(Locale.ROOT); |
| 183 | } |
| 184 | } |
| 185 | |