feat: cli command to repair corrupted booking snapshots
This commit is contained in:
@@ -0,0 +1,489 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\BusProNet\ApiClient;
|
||||
use App\BusProNet\Constants;
|
||||
use App\BusProNet\DataProcessor\BookingDataProcessor;
|
||||
use App\BusProNet\Model\Booking;
|
||||
use App\BusProNet\Model\Notification;
|
||||
use App\BusProNet\Model\Service;
|
||||
use App\BusProNet\Model\Travel;
|
||||
use App\Entity\BookingEditDraft;
|
||||
use App\Entity\User;
|
||||
use App\Repository\BookingEditDraftRepository;
|
||||
use App\Security\Crypt;
|
||||
use App\Service\BookingChangeTracker;
|
||||
use App\Service\BookingEditDraftMerger;
|
||||
use App\Service\TravelDataProvider;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||
|
||||
/**
|
||||
* Repairs a booking edit draft that BusPro keeps rejecting.
|
||||
*
|
||||
* A draft survives every failed submission and is replayed on each re-entry into the edit flow.
|
||||
* When one selection inside it has become unacceptable to BusPro - a Leistung whose status has
|
||||
* drifted to "Anfrage" because its contingent ran out, say - the whole change is refused, nothing
|
||||
* persists, and the customer is stuck in a loop they cannot edit their way out of. Booking 98787
|
||||
* accumulated 37 such failures over two months.
|
||||
*
|
||||
* The valuable half of that draft is the personal data: names, dates of birth, contact details,
|
||||
* addresses, room remarks. Re-entering it by hand for eighty participants is not a reasonable ask.
|
||||
* So this command rebuilds the draft on top of the booking as BusPro currently holds it, keeps
|
||||
* every field the customer typed, and reverts only the service selections that BusPro will not
|
||||
* accept - naming each one, so the office can tell the customer what to pick again.
|
||||
*
|
||||
* Dry run by default. --apply writes a JSON backup of the original form data first.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'app:booking:repair-draft',
|
||||
description: 'Rebuild a rejected booking edit draft, keeping typed data and reverting unacceptable service selections',
|
||||
)]
|
||||
class BookingRepairDraftCommand extends Command
|
||||
{
|
||||
/**
|
||||
* Draft `services` keys holding a single service id.
|
||||
*/
|
||||
/**
|
||||
* Date of birth the office writes into the empty slots of a template booking.
|
||||
*/
|
||||
private const PLACEHOLDER_DATE_OF_BIRTH = '2000-01-01';
|
||||
|
||||
private const SINGLE_SERVICE_KEYS = ['skiPass', 'veg', 'insurance', 'rentalInsurance'];
|
||||
|
||||
/**
|
||||
* Draft `services` keys holding a list of service ids.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
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<string>
|
||||
*/
|
||||
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<int, array<string, mixed>> $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<int, string> 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<string, mixed> $participantData
|
||||
* @param array<string, mixed> $liveServices
|
||||
* @param array<int, string> $blocked
|
||||
* @param list<string> $reverted
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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<string, mixed> $original
|
||||
* @param array<string, mixed> $repaired
|
||||
* @param list<string> $reverted
|
||||
* @param array<int, string> $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('<info>%d</info> 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user