| 1 |
<?php |
| 2 |
|
| 3 |
declare(strict_types=1); |
| 4 |
|
| 5 |
namespace Yatra\Services; |
| 6 |
|
| 7 |
use Yatra\Repositories\ItineraryRepository; |
| 8 |
use Yatra\Repositories\TripRepository; |
| 9 |
use InvalidArgumentException; |
| 10 |
|
| 11 |
/** |
| 12 |
* Itinerary Service |
| 13 |
* Business logic for itinerary entries |
| 14 |
*/ |
| 15 |
class ItineraryService |
| 16 |
{ |
| 17 |
private ItineraryRepository $repository; |
| 18 |
private TripRepository $tripRepository; |
| 19 |
|
| 20 |
public function __construct(ItineraryRepository $repository, ?TripRepository $tripRepository = null) |
| 21 |
{ |
| 22 |
$this->repository = $repository; |
| 23 |
$this->tripRepository = $tripRepository ?? new TripRepository(); |
| 24 |
} |
| 25 |
|
| 26 |
/** |
| 27 |
* Validate itinerary entry data |
| 28 |
*/ |
| 29 |
public function validate(array $data, ?int $id = null): void |
| 30 |
{ |
| 31 |
// Required fields |
| 32 |
if (empty($data['trip_id'])) { |
| 33 |
throw new InvalidArgumentException(__('Trip ID is required', 'yatra')); |
| 34 |
} |
| 35 |
|
| 36 |
if (empty($data['day'])) { |
| 37 |
throw new InvalidArgumentException(__('Day number is required', 'yatra')); |
| 38 |
} |
| 39 |
|
| 40 |
// For day entries (item_type_id and item_id are null), title can be empty if day_title is provided |
| 41 |
// For activity entries, title is required |
| 42 |
$isDayEntry = empty($data['item_type_id']) && empty($data['item_id']); |
| 43 |
if (!$isDayEntry && (empty($data['title']) || trim($data['title']) === '')) { |
| 44 |
throw new InvalidArgumentException(__('Title is required', 'yatra')); |
| 45 |
} |
| 46 |
|
| 47 |
// For day entries, if title is empty, use day_title or generate one |
| 48 |
if ($isDayEntry && (empty($data['title']) || trim($data['title']) === '')) { |
| 49 |
$dayNumber = (int) $data['day']; |
| 50 |
$data['title'] = !empty($data['day_title']) ? trim($data['day_title']) : sprintf(__('Day %d', 'yatra'), $dayNumber); |
| 51 |
} |
| 52 |
|
| 53 |
// Validate trip exists |
| 54 |
$trip = $this->tripRepository->find((int) $data['trip_id']); |
| 55 |
if (!$trip) { |
| 56 |
throw new InvalidArgumentException(__('Trip not found', 'yatra')); |
| 57 |
} |
| 58 |
|
| 59 |
// Validate day number |
| 60 |
$dayNumber = (int) $data['day']; |
| 61 |
if ($dayNumber < 1) { |
| 62 |
throw new InvalidArgumentException(__('Day number must be at least 1', 'yatra')); |
| 63 |
} |
| 64 |
|
| 65 |
// Check for duplicate day numbers |
| 66 |
// Only check if item_type_id and item_id are null (meaning it's a day creation/update, not an activity) |
| 67 |
if (empty($data['item_type_id']) && empty($data['item_id'])) { |
| 68 |
global $wpdb; |
| 69 |
|
| 70 |
if (!$wpdb) { |
| 71 |
// If for some reason the global $wpdb is not available, skip duplicate-day validation |
| 72 |
return; |
| 73 |
} |
| 74 |
|
| 75 |
$itineraryRepository = new \Yatra\Repositories\ItineraryRepository(); |
| 76 |
|
| 77 |
// If updating, first check if the current entry is actually a day entry (not an activity) |
| 78 |
if ($id !== null) { |
| 79 |
$currentEntry = $itineraryRepository->getEntryWithRelations($id); |
| 80 |
|
| 81 |
// If the current entry has item_type_id or item_id, it's an activity, not a day entry |
| 82 |
// In this case, we're converting an activity to a day entry, which is unusual but allowed |
| 83 |
// We'll still validate the day number |
| 84 |
if ($currentEntry && (!empty($currentEntry->item_type_id) || !empty($currentEntry->item_id))) { |
| 85 |
// This is converting an activity to a day entry - allow it but validate day number |
| 86 |
// (fall through to day validation below) |
| 87 |
} else if ($currentEntry && $currentEntry->day_id) { |
| 88 |
// Get the current day's day_number |
| 89 |
$currentDay = $itineraryRepository->getDayById((int) $currentEntry->day_id); |
| 90 |
|
| 91 |
if ($currentDay && (int) $currentDay->day_number === $dayNumber) { |
| 92 |
// Same day number - this is allowed (no change) |
| 93 |
return; // No validation error |
| 94 |
} |
| 95 |
} |
| 96 |
} |
| 97 |
|
| 98 |
// Check if day entry already exists (not just day record) |
| 99 |
// A day record can exist without a day entry (when only activities exist) |
| 100 |
$existingDayEntry = $this->repository->findDayEntryByTripAndDayNumber((int) $data['trip_id'], $dayNumber); |
| 101 |
|
| 102 |
if ($existingDayEntry) { |
| 103 |
// Day entry already exists - this should be an update, not a create |
| 104 |
// If updating, we've already checked above - allow it (frontend handles confirmation) |
| 105 |
if ($id !== null) { |
| 106 |
return; // Allow it, frontend handles the confirmation |
| 107 |
} else { |
| 108 |
// Creating new day entry but one already exists - this shouldn't happen |
| 109 |
// The backend's createEntry will handle updating it, so allow it here |
| 110 |
return; // Allow it, backend will update existing entry |
| 111 |
} |
| 112 |
} |
| 113 |
|
| 114 |
// Check if day record exists (for informational purposes, but don't block) |
| 115 |
// Day records can exist without day entries, so we allow creating day entries for existing day records |
| 116 |
$existingDay = $itineraryRepository->findDayByTripAndDayNumber((int) $data['trip_id'], $dayNumber); |
| 117 |
|
| 118 |
// If day record exists but day entry doesn't, allow creation (this is the normal case) |
| 119 |
// Only block if we're creating a completely new day (both record and entry don't exist) |
| 120 |
// and there's a conflict. But since we're creating a day entry, not a day record, |
| 121 |
// we should allow it if the day record exists. |
| 122 |
// The backend's createEntry will handle creating/updating the day record if needed. |
| 123 |
} |
| 124 |
} |
| 125 |
|
| 126 |
/** |
| 127 |
* Create itinerary entry |
| 128 |
*/ |
| 129 |
public function create(array $data): int |
| 130 |
{ |
| 131 |
$this->validate($data); |
| 132 |
|
| 133 |
global $wpdb; |
| 134 |
// Clear any previous errors |
| 135 |
$wpdb->last_error = ''; |
| 136 |
|
| 137 |
$result = $this->repository->createEntry($data); |
| 138 |
|
| 139 |
// Check for database errors |
| 140 |
if ($wpdb->last_error) { |
| 141 |
// Check for duplicate entry error |
| 142 |
if (strpos($wpdb->last_error, 'Duplicate entry') !== false) { |
| 143 |
throw new \Exception('A day with this number already exists for this trip. Please choose a different day number.'); |
| 144 |
} |
| 145 |
|
| 146 |
throw new \Exception('Failed to create itinerary entry. Please try again.'); |
| 147 |
} |
| 148 |
|
| 149 |
return $result; |
| 150 |
} |
| 151 |
|
| 152 |
/** |
| 153 |
* Update itinerary entry |
| 154 |
* @param int $id Entry ID |
| 155 |
* @param array $data Update data |
| 156 |
* @param string|null $mode 'day' or 'activity' to specify which table to update |
| 157 |
*/ |
| 158 |
public function update(int $id, array $data, ?string $mode = null): bool |
| 159 |
{ |
| 160 |
$this->validate($data, $id); |
| 161 |
|
| 162 |
global $wpdb; |
| 163 |
// Clear any previous errors |
| 164 |
$wpdb->last_error = ''; |
| 165 |
|
| 166 |
$result = $this->repository->updateEntry($id, $data, $mode); |
| 167 |
|
| 168 |
// Check for database errors |
| 169 |
if ($wpdb->last_error) { |
| 170 |
// Check for duplicate entry error |
| 171 |
if (strpos($wpdb->last_error, 'Duplicate entry') !== false) { |
| 172 |
throw new \Exception('A day with this number already exists for this trip. Please choose a different day number.'); |
| 173 |
} |
| 174 |
|
| 175 |
throw new \Exception('Failed to update itinerary entry. Please try again.'); |
| 176 |
} |
| 177 |
|
| 178 |
return $result; |
| 179 |
} |
| 180 |
|
| 181 |
/** |
| 182 |
* Bulk-save activities for a single day in one DB transaction-equivalent batch. |
| 183 |
* |
| 184 |
* Accepts an `activities` array; each row with `id` is updated, each row |
| 185 |
* without `id` is created. Returns per-row result objects so the caller can |
| 186 |
* surface partial failures. Cache invalidation fires once at the end (via |
| 187 |
* the existing per-row hooks already invoked by the repository). |
| 188 |
* |
| 189 |
* @param int $dayId The yatra_trip_itinerary_days.id row. |
| 190 |
* @param int $tripId The trip id (used for the create branch). |
| 191 |
* @param array $activities Array of activity payloads. Each row may include |
| 192 |
* an `id` (update) or omit it (create). |
| 193 |
* @return array {created: int, updated: int, results: array} |
| 194 |
*/ |
| 195 |
public function bulkSaveDayActivities(int $dayId, int $tripId, array $activities): array |
| 196 |
{ |
| 197 |
$results = []; |
| 198 |
$created = 0; |
| 199 |
$updated = 0; |
| 200 |
$failed = 0; |
| 201 |
|
| 202 |
foreach ($activities as $i => $row) { |
| 203 |
if (!is_array($row)) { |
| 204 |
$results[] = ['index' => $i, 'ok' => false, 'error' => 'Row is not an object']; |
| 205 |
$failed++; |
| 206 |
continue; |
| 207 |
} |
| 208 |
// The repo expects day_id + trip_id on creates; inject here so the |
| 209 |
// client doesn't have to repeat them per row. |
| 210 |
$row['day_id'] = $dayId; |
| 211 |
if ($tripId > 0 && empty($row['trip_id'])) { |
| 212 |
$row['trip_id'] = $tripId; |
| 213 |
} |
| 214 |
|
| 215 |
try { |
| 216 |
if (!empty($row['id'])) { |
| 217 |
$entryId = (int) $row['id']; |
| 218 |
unset($row['id']); |
| 219 |
$ok = $this->repository->updateEntry($entryId, $row, 'activity'); |
| 220 |
if ($ok) { |
| 221 |
$updated++; |
| 222 |
$results[] = ['index' => $i, 'ok' => true, 'id' => $entryId, 'op' => 'update']; |
| 223 |
} else { |
| 224 |
$failed++; |
| 225 |
$results[] = ['index' => $i, 'ok' => false, 'id' => $entryId, 'error' => 'updateEntry returned false']; |
| 226 |
} |
| 227 |
} else { |
| 228 |
$newId = $this->repository->createEntry($row); |
| 229 |
if ($newId > 0) { |
| 230 |
$created++; |
| 231 |
$results[] = ['index' => $i, 'ok' => true, 'id' => $newId, 'op' => 'create']; |
| 232 |
} else { |
| 233 |
$failed++; |
| 234 |
$results[] = ['index' => $i, 'ok' => false, 'error' => 'createEntry returned 0']; |
| 235 |
} |
| 236 |
} |
| 237 |
} catch (\Throwable $e) { |
| 238 |
$failed++; |
| 239 |
$results[] = ['index' => $i, 'ok' => false, 'error' => $e->getMessage()]; |
| 240 |
} |
| 241 |
} |
| 242 |
|
| 243 |
return [ |
| 244 |
'day_id' => $dayId, |
| 245 |
'trip_id' => $tripId, |
| 246 |
'created' => $created, |
| 247 |
'updated' => $updated, |
| 248 |
'failed' => $failed, |
| 249 |
'results' => $results, |
| 250 |
]; |
| 251 |
} |
| 252 |
|
| 253 |
/** |
| 254 |
* Get itinerary entry by ID |
| 255 |
*/ |
| 256 |
public function find(int $id): ?\stdClass |
| 257 |
{ |
| 258 |
$result = $this->repository->getEntryWithRelations($id); |
| 259 |
return $result; |
| 260 |
} |
| 261 |
|
| 262 |
/** |
| 263 |
* Get activity entry by ID (specifically from entries table) |
| 264 |
*/ |
| 265 |
public function findActivity(int $id): ?\stdClass |
| 266 |
{ |
| 267 |
return $this->repository->getActivityEntry($id); |
| 268 |
} |
| 269 |
|
| 270 |
/** |
| 271 |
* Delete itinerary entry |
| 272 |
* @param int $id Entry ID |
| 273 |
* @param string|null $mode 'day' or 'activity' to specify which table to delete from |
| 274 |
*/ |
| 275 |
public function delete(int $id, ?string $mode = null): bool |
| 276 |
{ |
| 277 |
return $this->repository->delete($id, $mode); |
| 278 |
} |
| 279 |
|
| 280 |
/** |
| 281 |
* Bulk delete itinerary entries |
| 282 |
* @param array $ids Array of entry IDs to delete |
| 283 |
* @return array ['deleted' => count, 'failed' => count] |
| 284 |
*/ |
| 285 |
public function bulkDelete(array $ids, array $dayIds = []): array |
| 286 |
{ |
| 287 |
// Activity entry ids and day ids are validated independently — see |
| 288 |
// ItineraryRepository::bulkDelete() for why they must stay separate. |
| 289 |
$ids = array_values(array_filter(array_map('intval', $ids), function ($id) { |
| 290 |
return $id > 0; |
| 291 |
})); |
| 292 |
$dayIds = array_values(array_filter(array_map('intval', $dayIds), function ($id) { |
| 293 |
return $id > 0; |
| 294 |
})); |
| 295 |
|
| 296 |
if (empty($ids) && empty($dayIds)) { |
| 297 |
return ['deleted' => 0, 'failed' => 0]; |
| 298 |
} |
| 299 |
|
| 300 |
return $this->repository->bulkDelete($ids, $dayIds); |
| 301 |
} |
| 302 |
|
| 303 |
/** |
| 304 |
* Get all itinerary entries for a specific trip |
| 305 |
*/ |
| 306 |
public function getByTripId(int $tripId): array |
| 307 |
{ |
| 308 |
return $this->repository->getByTripId($tripId); |
| 309 |
} |
| 310 |
} |
| 311 |
|
| 312 |
|