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,147 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Command;
|
||||
|
||||
use App\BusProNet\DataProvider\HotelDataProvider;
|
||||
use App\BusProNet\DataProvider\PickupDataProvider;
|
||||
use App\BusProNet\Model\Hotel;
|
||||
use App\Command\BpnImportCommand;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use League\Flysystem\FilesystemOperator;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
/**
|
||||
* The import writes a destination's cost unit and country from the hotel base data, on
|
||||
* update as well as on insert, so it can only run once MyE&P reports both fields. Until it
|
||||
* does, a run would blank them on the 2,206 destinations that carry a cost unit and the
|
||||
* 2,259 that carry a country - silently, because nothing reads them back until an invoice
|
||||
* is stamped. These tests pin the refusal, and that it happens before any write.
|
||||
*/
|
||||
class BpnImportCommandTest extends TestCase
|
||||
{
|
||||
public function testItRefusesToRunWhenNoHotelReportsACostUnit(): void
|
||||
{
|
||||
$tester = $this->runImport([$this->hotel(costUnit: null, countryCode: 'ch')]);
|
||||
|
||||
$this->assertSame(Command::FAILURE, $tester->getStatusCode());
|
||||
$this->assertStringContainsString('Kostenstellen', $tester->getDisplay());
|
||||
$this->assertStringNotContainsString('Länderkennungen', $tester->getDisplay());
|
||||
}
|
||||
|
||||
public function testItRefusesToRunWhenNoHotelReportsACountryCode(): void
|
||||
{
|
||||
$tester = $this->runImport([$this->hotel(costUnit: '4711', countryCode: null)]);
|
||||
|
||||
$this->assertSame(Command::FAILURE, $tester->getStatusCode());
|
||||
$this->assertStringContainsString('Länderkennungen', $tester->getDisplay());
|
||||
}
|
||||
|
||||
public function testItNamesBothFieldsWhenNeitherIsReported(): void
|
||||
{
|
||||
$tester = $this->runImport([$this->hotel(costUnit: null, countryCode: null)]);
|
||||
|
||||
$this->assertSame(Command::FAILURE, $tester->getStatusCode());
|
||||
$this->assertStringContainsString('Kostenstellen und Länderkennungen', $tester->getDisplay());
|
||||
}
|
||||
|
||||
public function testAnEmptyStringDoesNotCountAsAReportedField(): void
|
||||
{
|
||||
$tester = $this->runImport([$this->hotel(costUnit: '', countryCode: '')]);
|
||||
|
||||
$this->assertSame(Command::FAILURE, $tester->getStatusCode());
|
||||
$this->assertStringContainsString('Kostenstellen und Länderkennungen', $tester->getDisplay());
|
||||
}
|
||||
|
||||
public function testOneHotelCarryingAFieldIsEnoughToPassTheGuard(): void
|
||||
{
|
||||
// most hotels legitimately have no kostenstelle upstream, so the guard asks whether
|
||||
// the field is reported at all, not whether every hotel fills it
|
||||
$tester = $this->runImport([
|
||||
$this->hotel(costUnit: null, countryCode: null),
|
||||
$this->hotel(costUnit: '4711', countryCode: 'ch'),
|
||||
]);
|
||||
|
||||
$display = $tester->getDisplay();
|
||||
|
||||
$this->assertStringNotContainsString('Kostenstellen', $display);
|
||||
$this->assertStringNotContainsString('Länderkennungen', $display);
|
||||
}
|
||||
|
||||
public function testItRefusesToRunWithoutAnyHotelBaseData(): void
|
||||
{
|
||||
$tester = $this->runImport([]);
|
||||
|
||||
$this->assertSame(Command::FAILURE, $tester->getStatusCode());
|
||||
$this->assertStringContainsString('Hotel-Stammdaten', $tester->getDisplay());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<Hotel> $hotels
|
||||
*/
|
||||
private function runImport(array $hotels): CommandTester
|
||||
{
|
||||
$keyed = [];
|
||||
|
||||
foreach ($hotels as $index => $hotel) {
|
||||
$keyed[$index + 1] = $hotel;
|
||||
}
|
||||
|
||||
$tester = new CommandTester(new BpnImportCommand(
|
||||
$this->refusingConnection(),
|
||||
$this->provider(HotelDataProvider::class, $keyed),
|
||||
$this->provider(PickupDataProvider::class, []),
|
||||
new NullLogger(),
|
||||
// an auto stubbed listing, so it yields no files. The guards under test all run
|
||||
// before it, and the one case that gets past them stops there without writing.
|
||||
$this->createMock(FilesystemOperator::class),
|
||||
));
|
||||
|
||||
$tester->execute([]);
|
||||
|
||||
return $tester;
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T of HotelDataProvider|PickupDataProvider
|
||||
*
|
||||
* @param class-string<T> $class
|
||||
*
|
||||
* @return T&MockObject
|
||||
*/
|
||||
private function provider(string $class, array $all): object
|
||||
{
|
||||
$provider = $this->createMock($class);
|
||||
$provider->method('getAll')->willReturn($all);
|
||||
|
||||
return $provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Any query at all means the guard let a write through, which is the whole failure this
|
||||
* covers, so the connection fails the test rather than the command.
|
||||
*/
|
||||
private function refusingConnection(): Connection&MockObject
|
||||
{
|
||||
$connection = $this->createMock(Connection::class);
|
||||
$connection->expects($this->never())->method($this->anything());
|
||||
|
||||
return $connection;
|
||||
}
|
||||
|
||||
private function hotel(?string $costUnit, ?string $countryCode): Hotel
|
||||
{
|
||||
return (new Hotel())
|
||||
->setBusProId(163959)
|
||||
->setCode('LPJGR')
|
||||
->setName('Sportclub Jenatsch')
|
||||
->setCostUnit($costUnit)
|
||||
->setCountryCode($countryCode)
|
||||
;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Command;
|
||||
|
||||
use App\Command\MyEpImportCommand;
|
||||
use App\MyEp\Api\ApiException;
|
||||
use App\MyEp\Import\DestinationRow;
|
||||
use App\MyEp\Import\TravelExport;
|
||||
use App\MyEp\Import\TravelExportClient;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
/**
|
||||
* The import writes a destination's cost unit and country from the export, on update as well as
|
||||
* on insert, so it can only run once MyE&P reports both fields. Until it does, a run would blank
|
||||
* them on the 2,206 destinations that carry a cost unit and the 2,259 that carry a country -
|
||||
* silently, because nothing reads them back until an invoice is stamped. Most of these tests pin
|
||||
* the refusal, and that it happens before any write.
|
||||
*/
|
||||
class MyEpImportCommandTest extends TestCase
|
||||
{
|
||||
public function testItRefusesToRunWhenNoRowReportsACostUnit(): void
|
||||
{
|
||||
$tester = $this->runImport([$this->row(costUnit: null, country: 'ch')]);
|
||||
|
||||
$this->assertSame(Command::FAILURE, $tester->getStatusCode());
|
||||
$this->assertStringContainsString('Kostenstellen', $tester->getDisplay());
|
||||
$this->assertStringNotContainsString('Länderkennungen', $tester->getDisplay());
|
||||
}
|
||||
|
||||
public function testItRefusesToRunWhenNoRowReportsACountryCode(): void
|
||||
{
|
||||
$tester = $this->runImport([$this->row(costUnit: '4711', country: null)]);
|
||||
|
||||
$this->assertSame(Command::FAILURE, $tester->getStatusCode());
|
||||
$this->assertStringContainsString('Länderkennungen', $tester->getDisplay());
|
||||
}
|
||||
|
||||
public function testItNamesBothFieldsWhenNeitherIsReported(): void
|
||||
{
|
||||
$tester = $this->runImport([$this->row(costUnit: null, country: null)]);
|
||||
|
||||
$this->assertSame(Command::FAILURE, $tester->getStatusCode());
|
||||
$this->assertStringContainsString('Kostenstellen und Länderkennungen', $tester->getDisplay());
|
||||
}
|
||||
|
||||
public function testAnEmptyStringDoesNotCountAsAReportedField(): void
|
||||
{
|
||||
$tester = $this->runImport([$this->row(costUnit: '', country: '')]);
|
||||
|
||||
$this->assertSame(Command::FAILURE, $tester->getStatusCode());
|
||||
$this->assertStringContainsString('Kostenstellen und Länderkennungen', $tester->getDisplay());
|
||||
}
|
||||
|
||||
/**
|
||||
* Most hotels legitimately have no kostenstelle upstream, so the guard asks whether the field
|
||||
* is reported at all, not whether every row fills it.
|
||||
*/
|
||||
public function testOneRowCarryingAFieldIsEnoughToPassTheGuard(): void
|
||||
{
|
||||
$tester = $this->runImport(
|
||||
[$this->row(costUnit: null, country: null), $this->row(costUnit: '4711', country: 'ch')],
|
||||
$this->writingConnection(existingId: 7),
|
||||
);
|
||||
|
||||
$this->assertSame(Command::SUCCESS, $tester->getStatusCode());
|
||||
$this->assertStringNotContainsString('Kostenstellen', $tester->getDisplay());
|
||||
}
|
||||
|
||||
/**
|
||||
* A run that touches nothing and reports success reads as "nothing changed upstream" rather
|
||||
* than as a failure to read the export at all.
|
||||
*/
|
||||
public function testItRefusesToRunOnAnEmptyExport(): void
|
||||
{
|
||||
$tester = $this->runImport([]);
|
||||
|
||||
$this->assertSame(Command::FAILURE, $tester->getStatusCode());
|
||||
$this->assertStringContainsString('keine Reisen', $tester->getDisplay());
|
||||
}
|
||||
|
||||
public function testItRefusesToRunWhenTheExportCannotBeRead(): void
|
||||
{
|
||||
$client = $this->createMock(TravelExportClient::class);
|
||||
$client->method('fetchAll')->willThrowException(new ApiException('gateway timeout'));
|
||||
|
||||
$tester = new CommandTester(new MyEpImportCommand(
|
||||
$this->refusingConnection(),
|
||||
$client,
|
||||
new NullLogger(),
|
||||
));
|
||||
$tester->execute([]);
|
||||
|
||||
$this->assertSame(Command::FAILURE, $tester->getStatusCode());
|
||||
$this->assertStringContainsString('konnte nicht von MyE&P geladen werden', $tester->getDisplay());
|
||||
}
|
||||
|
||||
public function testAKnownPairIsUpdatedRatherThanInserted(): void
|
||||
{
|
||||
$connection = $this->createMock(Connection::class);
|
||||
$connection->method('fetchOne')->willReturn(42);
|
||||
$connection->expects($this->once())->method('update')->with(
|
||||
'destination',
|
||||
$this->callback(function (array $payload): bool {
|
||||
// the hotel name and code are the point: leaving them out of the update was what
|
||||
// let a renamed hotel keep a stale name forever and drop out of house matching
|
||||
$this->assertArrayHasKey('hotel', $payload);
|
||||
$this->assertArrayHasKey('hotel_code', $payload);
|
||||
$this->assertArrayNotHasKey('bus_pro_id', $payload);
|
||||
|
||||
return true;
|
||||
}),
|
||||
['id' => 42],
|
||||
);
|
||||
$connection->expects($this->never())->method('insert');
|
||||
|
||||
$tester = $this->runImport([$this->row(costUnit: '4711', country: 'ch')], $connection);
|
||||
|
||||
$this->assertSame(Command::SUCCESS, $tester->getStatusCode());
|
||||
$this->assertStringContainsString('0 Destinationen angelegt, 1 aktualisiert', $tester->getDisplay());
|
||||
}
|
||||
|
||||
public function testAnUnknownPairIsInserted(): void
|
||||
{
|
||||
$connection = $this->createMock(Connection::class);
|
||||
$connection->method('fetchOne')->willReturn(false);
|
||||
$connection->expects($this->once())->method('insert')->with(
|
||||
'destination',
|
||||
$this->callback(function (array $payload): bool {
|
||||
$this->assertSame(12778, $payload['bus_pro_id']);
|
||||
$this->assertSame(222928, $payload['hotel_bus_pro_id']);
|
||||
$this->assertSame('ch', $payload['country']);
|
||||
|
||||
return true;
|
||||
}),
|
||||
);
|
||||
$connection->expects($this->never())->method('update');
|
||||
|
||||
$tester = $this->runImport([$this->row(costUnit: '4711', country: 'ch')], $connection);
|
||||
|
||||
$this->assertStringContainsString('1 Destinationen angelegt, 0 aktualisiert', $tester->getDisplay());
|
||||
}
|
||||
|
||||
/**
|
||||
* Both counters are losses MyE&P made on its side and would otherwise not surface anywhere.
|
||||
*/
|
||||
public function testTheOperatorIsToldWhatMyEpDropped(): void
|
||||
{
|
||||
$tester = $this->runImport(
|
||||
[$this->row(costUnit: '4711', country: 'ch')],
|
||||
$this->writingConnection(existingId: 7),
|
||||
skipped: 3,
|
||||
pickupsWithoutMasterData: 21,
|
||||
);
|
||||
|
||||
$display = $tester->getDisplay();
|
||||
|
||||
$this->assertStringContainsString('3 Reise-Hotel-Kombinationen verworfen', $display);
|
||||
$this->assertStringContainsString('21 Zustiege ohne Stammdaten', $display);
|
||||
}
|
||||
|
||||
public function testACleanExportSaysNothingAboutDroppedRows(): void
|
||||
{
|
||||
$tester = $this->runImport(
|
||||
[$this->row(costUnit: '4711', country: 'ch')],
|
||||
$this->writingConnection(existingId: 7),
|
||||
);
|
||||
|
||||
$this->assertStringNotContainsString('verworfen', $tester->getDisplay());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param DestinationRow[] $rows
|
||||
*/
|
||||
private function runImport(
|
||||
array $rows,
|
||||
?Connection $connection = null,
|
||||
int $skipped = 0,
|
||||
int $pickupsWithoutMasterData = 0,
|
||||
): CommandTester {
|
||||
$client = $this->createMock(TravelExportClient::class);
|
||||
$client->method('fetchAll')->willReturn(
|
||||
new TravelExport($rows, $skipped, $pickupsWithoutMasterData, '2026-09-29T03:20:00+02:00')
|
||||
);
|
||||
|
||||
$tester = new CommandTester(new MyEpImportCommand(
|
||||
$connection ?? $this->refusingConnection(),
|
||||
$client,
|
||||
new NullLogger(),
|
||||
));
|
||||
|
||||
$tester->execute([]);
|
||||
|
||||
return $tester;
|
||||
}
|
||||
|
||||
/**
|
||||
* Any query at all means the guard let a write through, which is the whole failure the
|
||||
* refusal tests cover, so the connection fails the test rather than the command.
|
||||
*/
|
||||
private function refusingConnection(): Connection&MockObject
|
||||
{
|
||||
$connection = $this->createMock(Connection::class);
|
||||
$connection->expects($this->never())->method($this->anything());
|
||||
|
||||
return $connection;
|
||||
}
|
||||
|
||||
private function writingConnection(int $existingId): Connection&MockObject
|
||||
{
|
||||
$connection = $this->createMock(Connection::class);
|
||||
$connection->method('fetchOne')->willReturn($existingId);
|
||||
|
||||
return $connection;
|
||||
}
|
||||
|
||||
private function row(?string $costUnit, ?string $country): DestinationRow
|
||||
{
|
||||
return new DestinationRow(
|
||||
12778,
|
||||
'DWWSCH070227',
|
||||
'Davos - Sportclub Weissfluh',
|
||||
new \DateTimeImmutable('2027-02-07'),
|
||||
new \DateTimeImmutable('2027-02-12'),
|
||||
222928,
|
||||
'DWWGRU2',
|
||||
'Sportclub Weißfluh',
|
||||
$costUnit,
|
||||
$country,
|
||||
[],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -60,9 +60,32 @@ class DestinationRowTest extends TestCase
|
||||
'city' => 'Karlsruhe',
|
||||
'street' => 'A5 - Autohof Bruchsal',
|
||||
'time' => '23:59',
|
||||
'datetime' => '2026-04-04T23:59:00+02:00',
|
||||
]], $row->pickups);
|
||||
}
|
||||
|
||||
/**
|
||||
* MyE&P sends `time` as a bare HH:MM, so this is the only field that says which calendar day
|
||||
* a boarding point belongs to. Dropping it left App\Service\Assignment\PickupDepartureOrder
|
||||
* with no anchor and a naive clock sort, which puts a 00:30 stop before the 22:00 departure.
|
||||
*/
|
||||
public function testTheResolvedDepartureTimestampIsCarried(): void
|
||||
{
|
||||
$row = DestinationRow::fromArray($this->getRowData());
|
||||
|
||||
$this->assertSame('2026-04-04T23:59:00+02:00', $row->pickups[0]['datetime']);
|
||||
}
|
||||
|
||||
public function testAPickupWithoutAResolvedTimestampIsStillCarried(): void
|
||||
{
|
||||
$row = DestinationRow::fromArray($this->getRowData(['pickups' => [
|
||||
['busProId' => 27, 'city' => 'Ulm', 'street' => 'Busparkplatz', 'time' => null],
|
||||
]]));
|
||||
|
||||
$this->assertNull($row->pickups[0]['datetime']);
|
||||
$this->assertCount(1, $row->pickups);
|
||||
}
|
||||
|
||||
public function testStoredPickupsCanBeReadBackAsModels(): void
|
||||
{
|
||||
$row = DestinationRow::fromArray($this->getRowData());
|
||||
@@ -129,7 +152,7 @@ class DestinationRowTest extends TestCase
|
||||
'hotel' => 'Sportclub Jenatsch',
|
||||
'hotel_code' => 'LPJGR',
|
||||
'hotel_bus_pro_id' => 163959,
|
||||
'pickups' => '[{"busProId":4,"city":"Karlsruhe","street":"A5 - Autohof Bruchsal","time":"23:59"}]',
|
||||
'pickups' => '[{"busProId":4,"city":"Karlsruhe","street":"A5 - Autohof Bruchsal","time":"23:59","datetime":"2026-04-04T23:59:00+02:00"}]',
|
||||
'cost_unit' => '4711',
|
||||
'country' => 'ch',
|
||||
'created_at' => '2026-08-13 03:00:00',
|
||||
|
||||
@@ -21,7 +21,7 @@ class TravelExportClientTest extends TestCase
|
||||
$this->page([$this->row(3, 300)], 2, 2),
|
||||
]);
|
||||
|
||||
$rows = $this->getClientInstance($httpClient)->fetchAll();
|
||||
$rows = $this->getClientInstance($httpClient)->fetchAll()->rows;
|
||||
|
||||
$this->assertCount(3, $rows);
|
||||
$this->assertEquals([1, 2, 3], array_map(fn ($row) => $row->busProId, $rows));
|
||||
@@ -62,7 +62,7 @@ class TravelExportClientTest extends TestCase
|
||||
], 1, 1),
|
||||
]);
|
||||
|
||||
$rows = $this->getClientInstance($httpClient)->fetchAll();
|
||||
$rows = $this->getClientInstance($httpClient)->fetchAll()->rows;
|
||||
|
||||
$this->assertCount(1, $rows);
|
||||
$this->assertEquals(2, $rows[0]->busProId);
|
||||
@@ -72,7 +72,7 @@ class TravelExportClientTest extends TestCase
|
||||
{
|
||||
$httpClient = $this->mockClient([$this->page([], 1, 1)]);
|
||||
|
||||
$this->assertEquals([], $this->getClientInstance($httpClient)->fetchAll());
|
||||
$this->assertEquals([], $this->getClientInstance($httpClient)->fetchAll()->rows);
|
||||
}
|
||||
|
||||
public function testAPayloadWithoutADataListFails(): void
|
||||
@@ -97,14 +97,71 @@ class TravelExportClientTest extends TestCase
|
||||
'code' => 'AAA',
|
||||
'name' => 'Haus',
|
||||
'costUnit' => null,
|
||||
'country' => 'ch',
|
||||
'country' => 'CH',
|
||||
'countryCode' => 'ch',
|
||||
],
|
||||
'pickups' => [],
|
||||
];
|
||||
}
|
||||
|
||||
private function page(array $rows, int $page, int $totalPages): MockResponse
|
||||
/**
|
||||
* Both counters are losses MyE&P made on its side - a dropped travel, a boarding point with no
|
||||
* town - and the import is the only place they surface, so the client has to carry them out.
|
||||
*/
|
||||
public function testTheCountersMyEpReportsAreCarriedOut(): void
|
||||
{
|
||||
$httpClient = $this->mockClient([$this->page([$this->row(1, 100)], 1, 1, skipped: 3, pickupsWithoutMasterData: 21)]);
|
||||
|
||||
$export = $this->getClientInstance($httpClient)->fetchAll();
|
||||
|
||||
$this->assertSame(3, $export->skipped);
|
||||
$this->assertSame(21, $export->pickupsWithoutMasterData);
|
||||
$this->assertSame('2026-09-29T03:20:00+02:00', $export->generatedAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* They describe the whole export rather than the page, so they repeat unchanged and the first
|
||||
* page answers for the run. Reading the last one instead would be just as correct today and
|
||||
* wrong the moment a page is served from a newer build.
|
||||
*/
|
||||
public function testTheCountersAreReadFromTheFirstPage(): void
|
||||
{
|
||||
$httpClient = $this->mockClient([
|
||||
$this->page([$this->row(1, 100)], 1, 2, skipped: 3, pickupsWithoutMasterData: 21),
|
||||
$this->page([$this->row(2, 200)], 2, 2, skipped: 99, pickupsWithoutMasterData: 99),
|
||||
]);
|
||||
|
||||
$export = $this->getClientInstance($httpClient)->fetchAll();
|
||||
|
||||
$this->assertSame(3, $export->skipped);
|
||||
$this->assertSame(21, $export->pickupsWithoutMasterData);
|
||||
}
|
||||
|
||||
/**
|
||||
* An export whose meta block says nothing must not read as one that dropped nothing by
|
||||
* accident of a missing key, but zero is the only honest default available here.
|
||||
*/
|
||||
public function testAMetaBlockWithoutCountersReadsAsZero(): void
|
||||
{
|
||||
$httpClient = new MockHttpClient(
|
||||
[new MockResponse(json_encode(['data' => [$this->row(1, 100)], 'meta' => ['totalPages' => 1]]))],
|
||||
'https://myep.example/',
|
||||
);
|
||||
|
||||
$export = $this->getClientInstance($httpClient)->fetchAll();
|
||||
|
||||
$this->assertSame(0, $export->skipped);
|
||||
$this->assertSame(0, $export->pickupsWithoutMasterData);
|
||||
$this->assertNull($export->generatedAt);
|
||||
}
|
||||
|
||||
private function page(
|
||||
array $rows,
|
||||
int $page,
|
||||
int $totalPages,
|
||||
int $skipped = 0,
|
||||
int $pickupsWithoutMasterData = 0,
|
||||
): MockResponse {
|
||||
return new MockResponse(json_encode([
|
||||
'data' => $rows,
|
||||
'meta' => [
|
||||
@@ -112,6 +169,9 @@ class TravelExportClientTest extends TestCase
|
||||
'perPage' => 500,
|
||||
'total' => count($rows),
|
||||
'totalPages' => $totalPages,
|
||||
'skipped' => $skipped,
|
||||
'pickupsWithoutMasterData' => $pickupsWithoutMasterData,
|
||||
'generatedAt' => '2026-09-29T03:20:00+02:00',
|
||||
],
|
||||
]));
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user