From 042037867b8bf3a1cd1d7678762e5557a89b3a1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Tue, 15 Sep 2026 13:57:41 +0200 Subject: [PATCH] feat: cli command to repair corrupted booking snapshots --- src/Command/BookingRepairDraftCommand.php | 489 ++++++++++++++++++++++ 1 file changed, 489 insertions(+) create mode 100644 src/Command/BookingRepairDraftCommand.php diff --git a/src/Command/BookingRepairDraftCommand.php b/src/Command/BookingRepairDraftCommand.php new file mode 100644 index 0000000..4fda42a --- /dev/null +++ b/src/Command/BookingRepairDraftCommand.php @@ -0,0 +1,489 @@ + + */ + private const LIST_SERVICE_KEYS = ['courses', 'board', 'rentals', 'additionalServices']; + + /** + * Transport is reverted as a unit: a participant put back on their own arrival must lose the + * pickup and drop-off that only make sense on a coach. + * + * @var list + */ + private const TRANSPORT_KEYS = ['transportationOutbound', 'transportationInbound', 'pickup', 'dropOff']; + + public function __construct( + private readonly BookingEditDraftRepository $draftRepository, + private readonly EntityManagerInterface $entityManager, + private readonly ApiClient $apiClient, + private readonly Crypt $crypt, + private readonly TravelDataProvider $travelDataProvider, + private readonly BookingDataProcessor $bookingDataProcessor, + private readonly BookingEditDraftMerger $draftMerger, + private readonly BookingChangeTracker $changeTracker, + #[Autowire('%kernel.project_dir%')] + private readonly string $projectDir, + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this + ->addOption('booking', 'b', InputOption::VALUE_REQUIRED, 'idbuchung whose draft should be repaired') + ->addOption('apply', null, InputOption::VALUE_NONE, 'Write the repaired draft. Without this the command only reports') + ->addOption('backup-dir', null, InputOption::VALUE_REQUIRED, 'Where to write the backup of the original form data', 'var/draft-backups') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $bookingId = $input->getOption('booking'); + if (null === $bookingId) { + $io->error('--booking is required.'); + + return Command::INVALID; + } + + $draft = $this->findSingleDraft($io, (int) $bookingId); + if (null === $draft) { + return Command::FAILURE; + } + + $user = $draft->getUser(); + $booking = $this->fetchLiveBooking($io, $user, (int) $bookingId); + if (null === $booking) { + return Command::FAILURE; + } + + $travel = $this->travelDataProvider->getTravelData((int) $booking->dateId, $booking->hotelId, true); + if (null === $travel) { + $io->error(sprintf('No travel data for date id %d.', (int) $booking->dateId)); + + return Command::FAILURE; + } + + $formData = $draft->getFormData(); + $draftParticipants = $formData['participants'] ?? []; + + if (false === $this->assertAlignment($io, $booking, $draftParticipants)) { + return Command::FAILURE; + } + + $blocked = $this->collectUnbookableServices($io, $travel); + + // The booking as BusPro currently holds it is the only state known to be acceptable, so + // it is the base everything is rebuilt on. + $repairedDto = $this->bookingDataProcessor->createBookingDtoFromBooking( + $booking, + $travel, + false, + ); + + $liveSelections = $this->changeTracker->extractUserData($repairedDto)['participants'] ?? []; + + $reverted = []; + foreach ($draftParticipants as $index => $participantData) { + if (false === isset($repairedDto->participants[$index])) { + continue; + } + + $filtered = $this->filterServiceSelections( + $participantData, + $liveSelections[$index]['services'] ?? [], + $blocked, + $index, + $reverted, + ); + + $this->draftMerger->apply($repairedDto, $index, $repairedDto->participants[$index], $filtered, $travel); + } + + $repaired = $this->changeTracker->extractUserData($repairedDto); + + $this->report($io, $formData, $repaired, $reverted, $blocked); + + if (false === $input->getOption('apply')) { + $io->note('Dry run. Re-run with --apply to write the repaired draft.'); + + return Command::SUCCESS; + } + + $backupPath = $this->writeBackup($draft, (string) $input->getOption('backup-dir')); + $io->success(sprintf('Original form data backed up to %s', $backupPath)); + + $draft->setFormData($repaired); + $this->entityManager->flush(); + + $io->success(sprintf('Draft %d repaired.', (int) $draft->getId())); + + return Command::SUCCESS; + } + + private function findSingleDraft(SymfonyStyle $io, int $bookingId): ?BookingEditDraft + { + $drafts = $this->draftRepository->findBy(['bookingId' => $bookingId]); + + if ([] === $drafts) { + $io->error(sprintf('No draft found for booking %d.', $bookingId)); + + return null; + } + + if (count($drafts) > 1) { + $io->error(sprintf('Booking %d has %d drafts; resolve by hand.', $bookingId, count($drafts))); + + return null; + } + + $draft = $drafts[0]; + $io->definitionList( + ['Draft' => sprintf('%d (user %d)', (int) $draft->getId(), (int) $draft->getUser()->getId())], + ['Created' => $draft->getCreatedAt()->format('Y-m-d H:i')], + ['Last saved' => $draft->getUpdatedAt()->format('Y-m-d H:i')], + ['Participants' => (string) count($draft->getFormData()['participants'] ?? [])], + ); + + return $draft; + } + + private function fetchLiveBooking(SymfonyStyle $io, User $user, int $bookingId): ?Booking + { + $result = $this->apiClient->getBooking( + (string) $user->getEmail(), + $this->crypt->decrypt((string) $user->getPassword()), + $bookingId, + ); + + if ($result instanceof Notification) { + $io->error(sprintf('Vorgang_Details failed: %d %s', (int) $result->code, (string) $result->message)); + + return null; + } + + return $result; + } + + /** + * Refuses to repair a draft whose positions no longer line up with the live booking. + * + * Drafts are merged positionally, by array index, with no identity check. Empty template + * slots are interchangeable, so they prove nothing; the participants BusPro already knows by + * date of birth are the only anchors available. If one of those has moved, every later + * position is suspect and rebuilding would quietly graft data onto the wrong people. + * + * @param array> $draftParticipants + */ + private function assertAlignment(SymfonyStyle $io, Booking $booking, array $draftParticipants): bool + { + $liveCount = count($booking->participants); + $draftCount = count($draftParticipants); + + if ($liveCount !== $draftCount) { + $io->warning(sprintf( + 'Live booking has %d participants, the draft has %d. Seats were added or removed since the draft was written.', + $liveCount, + $draftCount, + )); + } + + $anchors = 0; + $mismatches = []; + + foreach (array_values($booking->participants) as $index => $participant) { + $liveDob = $participant->dateOfBirth?->format('Y-m-d'); + if (null === $liveDob || self::PLACEHOLDER_DATE_OF_BIRTH === $liveDob) { + continue; + } + + ++$anchors; + $draftDob = $draftParticipants[$index]['personalData']['dateOfBirth'] ?? null; + + if (null !== $draftDob && $draftDob !== $liveDob) { + $mismatches[] = sprintf('position %d: live %s, draft %s', $index + 1, $liveDob, $draftDob); + } + } + + if ([] !== $mismatches) { + $io->error('Draft positions no longer match the live booking:'); + $io->listing($mismatches); + $io->comment('Repairing would move typed data onto the wrong participants. Resolve by hand.'); + + return false; + } + + $io->text(sprintf('Alignment verified against %d participant(s) BusPro already knows.', $anchors)); + + return true; + } + + /** + * Collects the services BusPro will not accept as an addition. + * + * A Leistung only takes new participants while its own status is "Frei"; anything else is the + * condition behind "Status der Leistung (A) ist unterschiedlich zum Status des Teilnehmers". + * Live availability is preferred over the travel data, which lags behind it. + * + * @return array service id => status + */ + private function collectUnbookableServices(SymfonyStyle $io, Travel $travel): array + { + $statuses = []; + foreach ([...$travel->additionalServices, ...$travel->transportationServices] as $service) { + if (null !== $service->id && null !== $service->status) { + $statuses[$service->id] = $service->status; + } + } + + $live = $this->apiClient->getAvailabilitiesExtended((int) $travel->id); + if ($live instanceof Notification) { + $io->warning(sprintf( + 'VERFUEGBARKEIT2 failed (%d %s); falling back to travel data, which may be stale.', + (int) $live->code, + (string) $live->message, + )); + } else { + foreach ($live->getServices() as $availability) { + if (null !== $availability->serviceId && null !== $availability->status && '' !== trim($availability->status)) { + $statuses[$availability->serviceId] = $availability->status; + } + } + } + + $blocked = array_filter($statuses, static fn (string $status): bool => Constants::STATUS_AVAILABLE !== $status); + + if ([] === $blocked) { + $io->text('Every service on this travel is currently "Frei".'); + + return []; + } + + $rows = []; + foreach ($blocked as $serviceId => $status) { + $service = $travel->additionalServices[$serviceId] ?? $travel->transportationServices[$serviceId] ?? null; + $label = $service instanceof Service ? (string) $service->label : '?'; + $rows[] = [$serviceId, $label, $status]; + } + + $io->section('Services that cannot take new participants'); + $io->table(['id', 'Leistung', 'status'], $rows); + + return $blocked; + } + + /** + * Drops the draft's selections that would add a participant to a service BusPro has closed. + * + * Removing a key leaves the live booking's own value in place, because the merger applies + * only the keys it is given. Selections BusPro still accepts - a different ski pass, a meal + * preference - are kept, so the customer loses as little as possible. + * + * @param array $participantData + * @param array $liveServices + * @param array $blocked + * @param list $reverted + * + * @return array + */ + private function filterServiceSelections( + array $participantData, + array $liveServices, + array $blocked, + int $index, + array &$reverted, + ): array { + if (false === isset($participantData['services']) || [] === $blocked) { + return $participantData; + } + + $services = $participantData['services']; + $position = $index + 1; + + foreach (self::SINGLE_SERVICE_KEYS as $key) { + $selected = $services[$key] ?? null; + if (null !== $selected && isset($blocked[$selected]) && ($liveServices[$key] ?? null) !== $selected) { + unset($services[$key]); + $reverted[] = sprintf('participant %d: %s (service %d, %s)', $position, $key, $selected, $blocked[$selected]); + } + } + + foreach (self::LIST_SERVICE_KEYS as $key) { + $selected = $services[$key] ?? null; + if (false === is_array($selected)) { + continue; + } + + $liveList = $liveServices[$key] ?? []; + $kept = []; + foreach ($selected as $serviceId) { + if (isset($blocked[$serviceId]) && false === in_array($serviceId, $liveList, true)) { + $reverted[] = sprintf('participant %d: %s (service %d, %s)', $position, $key, $serviceId, $blocked[$serviceId]); + + continue; + } + + $kept[] = $serviceId; + } + + $services[$key] = $kept; + } + + foreach (['transportationOutbound', 'transportationInbound'] as $key) { + $selected = $services[$key] ?? null; + if (null === $selected || false === isset($blocked[$selected]) || ($liveServices[$key] ?? null) === $selected) { + continue; + } + + $reverted[] = sprintf('participant %d: %s (service %d, %s)', $position, $key, $selected, $blocked[$selected]); + + // Transport reverts as a unit, pickup and drop-off included. + foreach (self::TRANSPORT_KEYS as $transportKey) { + unset($services[$transportKey]); + } + + break; + } + + $participantData['services'] = $services; + + return $participantData; + } + + /** + * @param array $original + * @param array $repaired + * @param list $reverted + * @param array $blocked + */ + private function report(SymfonyStyle $io, array $original, array $repaired, array $reverted, array $blocked): void + { + $io->section('Typed data preserved'); + + $fields = ['firstName', 'lastName', 'dateOfBirth', 'email', 'mobile']; + $kept = 0; + $lost = []; + + foreach ($original['participants'] ?? [] as $index => $participantData) { + foreach ($fields as $field) { + $before = $participantData['personalData'][$field] ?? null; + if (null === $before || '' === $before) { + continue; + } + + $after = $repaired['participants'][$index]['personalData'][$field] ?? null; + if ($before === $after) { + ++$kept; + + continue; + } + + $lost[] = sprintf('participant %d: %s', $index + 1, $field); + } + } + + $io->text(sprintf('%d personal-data field(s) carried over.', $kept)); + + if ([] !== $lost) { + $io->warning(sprintf('%d field(s) could NOT be carried over:', count($lost))); + $io->listing(array_slice($lost, 0, 25)); + } + + $io->section('Service selections reverted'); + + if ([] === $reverted) { + $io->text([] === $blocked + ? 'None - no service on this travel is closed.' + : 'None - the draft selects no closed service.'); + + return; + } + + $io->warning(sprintf('%d selection(s) reverted to the booked state. The customer must choose again:', count($reverted))); + $io->listing($reverted); + } + + private function writeBackup(BookingEditDraft $draft, string $backupDir): string + { + $directory = $this->projectDir.'/'.trim($backupDir, '/'); + if (false === is_dir($directory)) { + mkdir($directory, 0o775, true); + } + + $path = sprintf( + '%s/draft-%d-booking-%d-%s.json', + $directory, + (int) $draft->getId(), + $draft->getBookingId(), + (new \DateTimeImmutable())->format('Ymd-His'), + ); + + file_put_contents($path, json_encode($draft->getFormData(), \JSON_PRETTY_PRINT | \JSON_UNESCAPED_UNICODE)); + + return $path; + } +}