feat: import destinations from the MyE&P travel export
This commit is contained in:
1 parent
5c7e702eab
commit
0f76c5a7b6
15 files changed
+791
-422
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user