feat: import destinations from the MyE&P travel export

This commit is contained in:
fromme committed 2026-09-29 12:57:54 +02:00
1 parent 5c7e702eab
commit 0f76c5a7b6
15 files changed
+791 -422

No files matched your search

+176
View File
@@ -0,0 +1,176 @@
<?php
namespace App\Command;
use App\MyEp\Api\ApiException;
use App\MyEp\Import\DestinationRow;
use App\MyEp\Import\TravelExportClient;
use Doctrine\DBAL\Connection;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* Fills the destination table from MyE&P's travel export.
*
* Replaces the Ziel_*.xml parsing this command used to do. MyE&P now owns every part of that:
* it reads BusProNet's German XML, flattens it to one row per travel date x hotel - the pair
* this table is keyed on - joins the hotel and pickup base data in, and resolves the boarding
* times. What is left here is the upsert.
*
* Still raw DBAL rather than the ORM: the rows carry no behaviour, nothing listens for their
* changes, and a Destination is soft deletable, so hydrating 400 entities to write scalars
* onto them would buy nothing.
*/
#[AsCommand(
name: 'app:myep-import',
description: 'Imports travel dates, products and hotels from the MyE&P API',
)]
class MyEpImportCommand extends Command
{
public function __construct(
private readonly Connection $connection,
private readonly TravelExportClient $travelExportClient,
private readonly LoggerInterface $logger,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
try {
$export = $this->travelExportClient->fetchAll();
} catch (ApiException $e) {
$io->error('Der Reise-Export konnte nicht von MyE&P geladen werden – Import abgebrochen.');
$this->logger->error('Import aborted: the MyE&P travel export could not be read', [
'reason' => $e->getMessage(),
]);
return Command::FAILURE;
}
// An empty export would otherwise be a run that touches nothing and reports success,
// which reads as "nothing changed upstream" rather than as a failure to read it.
if ([] === $export->rows) {
$io->error('Der Reise-Export von MyE&P enthält keine Reisen – Import abgebrochen.');
$this->logger->error('Import aborted: the MyE&P travel export was empty');
return Command::FAILURE;
}
// Every destination this import touches gets its cost unit and its country written from
// the export, on update as well as on insert. If MyE&P stops reporting those two fields,
// a run would quietly blank them on rows that have them: the cost unit then drops off
// the invoices (App\Service\Pdf\InvoiceApprover) and a missing country code blanks the
// flag icons and takes the row out of the country filters. Refusing to run is the
// recoverable failure; overwriting good data is not.
if ([] !== $missingFields = $this->missingHotelFields($export->rows)) {
$io->error(sprintf(
'Die MyE&P-API liefert keine %s – Import abgebrochen, damit bestehende Werte nicht überschrieben werden.',
implode(' und ', $missingFields),
));
$this->logger->error('Import aborted: the MyE&P travel export carries no '.implode(', ', $missingFields));
return Command::FAILURE;
}
$io->info(sprintf('%d Reisen von MyE&P geladen', count($export->rows)));
// Both are losses MyE&P made silently on its side: a skipped pair is a travel the
// disposition will never offer, and a boarding point without master data reaches the
// form with no town. Logged every run so they get chased instead of settling in.
$this->logger->info('MyE&P travel export read', [
'rows' => count($export->rows),
'skipped' => $export->skipped,
'pickups_without_master_data' => $export->pickupsWithoutMasterData,
'generated_at' => $export->generatedAt,
]);
if (0 < $export->skipped || 0 < $export->pickupsWithoutMasterData) {
$io->warning(sprintf(
'MyE&P hat %d Reise-Hotel-Kombinationen verworfen und %d Zustiege ohne Stammdaten gemeldet.',
$export->skipped,
$export->pickupsWithoutMasterData,
));
}
$now = new \DateTimeImmutable();
$addedCount = 0;
$updatedCount = 0;
$progressBar = $io->createProgressBar(count($export->rows));
$progressBar->setFormat(" %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s%\n %message%");
$progressBar->setMessage('Starting');
$progressBar->start();
foreach ($export->rows as $row) {
$progressBar->setMessage(sprintf('%s %s', $row->dateFrom->format('d.m.Y'), $row->product));
$id = $this->connection->fetchOne(
'SELECT id FROM destination WHERE bus_pro_id=? AND hotel_bus_pro_id=?',
[$row->busProId, $row->hotelBusProId],
);
if ($id) {
$this->connection->update('destination', $row->toUpdatePayload($now), ['id' => $id]);
++$updatedCount;
} else {
$this->connection->insert('destination', $row->toInsertPayload($now));
++$addedCount;
}
$progressBar->advance();
}
$progressBar->setMessage('Done');
$progressBar->finish();
$io->newLine(2);
$io->success(sprintf('%d Destinationen angelegt, %d aktualisiert', $addedCount, $updatedCount));
$this->logger->info('MyE&P destination import finished', [
'added' => $addedCount,
'updated' => $updatedCount,
]);
return Command::SUCCESS;
}
/**
* Names the hotel fields MyE&P is not reporting, German labels for the operator.
*
* Asserted across the whole export rather than per row, because a row legitimately has
* neither: only a minority of hotels carry a kostenstelle upstream, and some carry no
* country BusProNet can resolve. An export where not one row has the field is a field
* missing from the response, not a gap in the data.
*
* @param DestinationRow[] $rows
*
* @return list<string>
*/
private function missingHotelFields(array $rows): array
{
$required = [
'Kostenstellen' => static fn (DestinationRow $row): bool => '' !== (string) $row->costUnit,
'Länderkennungen' => static fn (DestinationRow $row): bool => '' !== (string) $row->country,
];
$missing = [];
foreach ($required as $label => $isReported) {
foreach ($rows as $row) {
if ($isReported($row)) {
continue 2;
}
}
$missing[] = $label;
}
return $missing;
}
}