diff --git a/src/Command/BookingRepairDraftCommand.php b/src/Command/BookingRepairDraftCommand.php deleted file mode 100644 index 2c8eb499..00000000 --- a/src/Command/BookingRepairDraftCommand.php +++ /dev/null @@ -1,491 +0,0 @@ - - */ - 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 BookingClient $bookingClient, - private readonly TravelClient $travelClient, - 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->bookingClient->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->travelClient->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; - } -}