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
@@ -1,245 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\BusProNet\DataProvider\HotelDataProvider;
|
||||
use App\BusProNet\DataProvider\PickupDataProvider;
|
||||
use App\BusProNet\Model\Hotel;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use League\Flysystem\FilesystemException;
|
||||
use League\Flysystem\FilesystemOperator;
|
||||
use League\Flysystem\StorageAttributes;
|
||||
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;
|
||||
|
||||
#[AsCommand(
|
||||
name: 'app:bpn-import',
|
||||
description: 'Imports dates, products and hotels from BusProNet',
|
||||
)]
|
||||
class BpnImportCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly Connection $connection,
|
||||
private readonly HotelDataProvider $hotelDataProvider,
|
||||
private readonly PickupDataProvider $pickupDataProvider,
|
||||
private readonly LoggerInterface $logger,
|
||||
private readonly FilesystemOperator $xmlExport,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
// Resolve the base data once, before any file is read. A destination cannot be
|
||||
// written without its hotel, so an unavailable hotel list would otherwise skip every
|
||||
// record and still report success. Holding both lists locally also keeps the loop off
|
||||
// the providers, which would otherwise be asked per pickup of all 174 files.
|
||||
$hotels = $this->hotelDataProvider->getAll();
|
||||
$pickups = $this->pickupDataProvider->getAll();
|
||||
|
||||
if ([] === $hotels) {
|
||||
$io->error('Hotel-Stammdaten konnten nicht von MyE&P geladen werden – Import abgebrochen.');
|
||||
$this->logger->error('Import aborted: hotel base data unavailable');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// Every destination this import touches gets its cost unit and its country written
|
||||
// from the hotel, on update as well as on insert. If MyE&P does not report those two
|
||||
// fields yet, 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($hotels)) {
|
||||
$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 hotel payload carries no '.implode(', ', $missingFields));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
// Get all xml files
|
||||
try {
|
||||
$xmlFiles = $this
|
||||
->xmlExport
|
||||
->listContents('')
|
||||
->filter(fn (StorageAttributes $attributes) => $attributes->isFile() && str_starts_with($attributes->path(), 'Ziel_'))
|
||||
->toArray();
|
||||
} catch (FilesystemException $e) {
|
||||
$io->error('Unable to list XML files');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$totalCount = count($xmlFiles);
|
||||
|
||||
if (0 === $totalCount) {
|
||||
$io->error('No XML files available or found');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$io->info('Found '.$totalCount.' XML files');
|
||||
$addedCount = 0;
|
||||
$updatedCount = 0;
|
||||
$warnings = [];
|
||||
|
||||
if ([] === $pickups) {
|
||||
$warnings[] = 'Zustiegs-Stammdaten konnten nicht von MyE&P geladen werden';
|
||||
}
|
||||
|
||||
$progressBar = $io->createProgressBar($totalCount);
|
||||
$progressBar->setFormat(" %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s%\n %message%");
|
||||
$progressBar->setMessage('Starting');
|
||||
$progressBar->start();
|
||||
|
||||
// Iterate over xml files
|
||||
foreach ($xmlFiles as $xmlFile) {
|
||||
$progressBar->setMessage($xmlFile->path());
|
||||
|
||||
// Load xml file into simplexml object
|
||||
try {
|
||||
$xml = simplexml_load_string($this->xmlExport->read($xmlFile->path()));
|
||||
} catch (FilesystemException $e) {
|
||||
$warnings[] = 'Unable to read XML file '.$xmlFile->path();
|
||||
$progressBar->advance();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Iterate over all destinations
|
||||
foreach ($xml->xpath('reise/termin') as $destinationXml) {
|
||||
$product = (string) $destinationXml->xpath('text')[0];
|
||||
$busProId = (int) $destinationXml->attributes()['idbuspro'];
|
||||
$code = (string) $destinationXml->attributes()['code'];
|
||||
$dateFrom = \DateTimeImmutable::createFromFormat('d.m.Y', (string) $destinationXml->attributes()['termin']);
|
||||
$dateTo = \DateTimeImmutable::createFromFormat('d.m.Y', (string) $destinationXml->attributes()['bis']);
|
||||
|
||||
// Find pickups
|
||||
$datePickups = [];
|
||||
foreach ($destinationXml->xpath('zustiege/zustieg') as $pickupXml) {
|
||||
$pickupBusProId = (int) $pickupXml->attributes()['idbuspro'];
|
||||
if ($pickupBusProId) {
|
||||
if (null === $pickup = $pickups[$pickupBusProId] ?? null) {
|
||||
$warnings[] = 'Pickup with busProId '.$pickupBusProId.' not found';
|
||||
continue;
|
||||
}
|
||||
|
||||
$datePickups[] = [
|
||||
'busProId' => $pickupBusProId,
|
||||
'city' => $pickup->getCity(),
|
||||
'street' => $pickup->getStreet(),
|
||||
'time' => (string) $pickupXml->attributes()['zeit'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Iterate over all hotel entries
|
||||
foreach ($destinationXml->xpath('hotel') as $hotelXml) {
|
||||
$hotelBusProId = (int) $hotelXml->attributes()['idbuspro'];
|
||||
if (null === $hotel = $hotels[$hotelBusProId] ?? null) {
|
||||
$warnings[] = 'Hotel with busProId '.$hotelBusProId.' not found';
|
||||
continue;
|
||||
}
|
||||
|
||||
// Finally update or store collected data
|
||||
if ($id = $this->connection->fetchOne('SELECT id FROM destination WHERE bus_pro_id=? AND hotel_bus_pro_id=?', [$busProId, $hotelBusProId])) {
|
||||
$this
|
||||
->connection
|
||||
->update('destination', [
|
||||
'code' => $code,
|
||||
'date_from' => $dateFrom->format('Y-m-d'),
|
||||
'date_to' => $dateTo->format('Y-m-d'),
|
||||
'product' => $product,
|
||||
'pickups' => json_encode($datePickups),
|
||||
'cost_unit' => $hotel->getCostUnit(),
|
||||
'country' => $hotel->getCountryCode(),
|
||||
'updated_at' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'),
|
||||
], [
|
||||
'id' => $id,
|
||||
'hotel_bus_pro_id' => $hotelBusProId,
|
||||
])
|
||||
;
|
||||
++$updatedCount;
|
||||
} else {
|
||||
$this
|
||||
->connection
|
||||
->insert('destination', [
|
||||
'code' => $code,
|
||||
'bus_pro_id' => $busProId,
|
||||
'date_from' => $dateFrom->format('Y-m-d'),
|
||||
'date_to' => $dateTo->format('Y-m-d'),
|
||||
'product' => $product,
|
||||
'hotel' => $hotel->getName(),
|
||||
'hotel_code' => $hotel->getCode(),
|
||||
'hotel_bus_pro_id' => $hotelBusProId,
|
||||
'pickups' => json_encode($datePickups),
|
||||
'cost_unit' => $hotel->getCostUnit(),
|
||||
'country' => $hotel->getCountryCode(),
|
||||
'created_at' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'),
|
||||
])
|
||||
;
|
||||
++$addedCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$progressBar->advance();
|
||||
}
|
||||
|
||||
$progressBar->setMessage('Done');
|
||||
$progressBar->finish();
|
||||
$io->newLine(2);
|
||||
|
||||
if ($warnings) {
|
||||
$io->warning(array_unique($warnings));
|
||||
}
|
||||
|
||||
$logMessage = 'Added '.$addedCount.' and updated '.$updatedCount.' destinations';
|
||||
$io->info($logMessage);
|
||||
$this->logger->info($logMessage);
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Names the hotel fields MyE&P is not reporting, German labels for the operator.
|
||||
*
|
||||
* Asserted across the whole list rather than per hotel, because a single hotel
|
||||
* legitimately has neither: only a minority of them carry a kostenstelle upstream, and
|
||||
* some carry no country at all. A list where not one hotel has the field is a field
|
||||
* missing from the response, not a gap in the data.
|
||||
*
|
||||
* @param array<int, Hotel> $hotels
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function missingHotelFields(array $hotels): array
|
||||
{
|
||||
$required = [
|
||||
'Kostenstellen' => static fn (Hotel $hotel): bool => '' !== (string) $hotel->getCostUnit(),
|
||||
'Länderkennungen' => static fn (Hotel $hotel): bool => '' !== (string) $hotel->getCountryCode(),
|
||||
];
|
||||
|
||||
$missing = [];
|
||||
|
||||
foreach ($required as $label => $isReported) {
|
||||
foreach ($hotels as $hotel) {
|
||||
if ($isReported($hotel)) {
|
||||
continue 2;
|
||||
}
|
||||
}
|
||||
|
||||
$missing[] = $label;
|
||||
}
|
||||
|
||||
return $missing;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
<?php
|
||||
|
||||
namespace App\Command;
|
||||
|
||||
use App\BusProNet\DataProvider\HotelDataProvider;
|
||||
use App\BusProNet\DataProvider\PickupDataProvider;
|
||||
use App\MyEp\Api\ApiClient;
|
||||
use App\MyEp\Import\DestinationRow;
|
||||
use App\MyEp\Import\InvalidRowException;
|
||||
use App\Service\Assignment\PickupDepartureOrder;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Read-only probe of the MyE&P API: does it answer, and does it carry what the import needs.
|
||||
*
|
||||
* Writes nothing and touches no database. It exists because the interesting failures in this
|
||||
* integration are silent ones - a field named `id` where the import reads `busProId`, a time
|
||||
* in a shape the departure ordering cannot parse - and they are only visible against a real
|
||||
* response. `app:bpn-import` cannot serve as the probe: its own guard passes or fails on the
|
||||
* hotel payload, and once past it the command writes.
|
||||
*/
|
||||
#[AsCommand(
|
||||
name: 'app:myep-check',
|
||||
description: 'Checks that the MyE&P API answers and carries the fields the import needs',
|
||||
)]
|
||||
class MyEpCheckCommand extends Command
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ApiClient $apiClient,
|
||||
private readonly HotelDataProvider $hotelDataProvider,
|
||||
private readonly PickupDataProvider $pickupDataProvider,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
$io->section('Base data');
|
||||
|
||||
$hotels = $this->hotelDataProvider->getAll();
|
||||
$pickups = $this->pickupDataProvider->getAll();
|
||||
|
||||
if ([] === $hotels) {
|
||||
$io->error('No hotels returned - the import would refuse to run.');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$withCostUnit = count(array_filter($hotels, fn ($h) => null !== $h->getCostUnit()));
|
||||
$withCountry = count(array_filter($hotels, fn ($h) => null !== $h->getCountryCode()));
|
||||
|
||||
$io->definitionList(
|
||||
['hotels' => (string) count($hotels)],
|
||||
['with costUnit' => sprintf('%d%s', $withCostUnit, 0 === $withCostUnit ? ' <- import would refuse' : '')],
|
||||
['with countryCode' => sprintf('%d%s', $withCountry, 0 === $withCountry ? ' <- import would refuse' : '')],
|
||||
['pickups' => (string) count($pickups)],
|
||||
);
|
||||
|
||||
$io->section('Travel export');
|
||||
|
||||
// A whole page rather than one row: the first row often has no boarding points, and an
|
||||
// unread pickups array is exactly the failure this probe exists to catch.
|
||||
$payload = $this->apiClient->get('api/travels/export', ['page' => 1, 'perPage' => 500]);
|
||||
$meta = $payload['meta'] ?? [];
|
||||
|
||||
$io->definitionList(...array_map(
|
||||
fn (string $key) => [$key => var_export($meta[$key] ?? null, true)],
|
||||
['total', 'totalPages', 'perPage', 'skipped', 'pickupsWithoutMasterData', 'generatedAt'],
|
||||
));
|
||||
|
||||
$rows = array_values(array_filter((array) ($payload['data'] ?? []), 'is_array'));
|
||||
|
||||
if ([] === $rows) {
|
||||
$io->error('The export returned no rows.');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$withPickups = array_values(array_filter($rows, fn (array $r) => [] !== ($r['pickups'] ?? [])));
|
||||
|
||||
$io->definitionList(
|
||||
['rows on this page' => (string) count($rows)],
|
||||
['of them with pickups' => (string) count($withPickups)],
|
||||
);
|
||||
|
||||
$row = $withPickups[0] ?? $rows[0];
|
||||
|
||||
// Does `datetime` actually resolve the overnight leg? A coach leaves in the evening and
|
||||
// keeps boarding through the night, so a 00:30 stop belongs to the day after a 23:30 one.
|
||||
// If every entry on such a row carries the same calendar date, the field is unresolved and
|
||||
// ordering by it inverts the route.
|
||||
$straddling = 0;
|
||||
$unresolved = [];
|
||||
|
||||
foreach ($withPickups as $candidate) {
|
||||
$times = array_filter(array_column($candidate['pickups'], 'time'));
|
||||
$dates = array_unique(array_map(
|
||||
fn (?string $d) => null === $d ? null : substr($d, 0, 10),
|
||||
array_column($candidate['pickups'], 'datetime'),
|
||||
));
|
||||
|
||||
$late = array_filter($times, fn (string $t) => $t >= '18:00');
|
||||
$early = array_filter($times, fn (string $t) => $t <= '06:00');
|
||||
|
||||
if ([] === $late || [] === $early) {
|
||||
continue;
|
||||
}
|
||||
|
||||
++$straddling;
|
||||
|
||||
if (1 === count($dates)) {
|
||||
$unresolved[] = $candidate['busProId'];
|
||||
}
|
||||
}
|
||||
|
||||
$io->definitionList(
|
||||
['rows straddling midnight' => (string) $straddling],
|
||||
['of them with one date only' => sprintf('%d%s', count($unresolved), [] === $unresolved ? '' : ' <- datetime unresolved')],
|
||||
);
|
||||
|
||||
// The decisive question is not whether a date looks odd but whether adopting `datetime`
|
||||
// would reorder a route. Compared against our own anchor-based ordering, which reads the
|
||||
// bare clock times and is what runs today.
|
||||
$disagree = [];
|
||||
|
||||
foreach ($withPickups as $candidate) {
|
||||
$pickups = $candidate['pickups'];
|
||||
|
||||
if (2 > count($pickups)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$legacy = array_column(
|
||||
PickupDepartureOrder::sort(array_map(
|
||||
function (array $p): array {
|
||||
unset($p['datetime']);
|
||||
|
||||
return $p;
|
||||
},
|
||||
$pickups,
|
||||
)),
|
||||
'busProId',
|
||||
);
|
||||
|
||||
$dated = $pickups;
|
||||
usort($dated, fn (array $a, array $b) => ($a['datetime'] ?? '') <=> ($b['datetime'] ?? ''));
|
||||
|
||||
if ($legacy !== array_column($dated, 'busProId')) {
|
||||
$disagree[] = $candidate['busProId'];
|
||||
}
|
||||
}
|
||||
|
||||
$io->definitionList(
|
||||
['rows where datetime order differs' => sprintf(
|
||||
'%d of %d%s',
|
||||
count($disagree),
|
||||
count($withPickups),
|
||||
[] === $disagree ? '' : ' <- adopting datetime would reorder these',
|
||||
)],
|
||||
);
|
||||
|
||||
if ([] !== $unresolved) {
|
||||
$io->warning(sprintf(
|
||||
'datetime does not roll past midnight on %d of %d straddling rows (e.g. busProId %s). '
|
||||
.'Ordering by it would put the early-morning stops before the evening departure.',
|
||||
count($unresolved),
|
||||
$straddling,
|
||||
implode(', ', array_slice($unresolved, 0, 5)),
|
||||
));
|
||||
}
|
||||
|
||||
$resolvedStraddlers = array_values(array_filter(
|
||||
$withPickups,
|
||||
function (array $r) use ($unresolved): bool {
|
||||
$times = array_filter(array_column($r['pickups'], 'time'));
|
||||
$late = array_filter($times, fn (string $t) => $t >= '18:00');
|
||||
$early = array_filter($times, fn (string $t) => $t <= '06:00');
|
||||
|
||||
return [] !== $late && [] !== $early && false === in_array($r['busProId'], $unresolved, true);
|
||||
},
|
||||
));
|
||||
|
||||
if ([] !== $resolvedStraddlers) {
|
||||
$sample = $resolvedStraddlers[0];
|
||||
$io->writeln(sprintf('<comment>A straddling row where datetime DID roll (busProId %d, dateFrom %s)</comment>', $sample['busProId'], $sample['dateFrom']));
|
||||
foreach ($sample['pickups'] as $pickup) {
|
||||
$io->writeln(sprintf(' %-14s %-6s %s', $pickup['city'] ?? '?', $pickup['time'] ?? '-', $pickup['datetime'] ?? '-'));
|
||||
}
|
||||
$io->newLine();
|
||||
}
|
||||
|
||||
$io->writeln('<comment>Raw first row</comment>');
|
||||
$io->writeln(json_encode($row, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
|
||||
|
||||
$io->section('As the import reads it');
|
||||
|
||||
try {
|
||||
$destinationRow = DestinationRow::fromArray($row);
|
||||
} catch (InvalidRowException $e) {
|
||||
$io->error(sprintf('The import would skip this row: %s', $e->getMessage()));
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$io->definitionList(
|
||||
['busProId' => (string) $destinationRow->busProId],
|
||||
['code' => var_export($destinationRow->code, true)],
|
||||
['product' => var_export($destinationRow->product, true)],
|
||||
['dateFrom' => $destinationRow->dateFrom->format('Y-m-d')],
|
||||
['dateTo' => $destinationRow->dateTo->format('Y-m-d')],
|
||||
['hotelBusProId' => (string) $destinationRow->hotelBusProId],
|
||||
['hotelCode' => var_export($destinationRow->hotelCode, true)],
|
||||
['hotelName' => var_export($destinationRow->hotelName, true)],
|
||||
['costUnit' => var_export($destinationRow->costUnit, true)],
|
||||
['country' => var_export($destinationRow->country, true)],
|
||||
['pickups' => json_encode($destinationRow->pickups, JSON_UNESCAPED_UNICODE)],
|
||||
);
|
||||
|
||||
if ([] !== $row['pickups'] && [] === $destinationRow->pickups) {
|
||||
$io->error('The export carries pickups but the import read none - check the id field name.');
|
||||
|
||||
return Command::FAILURE;
|
||||
}
|
||||
|
||||
$io->success('MyE&P answers and the import can read the row.');
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -114,7 +114,12 @@ class DestinationRow
|
||||
|
||||
/**
|
||||
* The shape here is what the destination table stores and what
|
||||
* App\BusProNet\Model\Pickup::fromArray() reads back.
|
||||
* App\BusProNet\Model\Pickup::fromArray() reads back, which ignores the extra `datetime`.
|
||||
*
|
||||
* `datetime` is not optional decoration. MyE&P sends `time` as a bare HH:MM, so without it
|
||||
* App\Service\Assignment\PickupDepartureOrder never finds a dated entry, its anchor is null
|
||||
* and it falls back to sorting on the clock alone - which puts a 00:30 boarding point ahead
|
||||
* of the 22:00 departure on every route that runs through the night.
|
||||
*/
|
||||
private static function readPickups(mixed $pickups): array
|
||||
{
|
||||
@@ -140,6 +145,7 @@ class DestinationRow
|
||||
'city' => self::readNullableString($pickup, 'city'),
|
||||
'street' => self::readNullableString($pickup, 'street'),
|
||||
'time' => self::readNullableString($pickup, 'time'),
|
||||
'datetime' => self::readNullableString($pickup, 'datetime'),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\MyEp\Import;
|
||||
|
||||
/**
|
||||
* The travel export as one run of the import sees it: its rows and what MyE&P dropped building them.
|
||||
*
|
||||
* The counters come from the first page's meta and describe the whole export, not the page, so
|
||||
* reading them once is correct. They matter because both are silent losses: a skipped row is a
|
||||
* travel the disposition will never offer, and a pickup without master data is a boarding point
|
||||
* with no town or street. The import logs them per run so the ids can be chased upstream rather
|
||||
* than quietly becoming permanent.
|
||||
*/
|
||||
final readonly class TravelExport
|
||||
{
|
||||
/**
|
||||
* @param DestinationRow[] $rows
|
||||
* @param int $skipped date x hotel pairs MyE&P dropped as unusable
|
||||
* @param int $pickupsWithoutMasterData distinct pickup ids absent from zustiege.xml
|
||||
*/
|
||||
public function __construct(
|
||||
public array $rows,
|
||||
public int $skipped,
|
||||
public int $pickupsWithoutMasterData,
|
||||
public ?string $generatedAt,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -26,15 +26,14 @@ class TravelExportClient
|
||||
}
|
||||
|
||||
/**
|
||||
* @return DestinationRow[]
|
||||
*
|
||||
* @throws ApiException
|
||||
*/
|
||||
public function fetchAll(): array
|
||||
public function fetchAll(): TravelExport
|
||||
{
|
||||
$rows = [];
|
||||
$page = 1;
|
||||
$totalPages = 1;
|
||||
$meta = [];
|
||||
|
||||
do {
|
||||
$payload = $this->apiClient->get(self::PATH, [
|
||||
@@ -63,6 +62,9 @@ class TravelExportClient
|
||||
}
|
||||
}
|
||||
|
||||
// The counters repeat unchanged on every page, so the first one answers for the run.
|
||||
$meta = [] === $meta ? (array) ($payload['meta'] ?? []) : $meta;
|
||||
|
||||
$totalPages = max(1, (int) ($payload['meta']['totalPages'] ?? 1));
|
||||
++$page;
|
||||
} while ($page <= $totalPages && $page <= self::MAX_PAGES && [] !== $data);
|
||||
@@ -74,6 +76,11 @@ class TravelExportClient
|
||||
]);
|
||||
}
|
||||
|
||||
return $rows;
|
||||
return new TravelExport(
|
||||
$rows,
|
||||
(int) ($meta['skipped'] ?? 0),
|
||||
(int) ($meta['pickupsWithoutMasterData'] ?? 0),
|
||||
isset($meta['generatedAt']) ? (string) $meta['generatedAt'] : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user