Files
myep/src/Service/BookingEditDataLoader.php
T

254 lines
9.8 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Service;
use App\BusProNet\ApiClient;
use App\BusProNet\DataProcessor\BookingDataProcessor;
use App\BusProNet\Model\Booking;
use App\BusProNet\Model\Notification;
use App\BusProNet\XmlLoader\AgencyLoader;
use App\Entity\User;
use App\Form\Model\BookingDto;
use App\Security\Crypt;
use Psr\Cache\InvalidArgumentException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\Cache\TagAwareCacheInterface;
/**
* Handles loading and initializing booking data for edit mode.
*
* Encapsulates the logic for loading booking data from session or API,
* refreshing availability data, and restoring drafts when available.
*/
class BookingEditDataLoader
{
public const string CACHE_TAG_USER_PREFIX = 'user_bookings_';
private bool $draftRestored = false;
public function __construct(
private readonly ApiClient $apiClient,
private readonly BookingDataProcessor $bookingDataProcessor,
private readonly BookingSessionManager $bookingSessionService,
private readonly BookingChangeTracker $fingerprintService,
private readonly TravelDataProvider $travelDataService,
private readonly BookingEditDraftManager $draftService,
private readonly AgencyLoader $agencyLoader,
private readonly Crypt $crypt,
private readonly TagAwareCacheInterface $bpnCache,
) {
}
/**
* Returns whether a draft was restored during the last load operation.
*
* This flag is reset on each call to loadFormData() and can be used
* by the controller to show a flash message to the user.
*/
public function isDraftRestored(): bool
{
return $this->draftRestored;
}
/**
* Loads booking data from session or initializes from API.
*
* Uses session data if available and valid (same booking ID). This preserves
* participant edits between form submissions within the same edit session.
*
* If no session data exists, loads fresh data from API. If a draft exists,
* it is automatically applied on top of the fresh API data to restore
* pending user edits.
*
* @param Request $request The HTTP request
* @param int $bookingId The booking ID to load
* @param User $user The authenticated user
*
* @return BookingDto|null The loaded booking DTO, or null if loading failed
*/
public function loadFormData(Request $request, int $bookingId, User $user): ?BookingDto
{
$this->draftRestored = false;
$formData = $this->bookingSessionService->getBookingDto($request, BookingDto::MODE_EDIT);
// Validate session data matches requested booking - clear stale data if mismatched
if (null !== $formData && $formData->booking?->id !== $bookingId) {
$this->bookingSessionService->clearBookingDto($request, BookingDto::MODE_EDIT);
$formData = null;
}
if (null === $formData) {
return $this->initializeFromApi($request, $bookingId, $user);
}
// No user edits pending — discard the session and reload live from API so that
// BusPro-side changes (participant data, status, surcharges) are immediately
// visible. isDirty() also returns false when originalFingerprint is null
// (inconsistent session state), which is equally safe to reload.
if (!$this->fingerprintService->isDirty($formData)) {
$this->bookingSessionService->clearBookingDto($request, BookingDto::MODE_EDIT);
$this->invalidateBookingCache($bookingId, $user);
return $this->initializeFromApi($request, $bookingId, $user);
}
// Refresh availability and mutability data to ensure current state.
// Availability is refreshed on every load; mutability is patched from cache.
$this->travelDataService->enrichWithFreshAvailabilities($formData->travel);
$bookingData = $this->fetchBookingData($bookingId, $user);
if (null !== $bookingData && !($bookingData instanceof Notification)) {
$mutableData = $this->travelDataService->getMutabilityData($bookingData->dateId, cached: true);
if (null !== $mutableData) {
$this->travelDataService->patchMutability($formData->travel, $mutableData);
}
}
return $formData;
}
/**
* Initializes booking data from API on first load and stores in session.
*
* If a draft exists for this user and booking, it is automatically applied
* on top of the fresh API data. This ensures user input is preserved while
* structural data (services, prices) remains current.
*
* @param Request $request The HTTP request
* @param int $bookingId The booking ID to load
* @param User $user The authenticated user
*
* @return BookingDto|null The loaded booking DTO, or null if loading failed
*/
public function initializeFromApi(Request $request, int $bookingId, User $user): ?BookingDto
{
$bookingData = $this->fetchBookingData($bookingId, $user);
if (null === $bookingData || $bookingData instanceof Notification) {
return null;
}
$travelData = $this->travelDataService->getTravelData($bookingData->dateId, $bookingData->hotelId);
if (null === $travelData) {
return null;
}
// Use cached mutability data for form/UI state.
$mutableData = $this->travelDataService->getMutabilityData($bookingData->dateId, cached: true);
// Force refresh ensures fresh data at edit start, then populates cache for subsequent loads
$availabilities = $this->travelDataService->getAvailabilityData($bookingData->dateId, cached: true, forceRefresh: true);
if (null === $mutableData || null === $availabilities) {
return null;
}
$this->travelDataService->patchAvailabilities($travelData, $availabilities);
$this->travelDataService->patchMutability($travelData, $mutableData);
// Resolve agency code before building the DTO so the internal-agency flag can be
// passed to createBookingDtoFromBooking, preventing applicant data from being
// copied into participant[0] for agency bookings where they are different people.
$agencyCode = null !== $bookingData->agencyId
? $this->agencyLoader->loadById($bookingData->agencyId)?->code
: null;
$formData = $this->bookingDataProcessor->createBookingDtoFromBooking(
$bookingData,
$travelData,
AgencyLoader::INTERNAL_AGENCY_CODE === $agencyCode,
);
$formData->agencyCode = $agencyCode;
// Set original fingerprint BEFORE applying draft, so dirty detection
// compares against the original API data (not the draft-modified data)
$formData->originalFingerprint = $this->fingerprintService->generateFingerprint($formData, true);
// Check for existing draft and apply if found
$draft = $this->draftService->findDraft($user, $bookingId);
if (null !== $draft) {
$applied = $this->draftService->applyDraftToDto($draft, $formData, $travelData);
if (true === $applied) {
$this->draftRestored = true;
}
}
$this->bookingSessionService->saveBookingDto($request, $formData, BookingDto::MODE_EDIT);
return $formData;
}
/**
* Fetches booking data from API with caching.
*
* Cache entries are tagged with the user ID to allow bulk invalidation
* when the user updates their personal data.
*
* @param int $bookingId The booking ID to fetch
* @param User $user The authenticated user
*
* @return Booking|Notification|null The booking data, notification on error, or null on cache failure
*/
public function fetchBookingData(int $bookingId, User $user): Booking|Notification|null
{
$cacheKey = $this->getBookingCacheKey($bookingId, $user);
$userTag = self::CACHE_TAG_USER_PREFIX.$user->getId();
$email = $user->getEmail();
$password = $this->crypt->decrypt($user->getPassword());
try {
return $this->bpnCache->get($cacheKey, function (ItemInterface $item) use ($email, $password, $bookingId, $userTag) {
$item->expiresAfter(300);
$item->tag([$userTag]);
return $this->apiClient->getBooking($email, $password, $bookingId);
});
} catch (InvalidArgumentException) {
return null;
}
}
/**
* Invalidates the booking cache after successful update.
*
* @param int $bookingId The booking ID to invalidate
* @param User $user The authenticated user
*/
public function invalidateBookingCache(int $bookingId, User $user): void
{
try {
$cacheKey = $this->getBookingCacheKey($bookingId, $user);
$this->bpnCache->delete($cacheKey);
} catch (InvalidArgumentException) {
// Ignore cache deletion errors
}
}
private function getBookingCacheKey(int $bookingId, User $user): string
{
return sprintf('bpn_booking_%d_u_%d', $bookingId, $user->getId());
}
/**
* Invalidates all cached bookings for a user.
*
* Called when the user updates their personal data to ensure
* booking edits show the latest applicant information.
*
* @param User $user The user whose booking caches should be invalidated
*/
public function invalidateUserBookingCaches(User $user): void
{
try {
$userTag = self::CACHE_TAG_USER_PREFIX.$user->getId();
$this->bpnCache->invalidateTags([$userTag]);
} catch (InvalidArgumentException) {
// Ignore cache invalidation errors
}
}
}