177 lines
6.9 KiB
PHP
177 lines
6.9 KiB
PHP
<?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;
|
||
}
|
||
}
|