feat: report the source export timestamp on every travel export page
This commit is contained in:
1 parent
c2c55bc8b2
commit
272c811f4f
6 files changed
+49
-5
No files matched your search
@@ -202,6 +202,7 @@ an error: it returns `200` with an empty `data` array and the usual `meta`.
|
||||
"totalPages": 1,
|
||||
"skipped": 0,
|
||||
"pickupsWithoutMasterData": 0,
|
||||
"sourceGeneratedAt": "2026-09-29T11:11:01+02:00",
|
||||
"generatedAt": "2026-09-29T12:10:06+02:00"
|
||||
}
|
||||
}
|
||||
@@ -211,8 +212,8 @@ an error: it returns `200` with an empty `data` array and the usual `meta`.
|
||||
is always an int and never null; `hotel` is always an object and never null; `pickups` is always an array, possibly
|
||||
empty, never null.
|
||||
|
||||
**`meta` describes the whole export, not the page.** `total`, `totalPages`, `skipped`, `pickupsWithoutMasterData`
|
||||
and `generatedAt` are identical on every page, so you can read them once on page 1 and decide whether to walk the
|
||||
**`meta` describes the whole export, not the page.** `total`, `totalPages`, `skipped`, `pickupsWithoutMasterData`,
|
||||
`sourceGeneratedAt` and `generatedAt` are identical on every page, so you can read them once on page 1 and decide whether to walk the
|
||||
rest. `total` is the row count **after** skips, and `totalPages` is `ceil(total / perPage)`.
|
||||
|
||||
- `skipped` — (travel, hotel) pairs dropped as unusable: a travel date that could not be parsed, or a hotel absent
|
||||
@@ -240,6 +241,11 @@ rest. `total` is the row count **after** skips, and `totalPages` is `ceil(total
|
||||
under one cache tag, so all three move together on an upstream sync rather than drifting between your calls.
|
||||
It is cached for 3 hours; `generatedAt` is when the cached copy was assembled, not when you requested it.
|
||||
|
||||
**Page consistency.** `sourceGeneratedAt` is the timestamp of the upstream export the rows were built from — the
|
||||
same value `GET /api/last-update` reports, as ISO-8601 — or `null` if it could not be read. An upstream sync can
|
||||
replace the cached copy while you are walking the pages; if `sourceGeneratedAt` differs from the value you saw on
|
||||
page 1, you have stitched two exports together. Discard what you have and restart from page 1.
|
||||
|
||||
---
|
||||
|
||||
### Travels
|
||||
|
||||
@@ -59,6 +59,7 @@ class ExportController extends AbstractController
|
||||
'totalPages' => (int) ceil($total / $query->perPage),
|
||||
'skipped' => $export->skipped,
|
||||
'pickupsWithoutMasterData' => $export->pickupsWithoutMasterData,
|
||||
'sourceGeneratedAt' => $export->sourceGeneratedAt?->format('c'),
|
||||
'generatedAt' => $export->generatedAt->format('c'),
|
||||
],
|
||||
]);
|
||||
|
||||
@@ -19,12 +19,14 @@ final readonly class TravelExport
|
||||
* @param list<array<string, mixed>> $rows one row per (travel, hotel) pair
|
||||
* @param int $skipped (travel, hotel) pairs dropped as unusable
|
||||
* @param int $pickupsWithoutMasterData distinct pickup ids absent from zustiege.xml
|
||||
* @param \DateTimeImmutable|null $sourceGeneratedAt the uebertragung.info timestamp of the export read, null if unknown
|
||||
*/
|
||||
public function __construct(
|
||||
public array $rows,
|
||||
public int $skipped,
|
||||
public int $pickupsWithoutMasterData,
|
||||
public \DateTimeImmutable $generatedAt,
|
||||
public ?\DateTimeImmutable $sourceGeneratedAt = null,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ namespace App\Service;
|
||||
|
||||
use App\BusProNet\Model\Hotel;
|
||||
use App\BusProNet\Model\Pickup;
|
||||
use App\BusProNet\Model\XmlExportInfo;
|
||||
use App\BusProNet\Traits\TypeConversionTrait;
|
||||
use App\BusProNet\XmlCrawlerFactory;
|
||||
use App\BusProNet\XmlLoader\HotelLoader;
|
||||
@@ -67,6 +68,11 @@ class TravelExportBuilder
|
||||
|
||||
private function assemble(): TravelExport
|
||||
{
|
||||
// Read before the walk and cached with the rows, so every page served from this
|
||||
// copy names the same source export. A client that sees it change between pages
|
||||
// knows xml-sync swapped the export underneath its walk.
|
||||
$sourceGeneratedAt = XmlExportInfo::fromStorage($this->xmlExport)?->lastTransfer;
|
||||
|
||||
$hotels = $this->hotelLoader->loadAll();
|
||||
$pickupMasterData = $this->pickupLoader->loadAll();
|
||||
|
||||
@@ -96,6 +102,7 @@ class TravelExportBuilder
|
||||
$skipped,
|
||||
count($pickupsWithoutMasterData),
|
||||
new \DateTimeImmutable(),
|
||||
$sourceGeneratedAt,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ class ExportControllerTest extends TestCase
|
||||
$first = $this->call(new TravelExportQuery(1, 5))['meta'];
|
||||
$last = $this->call(new TravelExportQuery(3, 5))['meta'];
|
||||
|
||||
foreach (['total', 'totalPages', 'skipped', 'pickupsWithoutMasterData', 'generatedAt'] as $key) {
|
||||
foreach (['total', 'totalPages', 'skipped', 'pickupsWithoutMasterData', 'sourceGeneratedAt', 'generatedAt'] as $key) {
|
||||
self::assertSame($first[$key], $last[$key], $key.' must not vary by page');
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,18 @@ class ExportControllerTest extends TestCase
|
||||
self::assertSame(7, $meta['skipped']);
|
||||
self::assertSame(21, $meta['pickupsWithoutMasterData']);
|
||||
self::assertSame('2026-09-29T03:20:00+02:00', $meta['generatedAt']);
|
||||
self::assertSame('2026-09-28T23:10:15+02:00', $meta['sourceGeneratedAt']);
|
||||
}
|
||||
|
||||
public function testAnUnknownSourceExportIsNullRatherThanOmitted(): void
|
||||
{
|
||||
$builder = $this->createStub(TravelExportBuilder::class);
|
||||
$builder->method('build')->willReturn(new TravelExport([], 0, 0, new \DateTimeImmutable()));
|
||||
|
||||
$meta = json_decode((string) $this->controller($builder)->travels(new TravelExportQuery())->getContent(), true)['meta'];
|
||||
|
||||
self::assertArrayHasKey('sourceGeneratedAt', $meta);
|
||||
self::assertNull($meta['sourceGeneratedAt']);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -110,6 +122,7 @@ class ExportControllerTest extends TestCase
|
||||
$skipped,
|
||||
$withoutMasterData,
|
||||
new \DateTimeImmutable('2026-09-29 03:20:00', new \DateTimeZone('Europe/Berlin')),
|
||||
new \DateTimeImmutable('2026-09-28 23:10:15', new \DateTimeZone('Europe/Berlin')),
|
||||
));
|
||||
|
||||
$response = $this->controller($builder)->travels($query ?? new TravelExportQuery());
|
||||
|
||||
@@ -156,6 +156,18 @@ class TravelExportBuilderTest extends TestCase
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function testCarriesTheSourceExportTimestamp(): void
|
||||
{
|
||||
$export = $this->build(null, ['Ziel_1.xml'], "22.09.2026 11:10:15\r\nLetzte \xDCbertragung\r\nAnzahl \xDCbertragene Dateien: 177\r\nVollexport\r\n");
|
||||
|
||||
self::assertSame('2026-09-22T11:10:15+02:00', $export->sourceGeneratedAt?->format('c'));
|
||||
}
|
||||
|
||||
public function testTheSourceExportTimestampIsNullWithoutAMarker(): void
|
||||
{
|
||||
self::assertNull($this->build()->sourceGeneratedAt);
|
||||
}
|
||||
|
||||
private function rowFor(string $key): array
|
||||
{
|
||||
foreach ($this->build()->rows as $row) {
|
||||
@@ -170,7 +182,7 @@ class TravelExportBuilderTest extends TestCase
|
||||
/**
|
||||
* @param list<string> $files
|
||||
*/
|
||||
private function build(?string $xml = null, array $files = ['Ziel_1.xml']): TravelExport
|
||||
private function build(?string $xml = null, array $files = ['Ziel_1.xml'], ?string $marker = null): TravelExport
|
||||
{
|
||||
$contents = [
|
||||
'Ziel_1.xml' => $xml ?? (string) file_get_contents(__DIR__.'/../Resources/travel_export_data.xml'),
|
||||
@@ -183,7 +195,10 @@ class TravelExportBuilderTest extends TestCase
|
||||
$files,
|
||||
)));
|
||||
$filesystem->method('read')->willReturnCallback(
|
||||
static fn (string $path): string => $contents[$path] ?? ''
|
||||
static fn (string $path): string => 'uebertragung.info' === $path ? (string) $marker : $contents[$path] ?? ''
|
||||
);
|
||||
$filesystem->method('fileExists')->willReturnCallback(
|
||||
static fn (string $path): bool => 'uebertragung.info' === $path && null !== $marker
|
||||
);
|
||||
|
||||
$hotelLoader = $this->createStub(HotelLoader::class);
|
||||
|
||||
Reference in new issue
Block a user