*/ 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; } }