Files
myep-team/src/Command/MyEpImportCommand.php
T

177 lines
6.9 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
}
}