PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.16
Yatra – Travel Booking & Tour Operator Software v3.0.16
3.0.16 3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 All 84 releases
yatra / app / Services / ItineraryPdfBuilder.php

ItineraryPdfBuilder.php in Yatra – Travel Booking & Tour Operator Software 3.0.16, at app/Services/ItineraryPdfBuilder.php

228 lines 12.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 declare(strict_types=1);
4
5 namespace Yatra\Services;
6
7 use Yatra\Helpers\FormatHelper;
8 use Yatra\Repositories\TripRepository;
9
10 /**
11 * Single source of truth for the customer-facing itinerary PDF.
12 *
13 * Two REST routes generate this exact same PDF — `/bookings/{id}/itinerary`
14 * (BookingsController, used when the booking has no payment row yet) and
15 * `/payments/{id}/itinerary` (PaymentGatewayController, used once payment
16 * exists). Before this class, each one composed its own template-data
17 * array, fetched its own trip, and re-implemented the same status mapping
18 * & date formatting — three subtle drift points where the two PDFs would
19 * disagree on the same booking (e.g. the payment-side one passed
20 * `default_font: DejaVu Sans` which overrode the locale-aware font,
21 * stripping every Nepali glyph in the booking-side PDF; only one path
22 * loaded the day-by-day itinerary; etc.).
23 *
24 * Callers now hand this class a normalized `$source` array — extracted
25 * from whichever data shape they have on hand (booking array vs joined
26 * payment object) — and we build + render the PDF identically for both.
27 */
28 class ItineraryPdfBuilder
29 {
30 private TripRepository $tripRepository;
31 private PdfService $pdfService;
32
33 public function __construct(?TripRepository $tripRepository = null, ?PdfService $pdfService = null)
34 {
35 $this->tripRepository = $tripRepository ?? new TripRepository();
36 $this->pdfService = $pdfService ?? new PdfService();
37 }
38
39 public function pdfService(): PdfService
40 {
41 return $this->pdfService;
42 }
43
44 /**
45 * Build the PDF binary for an itinerary.
46 *
47 * @param array<string,mixed> $source Booking-like array with at minimum
48 * `trip_id`, `booking_id`, `travel_date`, `created_at`,
49 * `contact_first_name`, `contact_last_name`, `contact_email`,
50 * `customer_name`, `booking_status`, `total_amount`,
51 * `amount_paid`, `amount_due`, `travelers_count`. Unknown keys
52 * are ignored.
53 * @return string Raw PDF bytes.
54 */
55 public function build(array $source): string
56 {
57 $tripId = (int) ($source['trip_id'] ?? 0);
58 $trip = null;
59 $itineraryDays = [];
60 if ($tripId > 0) {
61 // findWithRelations: needed so $trip carries the itinerary-day
62 // model for the "About this trip" sidebar. getItineraryDays
63 // returns the timeline used by the "Travel Timeline" section.
64 $trip = $this->tripRepository->findWithRelations($tripId)
65 ?: $this->tripRepository->find($tripId);
66 $itineraryDays = $this->tripRepository->getItineraryDays($tripId);
67 }
68
69 $bookingId = (int) ($source['booking_id'] ?? 0);
70 $bookingRef = $bookingId > 0
71 ? 'YTR-' . strtoupper(str_pad((string) $bookingId, 8, '0', STR_PAD_LEFT))
72 : '';
73
74 $createdAt = (string) ($source['created_at'] ?? $source['booking_date'] ?? '');
75 $bookingDate = $createdAt !== '' ? date_i18n(get_option('date_format'), strtotime($createdAt)) : '';
76 $travelRaw = (string) ($source['travel_date'] ?? '');
77 $travelDate = $travelRaw !== '' ? date_i18n(get_option('date_format'), strtotime($travelRaw)) : '';
78
79 // Departure / return date. The booking already stores the real
80 // trip end (`end_date`, written at booking time from the selected
81 // departure or start + duration), so prefer that authoritative
82 // value rather than re-deriving one — this is what keeps the
83 // travel timeline showing the ACTUAL booked end date instead of
84 // repeating the arrival date. Only when no stored end_date exists
85 // do we derive it, and then the SAME way
86 // BookingRepository::calculateEndDate does: start + (duration_days
87 // - 1). A 5-day trip therefore ends on day 5, not day 6 — the old
88 // "+ duration_days" was off by one and implied an extra night.
89 $returnDate = '';
90 $endRaw = (string) ($source['end_date'] ?? '');
91 if ($endRaw === '' && $travelRaw !== '' && $trip && (int) ($trip->duration_days ?? 0) > 1) {
92 $endRaw = date(
93 'Y-m-d',
94 strtotime($travelRaw . ' +' . ((int) $trip->duration_days - 1) . ' days')
95 );
96 }
97 // Expose a return date only when it genuinely differs from the
98 // arrival date. Single-day bookings store end_date == start_date,
99 // so the timeline renders one item instead of two identical dates.
100 if ($endRaw !== '' && $endRaw !== $travelRaw) {
101 $returnDate = date_i18n(get_option('date_format'), strtotime($endRaw));
102 }
103
104 $statusRaw = strtolower((string) ($source['booking_status'] ?? $source['status'] ?? ''));
105 $statusClass = in_array($statusRaw, ['confirmed', 'completed', 'success'], true)
106 ? 'confirmed'
107 : (in_array($statusRaw, ['cancelled'], true) ? 'cancelled' : 'pending');
108
109 $customerName = trim(
110 (string) ($source['contact_first_name'] ?? '')
111 . ' '
112 . (string) ($source['contact_last_name'] ?? '')
113 );
114 if ($customerName === '') {
115 $customerName = (string) ($source['customer_name'] ?? __('Customer', 'yatra'));
116 }
117
118 $currency = SettingsService::getCurrency();
119
120 $tripFallbackTitle = (string) ($source['trip_title'] ?? __('Trip Booking', 'yatra'));
121
122 $templateData = [
123 'company_name' => SettingsService::get('company_name', get_bloginfo('name')),
124 'company_address' => SettingsService::get('company_address', ''),
125 'company_address_lines' => \Yatra\Helpers\FormatHelper::companyAddressLines(),
126 'company_email' => SettingsService::get('company_email', get_option('admin_email')),
127 'company_phone' => SettingsService::get('company_phone', ''),
128 'customer_name' => $customerName,
129 'customer_email' => (string) ($source['contact_email'] ?? $source['customer_email'] ?? ''),
130 'booking_ref' => $bookingRef,
131 'booking_date' => $bookingDate,
132 'booking_status' => ucfirst($statusRaw !== '' ? $statusRaw : 'pending'),
133 'status_class' => $statusClass,
134 'trip_title' => $trip ? ((string) ($trip->title ?? $tripFallbackTitle)) : $tripFallbackTitle,
135 'trip_description'=> $trip ? (string) ($trip->description ?? $trip->content ?? '') : '',
136 // Duration is duration_days/duration_nights (no `duration` column).
137 'trip_duration' => $trip
138 ? yatra_format_duration(
139 (int) ($trip->duration_days ?? 0),
140 isset($trip->duration_nights) ? (int) $trip->duration_nights : null,
141 // Hour-based day tours: "8 hours" instead of "1 day". Absent
142 // or NULL on every day-based trip, which keeps its wording.
143 (int) ($trip->duration_hours ?? 0)
144 )
145 : '',
146 'trip_difficulty' => $trip ? (string) ($trip->difficulty_name ?? '') : '',
147 'trip_highlights' => $trip ? ($trip->highlights ?? $trip->trip_highlights ?? '') : '',
148 'trip_includes' => $trip ? ($trip->includes ?? $trip->trip_includes ?? '') : '',
149 'trip_excludes' => $trip ? ($trip->excludes ?? $trip->trip_excludes ?? '') : '',
150 'departure_location' => $trip ? (string) ($trip->departure_location ?? '') : '',
151 'destination' => $trip ? (string) ($trip->destination ?? '') : (string) ($source['destination'] ?? ''),
152 'travel_date' => $travelDate,
153 'return_date' => $returnDate,
154 'currency_symbol' => FormatHelper::getCurrencySymbol($currency),
155 'total_amount' => yatra_format_price((float) ($source['total_amount'] ?? 0), $currency, false),
156 'amount_paid' => yatra_format_price((float) ($source['amount_paid'] ?? 0), $currency, false),
157 'amount_due' => yatra_format_price((float) ($source['amount_due'] ?? 0), $currency, false),
158 'traveler_count' => (int) ($source['travelers_count'] ?? $source['traveler_count'] ?? $source['travelers'] ?? 1),
159 'itinerary_days' => $itineraryDays,
160 // Real trip-specific Important Information fields, same
161 // ones the single-trip page surfaces in its "Important
162 // Information" section. The template renders only the
163 // rows that are non-empty — no more hardcoded "Comfortable
164 // clothing, walking shoes, sunscreen..." boilerplate.
165 'physical_requirements' => $trip ? (string) ($trip->physical_requirements ?? '') : '',
166 'visa_requirements' => $trip ? (string) ($trip->visa_requirements ?? '') : '',
167 'vaccination_requirements' => $trip ? (string) ($trip->vaccination_requirements ?? '') : '',
168 'cancellation_policy' => $trip ? (string) ($trip->cancellation_policy ?? '') : '',
169 'age_min' => $trip ? ($trip->age_min ?? null) : null,
170 'age_max' => $trip ? ($trip->age_max ?? null) : null,
171 'accommodation_type' => $trip ? (string) ($trip->accommodation_type ?? '') : '',
172 'accommodation_details' => $trip ? (string) ($trip->accommodation_details ?? '') : '',
173 'meal_plan' => $trip ? (string) ($trip->meal_plan ?? '') : '',
174 'transportation_included' => (bool) ($trip->transportation_included ?? false),
175 'pickup_location' => $trip ? (string) ($trip->pickup_location ?? '') : '',
176 'dropoff_location' => $trip ? (string) ($trip->dropoff_location ?? '') : '',
177 'transportation_details' => $trip ? (string) ($trip->transportation_details ?? '') : '',
178 ];
179
180 // Locale-aware font is resolved inside PdfService — do NOT
181 // pass a `default_font` override here, otherwise the Nepali /
182 // Hindi / Arabic fonts loaded for non-Latin locales get
183 // bypassed and we end up with the missing-glyph rectangles
184 // this builder was created to eliminate.
185 return $this->pdfService->renderTemplateToPdfSafely(
186 'pdf/itinerary.php',
187 $templateData,
188 ['paper' => 'A4', 'orientation' => 'portrait']
189 );
190 }
191
192 /**
193 * Convenience: normalize a `$payment` joined record (the shape
194 * PaymentRepository returns for itinerary endpoints — payment row
195 * with booking columns joined in) into the `$source` array `build()`
196 * accepts. Saves the caller from duplicating field mapping logic.
197 */
198 public function buildFromPaymentRecord(object $payment): string
199 {
200 return $this->build([
201 'trip_id' => $payment->trip_id ?? 0,
202 'booking_id' => $payment->booking_id ?? 0,
203 'created_at' => $payment->created_at ?? null,
204 'travel_date' => $payment->travel_date ?? null,
205 // Actual booked trip range — used for the arrival/departure
206 // timeline. Null-safe: build() falls back to a correct
207 // derivation when the joined record doesn't carry these.
208 'start_date' => $payment->start_date ?? null,
209 'end_date' => $payment->end_date ?? null,
210 // Use the BOOKING status, not the payment status. Payment
211 // status can be "completed" while the booking itself is
212 // still "pending" admin confirmation — the itinerary
213 // header should reflect the booking, not the transaction.
214 'booking_status' => $payment->booking_status ?? $payment->status ?? null,
215 'contact_first_name' => $payment->contact_first_name ?? null,
216 'contact_last_name' => $payment->contact_last_name ?? null,
217 'contact_email' => $payment->contact_email ?? $payment->customer_email ?? null,
218 'customer_name' => $payment->customer_name ?? null,
219 'trip_title' => $payment->trip_title ?? null,
220 'destination' => $payment->destination ?? null,
221 'total_amount' => $payment->booking_total_amount ?? $payment->amount ?? 0,
222 'amount_paid' => $payment->booking_amount_paid ?? $payment->amount ?? 0,
223 'amount_due' => $payment->booking_amount_due ?? 0,
224 'travelers_count' => $payment->traveler_count ?? $payment->travelers_count ?? $payment->travelers ?? 1,
225 ]);
226 }
227 }
228