repository = new TripRepository(); $this->revisionRepository = new TripRevisionRepository(); $this->attributeRepository = new TripAttributeRepository(); $this->availabilityRepository = new TripAvailabilityRepository(); $this->attributeService = new AttributeService(); } /** * Get repository */ protected function getRepository(): TripRepository { return $this->repository; } /** * Get trip by ID with caching (repository layer) */ public function getById(int $id): ?\stdClass { $trip = $this->repository->findByIdCached($id); if ($trip) { Logger::debug('Trip loaded (repository cache)', ['trip_id' => $id]); } return $trip; } /** * Get trip with relationships and caching (repository layer) */ public function getWithRelationsCached(int $id): ?\stdClass { $trip = $this->repository->findWithRelationsCached($id); if ($trip) { Logger::debug('Trip with relationships loaded (repository cache)', ['trip_id' => $id]); } return $trip; } /** * Create trip with caching */ public function create(array $data): int { $attributes = $data['attributes'] ?? null; unset($data['attributes']); // Validate data $this->validate($data); $this->validatePricing($data); $this->validateDuration($data); // Drop per-trip currency (managed globally) if (isset($data['currency'])) { unset($data['currency']); } // Process data $processedData = $this->processBeforeCreate($data); // Create trip $id = $this->repository->create($processedData); // Save trip attributes if provided if (is_array($attributes)) { $this->saveTripAttributes($id, $attributes); } Logger::info("Trip created", ['trip_id' => $id]); return $id; } /** * Update trip with cache invalidation */ public function update(int $id, array $data): bool { $attributes = $data['attributes'] ?? null; unset($data['attributes']); // Validate data $this->validate($data, $id); if (isset($data['pricing_type']) || isset($data['original_price']) || isset($data['price_types'])) { $this->validatePricing($data); } if (isset($data['trip_type']) || isset($data['duration_days']) || isset($data['duration_nights'])) { $this->validateDuration($data); } // Update trip $result = $this->repository->update($id, $data); // Save trip attributes if provided if ($result && is_array($attributes)) { $this->saveTripAttributes($id, $attributes); } if ($result) { Logger::info("Trip updated", ['trip_id' => $id]); } return $result; } /** * Delete trip with cache invalidation */ public function delete(int $id): bool { $result = $this->repository->delete($id); if ($result) { Logger::info("Trip deleted", ['trip_id' => $id]); } return $result; } /** * Validate trip data */ protected function validate(array $data, ?int $id = null): void { // Required fields if (empty($data['title'])) { throw new \InvalidArgumentException('Trip title is required'); } if (empty($data['slug'])) { throw new \InvalidArgumentException('Trip slug is required'); } if (!preg_match('/^[\pL\pN-]+$/u', $data['slug'])) { throw new \InvalidArgumentException('Trip slug can only contain letters, numbers, and hyphens'); } // Check if slug is unique (exclude current trip when updating) $existing = $this->repository->findBySlug($data['slug']); if ($existing) { $existingId = (int) $existing->id; // Only throw error if creating new trip OR if existing trip is different from current trip if ($id === null || $existingId !== (int) $id) { throw new \InvalidArgumentException('Trip slug must be unique'); } } } /** * Validate pricing data */ private function validatePricing(array $data): void { $pricingType = $data['pricing_type'] ?? 'regular'; if ($pricingType === 'regular') { if (!isset($data['original_price']) || (float) $data['original_price'] <= 0) { throw new \InvalidArgumentException('Original price is required and must be greater than 0 for regular pricing'); } // Validate discounted price if (isset($data['discounted_price']) && !empty($data['discounted_price'])) { if ((float) $data['discounted_price'] >= (float) $data['original_price']) { throw new \InvalidArgumentException('Discounted price must be less than original price'); } } } elseif ($pricingType === 'traveler_based') { if (empty($data['price_types']) || !is_array($data['price_types'])) { throw new \InvalidArgumentException('Price types are required for traveler-based pricing'); } // Validate each price type foreach ($data['price_types'] as $priceType) { if (empty($priceType['category_id'])) { throw new \InvalidArgumentException('Category ID is required for each price type'); } if (empty($priceType['original_price']) || (float) $priceType['original_price'] <= 0) { throw new \InvalidArgumentException('Original price is required and must be greater than 0 for each price type'); } } } } /** * Validate duration data */ private function validateDuration(array $data): void { $tripType = $data['trip_type'] ?? 'multi_day'; $days = isset($data['duration_days']) ? (int) $data['duration_days'] : null; $nights = isset($data['duration_nights']) ? (int) $data['duration_nights'] : null; if ($tripType === 'single_day') { if ($days !== null && $days !== 1) { throw new \InvalidArgumentException('Single day trips must have duration_days = 1'); } if ($nights !== null && $nights !== 0) { throw new \InvalidArgumentException('Single day trips must have duration_nights = 0'); } } elseif ($tripType === 'multi_day') { if ($days !== null && $days < 2) { throw new \InvalidArgumentException('Multi-day trips must have duration_days >= 2'); } if ($days !== null && $nights !== null) { if ($nights >= $days) { throw new \InvalidArgumentException('Nights should be less than days (typically days - 1)'); } } } } /** * Process before create */ protected function processBeforeCreate(array $data): array { if (!preg_match('/^[\pL\pN-]+$/u', $data['slug'])) { throw new \InvalidArgumentException('Trip slug can only contain letters, numbers, and hyphens'); } if (empty($data['status'])) { $data['status'] = 'draft'; } // Currency is managed globally (not per-trip); drop any incoming value if (isset($data['currency'])) { unset($data['currency']); } // Set created_by if (empty($data['created_by'])) { $data['created_by'] = get_current_user_id(); } // Set version $data['version'] = 1; // Process JSON fields $data = $this->processJsonFields($data); // Ensure difficulty_level is stored as integer ID if (isset($data['difficulty_level'])) { $data['difficulty_level'] = is_numeric($data['difficulty_level']) ? (int) $data['difficulty_level'] : null; } return $data; } /** * Process before update - save revision when publishing */ protected function processBeforeUpdate(int $id, array $data): array { // Get current trip data before update $currentTrip = $this->repository->find($id); if ($currentTrip) { $currentStatus = $currentTrip->status ?? 'draft'; $newStatus = $data['status'] ?? $currentStatus; // Create revision every time trip is published (status is 'published') // This captures the state before each publish, allowing users to revert to any published version if ($newStatus === 'publish') { // Get latest version number $latestVersion = $this->revisionRepository->getLatestVersion($id); $newVersion = $latestVersion + 1; // Get current user ID $currentUserId = get_current_user_id(); // Serialize current trip data (state before this publish) $tripDataArray = (array) $currentTrip; $serializedData = maybe_serialize($tripDataArray); // Save revision (with 'inherit' status like WordPress) try { $this->revisionRepository->createRevision($id, $newVersion, $serializedData, $currentUserId, 'inherit'); // Clean up old revisions (similar to WordPress's revision limit) $this->revisionRepository->cleanupRevisions($id); } catch (\Exception $e) { // Log error but don't fail the update if (defined('WP_DEBUG') && WP_DEBUG) { } } // Increment version $data['version'] = $newVersion; } else { // Keep current version if not publishing $data['version'] = $currentTrip->version ?? 1; } } // Set updated_by $data['updated_by'] = get_current_user_id(); // Merge custom_fields: keep existing keys that the incoming payload doesn't touch if (isset($data['custom_fields']) && is_array($data['custom_fields'])) { $data['custom_fields'] = $this->mergeCustomFields($id, $data['custom_fields']); } // Process JSON fields $data = $this->processJsonFields($data); // Ensure difficulty_level is stored as integer ID if (isset($data['difficulty_level'])) { $data['difficulty_level'] = is_numeric($data['difficulty_level']) ? (int) $data['difficulty_level'] : null; } return $data; } /** * Merge incoming custom_fields with what is already stored in the DB for a trip. * Only keys present in the incoming array are changed; all other existing keys are * kept intact. This is the "append, never override" contract the UI depends on. */ private function mergeCustomFields(int $id, array $incoming): array { $existing = $this->repository->find($id); if (!$existing || empty($existing->custom_fields)) { return $incoming; } $storedRaw = $existing->custom_fields; if (is_string($storedRaw)) { $decoded = maybe_unserialize($storedRaw); if (!is_array($decoded)) { $decoded = json_decode($storedRaw, true); } $storedRaw = is_array($decoded) ? $decoded : []; } return array_merge($storedRaw, $incoming); } /** * Process JSON fields - serialize arrays */ private function processJsonFields(array $data): array { // Note: highlights, gallery_images, faqs, price_types, itinerary_days, availability_dates // are stored in separate tables, not as JSON columns $jsonFields = [ 'testimonials', 'countries', 'regions', 'landmarks', 'tags', 'included_items', 'excluded_items', 'frontend_tabs', 'blackout_dates', 'custom_fields', 'pricing_rules', 'booking_rules', ]; foreach ($jsonFields as $field) { if (isset($data[$field]) && is_array($data[$field])) { $data[$field] = maybe_serialize($data[$field]); } } return $data; } /** * Generate unique slug from title */ private function generateSlug(string $title): string { $slug = sanitize_title($title); $baseSlug = $slug; $counter = 1; // Ensure uniqueness while ($this->repository->findBySlug($slug)) { $slug = $baseSlug . '-' . $counter; $counter++; } return $slug; } /** * Create trip with relationships */ public function createWithRelations(array $data, array $relationships = []): int { // Extract relationship fields from data if not already in relationships array // (these should not be in the main table) $relationshipFields = [ 'highlights', 'gallery_images', 'faqs', 'itinerary_days', 'availability_dates', 'trip_category', 'price_types', // Include price_types in relationship fields 'downloadable_items', // Managed in separate yatra_trip_downloads table 'attributes', 'activities', // Add activities to relationship fields 'destinations', // Add destinations to relationship fields ]; foreach ($relationshipFields as $field) { if (!isset($relationships[$field])) { // Handle field name mapping for activities $dataField = $field === 'activities' ? 'activity_types' : $field; if (isset($data[$dataField])) { $relationships[$field] = $data[$dataField]; } } } // For validation, we need price_types in data temporarily $priceTypesForValidation = $relationships['price_types'] ?? $data['price_types'] ?? []; $data['price_types'] = $priceTypesForValidation; $this->validate($data); // Sanitize data using TripValidator $tripValidator = new \Yatra\Validators\TripValidator(); $data = $tripValidator->sanitize($data, null); // Remove relationship fields from data before processing foreach ($relationshipFields as $field) { // Don't remove featured_priority - it's a direct database field, not a relationship if ($field !== 'featured_priority') { unset($data[$field]); } } $data = $this->processBeforeCreate($data); $attrPayload = $relationships['attributes'] ?? []; if (!is_array($attrPayload)) { $attrPayload = []; } (new AttributeRepository())->validatePayloadCoversRequiredAttributes($attrPayload); return $this->repository->createWithRelations($data, $relationships); } /** * Update trip with relationships */ public function updateWithRelations(int $id, array $data, array $relationships = []): bool { // Extract relationship fields from data if not already in relationships array // (these should not be in the main table) $relationshipFields = [ 'highlights', 'gallery_images', 'faqs', 'itinerary_days', 'availability_dates', 'trip_category', 'price_types', // Include price_types in relationship fields 'downloadable_items', // Managed in separate yatra_trip_downloads table 'attributes', // Add attributes to relationship fields 'activities', // Add activities to relationship fields 'destinations', // Add destinations to relationship fields ]; foreach ($relationshipFields as $field) { if (!isset($relationships[$field])) { // Handle field name mapping for activities $dataField = $field === 'activities' ? 'activity_types' : $field; if (isset($data[$dataField])) { $relationships[$field] = $data[$dataField]; } } } $data = $this->processBeforeUpdate($id, $data); $attributesRelationship = $relationships['attributes'] ?? null; unset($relationships['attributes']); $result = $this->repository->updateWithRelations($id, $data, $relationships); if ($result && is_array($attributesRelationship)) { $this->saveTripAttributes($id, $attributesRelationship); } else { } // Always sync pricing_type to availability dates and recurring rules when saving if (isset($data['pricing_type'])) { $this->syncPricingTypeToAvailability($id, $data['pricing_type']); } return $result; } /** * Sync pricing type to all availability dates and recurring rules for a trip * This ensures availability/rules always match the trip's pricing type * * @param int $tripId Trip ID * @param string $pricingType The pricing type to sync ('regular' or 'traveler_based') */ public function syncPricingTypeToAvailability(int $tripId, string $pricingType): void { global $wpdb; // Update availability dates $availabilityRepository = new \Yatra\Repositories\AvailabilityRepository(); $updated_dates = $availabilityRepository->updatePricingTypeByTripId($tripId, $pricingType); // Note: Recurring rules table doesn't have pricing_type column // Pricing type is derived from the trip, not stored in rules // If changing to regular pricing, clear traveler_pricing and price_types if ($pricingType === 'regular') { // Clear price_types from availability dates $cleared_dates = $availabilityRepository->clearPriceTypesByTripId($tripId); // Clear traveler_pricing from availability dates $cleared_pricing = $availabilityRepository->clearTravelerPricingByTripId($tripId); // Note: Recurring rules don't have pricing_type column // Pricing is derived from the trip, not stored in rules } } /** * Get trip with relationships */ public function getWithRelations(int $id, bool $includeDeleted = false): ?\stdClass { $trip = $this->repository->findWithRelations($id, $includeDeleted); if ($trip) { $trip->attributes = $this->attributeService->getTripAttributes($id); } return $trip; } /** * Duplicate a trip with all relationships * * Creates a new draft trip based on an existing one, copying * core fields and relationships (destinations, activities, * categories, pricing, highlights, gallery, FAQs, itinerary, * availability dates). Slug will be regenerated to ensure * uniqueness and status is always set to draft. * * @param int $id Existing trip ID * @return int New trip ID * @throws \Exception If source trip not found */ public function duplicate(int $id): int { $source = $this->getWithRelations($id); if (!$source) { throw new \Exception(__('Trip not found', 'yatra')); } $data = (array) $source; // Adjust core fields $data['title'] = ($data['title'] ?? '') . ' (Copy)'; // Generate new unique slug based on existing slug with numeric suffix (-1, -2, ...) $baseSlug = !empty($source->slug) ? sanitize_title((string) $source->slug) : $this->generateSlug($data['title'] ?? 'trip-copy'); $suffix = 1; $newSlug = $baseSlug . '-' . $suffix; while ($this->repository->findBySlug($newSlug)) { $suffix++; $newSlug = $baseSlug . '-' . $suffix; } $data['slug'] = $newSlug; // Always create as draft copy $data['status'] = 'draft'; // Ensure created_by is current user; clear technical fields $data['created_by'] = get_current_user_id(); unset( $data['id'], $data['created_at'], $data['updated_at'], $data['version'], $data['published_at'] ); // Extract relationships from the hydrated source object in the shapes // expected by TripRepository::createWithRelations (scalar IDs / simple arrays) $relationships = []; // Destinations: array of destination IDs $destinations = []; if (!empty($source->destinations) && is_array($source->destinations)) { foreach ($source->destinations as $dest) { $id = (int) ($dest->destination_id ?? $dest->id ?? 0); if ($id > 0 && !in_array($id, $destinations, true)) { $destinations[] = $id; } } } $relationships['destinations'] = $destinations; // Activities: array of activity IDs $activities = []; if (!empty($source->activities) && is_array($source->activities)) { foreach ($source->activities as $act) { $id = (int) ($act->activity_id ?? $act->id ?? 0); if ($id > 0 && !in_array($id, $activities, true)) { $activities[] = $id; } } } $relationships['activities'] = $activities; // Trip categories: array of category IDs $categories = []; if (!empty($source->trip_category) && is_array($source->trip_category)) { foreach ($source->trip_category as $cat) { $id = (int) ($cat->category_id ?? $cat->id ?? 0); if ($id > 0 && !in_array($id, $categories, true)) { $categories[] = $id; } } } $relationships['trip_category'] = $categories; // Price types: normalize to simple associative arrays $priceTypes = []; if (!empty($source->price_types) && is_array($source->price_types)) { foreach ($source->price_types as $pt) { $categoryId = (int) ($pt->category_id ?? 0); if ($categoryId <= 0) { continue; } $item = [ 'category_id' => $categoryId, 'original_price' => (float) ($pt->original_price ?? 0), ]; if (isset($pt->discounted_price)) { $item['discounted_price'] = (float) $pt->discounted_price; } $priceTypes[] = $item; } } $relationships['price_types'] = $priceTypes; // Highlights: normalize stdClass rows from DB into arrays/strings expected by saveHighlights $highlights = []; $rawHighlights = $data['highlights'] ?? ($source->highlights ?? []); if (!empty($rawHighlights) && is_array($rawHighlights)) { foreach ($rawHighlights as $highlight) { // Already a simple string if (is_string($highlight)) { $highlights[] = $highlight; continue; } // Already an array in the expected shape if (is_array($highlight)) { $highlights[] = $highlight; continue; } // Convert stdClass from yatra_trip_highlights table into array if (is_object($highlight)) { $text = $highlight->text ?? $highlight->highlight_text ?? ''; if ($text === '') { continue; } $highlights[] = [ 'text' => $text, 'icon' => $highlight->icon ?? $highlight->highlight_icon ?? null, 'image_id' => isset($highlight->image_id) ? (int) $highlight->image_id : (isset($highlight->highlight_image_id) ? (int) $highlight->highlight_image_id : 0), 'is_featured' => isset($highlight->is_featured) ? (int) $highlight->is_featured : 0, ]; } } } $relationships['highlights'] = $highlights; // Gallery images: normalize stdClass rows from DB into arrays/strings expected by saveGalleryImages $galleryImages = []; $rawGalleryImages = $data['gallery_images'] ?? ($source->gallery_images ?? []); if (!empty($rawGalleryImages) && is_array($rawGalleryImages)) { foreach ($rawGalleryImages as $image) { // Already a simple URL string if (is_string($image)) { $galleryImages[] = $image; continue; } // Already an array in the expected shape if (is_array($image)) { $galleryImages[] = $image; continue; } // Convert stdClass from yatra_trip_gallery_images into array if (is_object($image)) { $url = $image->url ?? $image->image_url ?? ''; if ($url === '') { continue; } $galleryImages[] = [ 'url' => $url, 'id' => isset($image->image_id) ? (int) $image->image_id : 0, 'thumbnail_url' => $image->thumbnail_url ?? null, 'alt_text' => $image->alt_text ?? null, 'caption' => $image->caption ?? null, 'is_featured' => isset($image->is_featured) ? (int) $image->is_featured : 0, ]; } } } $relationships['gallery_images'] = $galleryImages; // Other relationships can be copied as-is; repository helpers can handle them $relationships['faqs'] = $data['faqs'] ?? ($source->faqs ?? []); $relationships['itinerary_days'] = $data['itinerary_days'] ?? ($source->itinerary_days ?? []); $relationships['availability_dates'] = $data['availability_dates'] ?? ($source->availability_dates ?? []); // Remove relationship keys from main data to avoid column issues unset( $data['destinations'], $data['activities'], $data['trip_category'], $data['price_types'], $data['highlights'], $data['gallery_images'], $data['faqs'], $data['itinerary_days'], $data['availability_dates'] ); return $this->createWithRelations($data, $relationships); } /** * Soft delete trip */ public function softDelete(int $id): bool { $userId = get_current_user_id(); return $this->repository->softDelete($id, $userId); } /** * Restore trip */ public function restore(int $id): bool { return $this->repository->restore($id); } /** * Permanently remove a trip (hard delete). Used from trash / permanent-delete REST. */ public function permanentDelete(int $id): bool { if ($this->repository->find($id, true) === null) { return false; } return $this->delete($id); } /** * Publish trip */ public function publish(int $id): bool { $data = [ 'status' => 'publish', 'published_at' => current_time('mysql'), ]; return $this->repository->update($id, $data); } /** * Get active trips */ public function getActiveTrips(array $args = []): array { return $this->repository->getActive($args); } /** * Map trip_id => bookings count for list views. * * @param int[] $tripIds * @param string[]|null $excludeStatuses * @return array */ public function getBookingsCountMap(array $tripIds, ?array $excludeStatuses = null): array { return $this->repository->getBookingsCountMap($tripIds, $excludeStatuses); } /** * Search trips */ public function search(string $keyword, array $args = []): array { return $this->repository->search($keyword, $args); } /** * Admin trip list/grid: read rows directly from the repository (bypasses BaseService::getAll() list cache). * Count queries do not use that cache; a stale cached empty list made /trips/stats correct while the grid stayed empty. */ public function getAllForAdminList(array $args = []): array { return $this->repository->all($args); } /** * Count items */ public function count(array $args = []): int { return $this->repository->count($args); } /** * Count by status */ public function countByStatus(string $status): int { return $this->repository->countByStatus($status); } /** * Get status counts for admin list views * * Returns a stable set of counts that do not change with filters * so that the UI can show consistent "All / Published / Draft / ..." tabs. */ public function getStatusCounts(): array { // For admin stats, include all trips regardless of soft delete status $args = ['include_deleted' => true]; $statuses = [ 'publish', 'draft', 'review', 'approved', 'archived', 'trash', ]; $counts = []; foreach ($statuses as $status) { $statusArgs = array_merge($args, ['where' => ['status' => $status]]); $counts[$status] = $this->repository->count($statusArgs); } // Get total count without status filter $all = $this->repository->count($args); return [ 'all' => (int) $all, 'published' => (int) ($counts['publish'] ?? 0), 'draft' => (int) ($counts['draft'] ?? 0), 'review' => (int) ($counts['review'] ?? 0), 'approved' => (int) ($counts['approved'] ?? 0), 'archived' => (int) ($counts['archived'] ?? 0), 'trash' => (int) ($counts['trash'] ?? 0), ]; } /** * Restore a revision (WordPress-style) * * This method works like WordPress's revision restore: * 1. Creates a new revision of the current trip state (before restore) * 2. Updates the trip with the revision data * 3. The update will automatically create another revision (the restored state) * * @param int $tripId Trip ID * @param int $revisionId Revision ID to restore * @return bool Success * @throws \Exception If revision not found or restore fails */ public function restoreRevision(int $tripId, int $revisionId): bool { // Verify trip exists $trip = $this->repository->find($tripId); if (!$trip) { throw new \Exception(__('Trip not found', 'yatra')); } // Get the revision to restore $revision = $this->revisionRepository->findRevision($revisionId); if (!$revision) { throw new \Exception(__('Revision not found', 'yatra')); } // Verify revision belongs to this trip if ((int) $revision->trip_id !== $tripId) { throw new \Exception(__('Revision does not belong to this trip', 'yatra')); } // Step 1: Create a revision of the current state (before restore) // This preserves the current state, just like WordPress does $currentUserId = get_current_user_id(); $currentTripData = (array) $trip; $currentSerializedData = maybe_serialize($currentTripData); // Get latest version and create new revision $latestVersion = $this->revisionRepository->getLatestVersion($tripId); $preRestoreVersion = $latestVersion + 1; try { // Create revision of current state with 'inherit' status $this->revisionRepository->createRevision( $tripId, $preRestoreVersion, $currentSerializedData, $currentUserId, 'inherit' ); } catch (\Exception $e) { // Log but continue - revision creation failure shouldn't block restore if (defined('WP_DEBUG') && WP_DEBUG) { } } // Step 2: Unserialize the revision data $revisionData = maybe_unserialize($revision->data); if (!is_array($revisionData)) { throw new \Exception(__('Invalid revision data', 'yatra')); } // Step 3: Prepare data for update (exclude fields that shouldn't be restored) $restoreData = $revisionData; // Don't restore these fields (keep current values) unset($restoreData['id']); unset($restoreData['created_at']); unset($restoreData['created_by']); unset($restoreData['updated_at']); unset($restoreData['version']); // Will be incremented by processBeforeUpdate // Set updated_by to current user $restoreData['updated_by'] = $currentUserId; // Step 4: Update the trip (this will automatically create a new revision) // The update will create a revision with 'inherit' status $result = $this->updateWithRelations($tripId, $restoreData, []); if ($result) { // Clean up old revisions after restore $this->revisionRepository->cleanupRevisions($tripId); } return $result; } /** * Get trip with attributes */ public function getWithAttributes(int $id): ?\stdClass { $trip = $this->getById($id); if ($trip) { $trip->attributes = $this->attributeService->getTripAttributes($id); } return $trip; } /** * Set attribute for a trip */ public function setAttribute(int $tripId, int $attributeId, $value): bool { try { // Validate trip exists $trip = $this->getById($tripId); if (!$trip) { throw new \InvalidArgumentException('Trip not found'); } $result = $this->attributeService->setTripAttribute($tripId, $attributeId, $value); if ($result) { // Clear trip cache since attributes changed CacheService::clearTripCache($tripId); // Log action Logger::info("Attribute set for trip", [ 'trip_id' => $tripId, 'attribute_id' => $attributeId, 'value' => $value ]); } return $result; } catch (\Exception $e) { Logger::error("Failed to set trip attribute", [ 'trip_id' => $tripId, 'attribute_id' => $attributeId, 'error' => $e->getMessage() ]); return false; } } /** * Remove attribute from a trip */ public function removeAttribute(int $tripId, int $attributeId): bool { try { // Validate trip exists $trip = $this->getById($tripId); if (!$trip) { throw new \InvalidArgumentException('Trip not found'); } $result = $this->attributeService->removeTripAttribute($tripId, $attributeId); if ($result) { // Clear trip cache since attributes changed CacheService::clearTripCache($tripId); // Log action Logger::info("Attribute removed from trip", [ 'trip_id' => $tripId, 'attribute_id' => $attributeId ]); } return $result; } catch (\Exception $e) { Logger::error("Failed to remove trip attribute", [ 'trip_id' => $tripId, 'attribute_id' => $attributeId, 'error' => $e->getMessage() ]); return false; } } /** * Get all attributes for a trip */ public function getAttributes(int $tripId): array { try { return $this->attributeService->getTripAttributes($tripId); } catch (\Exception $e) { Logger::error("Failed to get trip attributes", [ 'trip_id' => $tripId, 'error' => $e->getMessage() ]); return []; } } /** * Update trip attributes (alias for bulkUpdateAttributes) */ public function updateTripAttributes(int $tripId, array $attributes): bool { return $this->bulkUpdateAttributes($tripId, $attributes); } /** * Bulk update trip attributes */ public function bulkUpdateAttributes(int $tripId, array $attributes): bool { try { // Validate trip exists $trip = $this->getById($tripId); if (!$trip) { throw new \InvalidArgumentException('Trip not found'); } $result = $this->attributeService->bulkUpdateTripAttributes($tripId, $attributes); if ($result) { // Clear trip cache since attributes changed CacheService::clearTripCache($tripId); // Log action Logger::info("Bulk attributes updated for trip", [ 'trip_id' => $tripId, 'attribute_count' => count($attributes) ]); } return $result; } catch (\InvalidArgumentException $e) { throw $e; } catch (\Exception $e) { Logger::error("Failed to bulk update trip attributes", [ 'trip_id' => $tripId, 'error' => $e->getMessage() ]); return false; } } /** * Get trips filtered by attribute value */ public function getByAttributeValue(int $attributeId, string $value): array { try { return $this->attributeService->getTripsByAttributeValue($attributeId, $value); } catch (\Exception $e) { Logger::error("Failed to get trips by attribute value", [ 'attribute_id' => $attributeId, 'value' => $value, 'error' => $e->getMessage() ]); return []; } } /** * Get available attribute values for filtering */ public function getAttributeFilterValues(int $attributeId): array { try { return $this->attributeService->getAttributeValues($attributeId); } catch (\Exception $e) { Logger::error("Failed to get attribute filter values", [ 'attribute_id' => $attributeId, 'error' => $e->getMessage() ]); return []; } } /** * Save trip attributes */ private function saveTripAttributes(int $tripId, array $attributes): bool { (new AttributeRepository())->validatePayloadCoversRequiredAttributes($attributes); $tripAttributeRepository = new \Yatra\Repositories\TripAttributeRepository(); $result = $tripAttributeRepository->saveTripAttributes($tripId, $attributes); return $result; } /** * Get trip activities */ public function getTripActivities(int $tripId): array { return $this->repository->getTripActivities($tripId); } /** * Get trip destinations */ public function getTripDestinations(int $tripId): array { return $this->repository->getTripDestinations($tripId); } /** * Get trip attributes */ public function getTripAttributes(int $tripId): array { $tripAttributeRepository = new \Yatra\Repositories\TripAttributeRepository(); return $tripAttributeRepository->getTripAttributes($tripId); } /** * Get trip categories */ public function getTripCategories(int $tripId): array { return $this->repository->getTripCategories($tripId); } /** * Count departures by date */ public function countDeparturesByDate(int $tripId, string $date): int { // The December 2025 service/controller refactor moved this query to // TripAvailabilityRepository::countAvailableDeparturesByDate() but left // this call pointing at TripRepository, where no such method exists — // so the storefront's date-pricing request (fired whenever a customer // picks a date) has returned a fatal 500 ever since. return $this->availabilityRepository->countAvailableDeparturesByDate($tripId, $date); } /** * Get trip with availability */ public function getTripWithAvailability(int $tripId): ?\stdClass { return $this->repository->getTripWithAvailability($tripId); } /** * Get price range for a trip * * @param int $tripId Trip ID * @return array ['min_price' => float, 'max_price' => float] */ public function getTripPriceRange(int $tripId): array { // Traveler-based pricing stores per-category prices in `trips.price_types` JSON. // The old implementation referenced a repository that may not exist in all builds; // compute the range directly from the persisted JSON so list endpoints can't fatal. $tripRepository = new \Yatra\Repositories\TripRepository(); $priceTypes = $tripRepository->getPriceTypes($tripId); $min = PHP_FLOAT_MAX; $max = 0.0; foreach ($priceTypes as $pt) { if (!is_array($pt)) { continue; } $discounted = isset($pt['discounted_price']) ? (float) $pt['discounted_price'] : 0.0; $sale = isset($pt['sale_price']) ? (float) $pt['sale_price'] : 0.0; $original = isset($pt['original_price']) ? (float) $pt['original_price'] : 0.0; $legacyPrice = isset($pt['price']) ? (float) $pt['price'] : 0.0; $effective = 0.0; if ($discounted > 0) { $effective = $discounted; } elseif ($sale > 0) { $effective = $sale; } elseif ($original > 0) { $effective = $original; } elseif ($legacyPrice > 0) { $effective = $legacyPrice; } if ($effective <= 0) { continue; } $min = min($min, $effective); $max = max($max, $effective); } if ($min === PHP_FLOAT_MAX) { return ['min_price' => 0.0, 'max_price' => 0.0]; } return ['min_price' => (float) $min, 'max_price' => (float) $max]; } }