fix: emit export pickup times as clock readings without an offset

This commit is contained in:
fromme committed 2026-09-30 14:52:04 +02:00
1 parent 6c700ea18b
commit ede8fd840a
6 files changed
+136 -27

No files matched your search

+18 -12
View File
@@ -190,7 +190,7 @@ an error: it returns `200` with an empty `data` array and the usual `meta`.
"city": "Essen",
"street": "Am Hauptbahnhof (Südausgang Fernbushaltestelle)",
"time": "20:15",
"datetime": "2026-12-25T20:15:00+01:00"
"datetime": "2026-12-25T20:15:00"
}
]
}
@@ -225,12 +225,17 @@ rest. `total` is the row count **after** skips, and `totalPages` is `ceil(total
**Pickup times.** `pickups` is in **source order** — the order the boarding list is driven in — not sorted by time.
- `time` is `"HH:MM"` for display; `datetime` is the same moment as a full ISO-8601 string with a
`Europe/Berlin` offset (`+01:00` / `+02:00` depending on DST). **Prefer `datetime`.**
- Upstream states a boarding time either as a full date and time or as a bare time. A bare time is resolved
against the departure date and, where the sequence demands it, rolled onto the following calendar day — so an
`02:15` listed after a `23:59` comes back on the next date, not the same one. `datetime` is emitted for
bare-time sources too, and carries that resolution.
- `time` is `"HH:MM"` for display; `datetime` is the same reading with its date resolved, as
`YYYY-MM-DDTHH:MM:SS` **without an offset**. **Prefer `datetime`.**
- Both are upstream's local clock readings, not instants: coaches are timetabled in plain clock times. A stop at
`02:45` on the night the clocks go forward comes back as `…T02:45:00`, although that hour does not exist in
`Europe/Berlin`, and a stop in the hour repeated when the clocks go back passes through unchanged. Do not attach
an offset to compare or sort them; they already sort correctly as strings.
- Upstream states a boarding time either as a full date and time, taken verbatim, or as a bare time. A bare time
takes the departure date (`dateFrom`), and moves to the following day if it would otherwise fall before the
latest full date and time **anywhere** in the same boarding list, whatever its position. So on a departure
dated 26.12. whose list also dates a stop `26.12. 23:59`, a bare `02:15` comes back as `…12-27T02:15:00`.
`datetime` carries that resolution for bare-time sources too.
- A boarding point with no time upstream comes back with **both** `time` and `datetime` null. They are always
null together.
- Caveat: where upstream states full datetimes for a whole boarding list, those dates are taken verbatim. Some
@@ -313,7 +318,7 @@ returns it in one page.
"city": "Frankfurt",
"street": "Flughafen (P36 zwischen Terminal 1 und 2)",
"price": 5.9,
"datetime": "2026-12-03T23:30:00+01:00"
"datetime": "2026-12-03T23:30:00"
}
],
"guide": null
@@ -380,10 +385,11 @@ such as for arriving by car.
| 89 | Leistungen Events |
| 90 | Leistungen Transport |
**Pickups.** Outbound boarding points only, in source order. `datetime` follows the same resolution rules as in
`GET /api/exports/travels`, including rolling a bare time past midnight onto the next day. There is no separate
`time` field; format `datetime` for display. `price` is the outbound boarding surcharge exactly as upstream states
it: `null` where upstream states none. It is not levelled against the return leg. An id absent from
**Pickups.** Outbound boarding points only, in source order. `datetime` is resolved and formatted exactly as in
`GET /api/exports/travels` — a clock reading without an offset, `null` where upstream states no time — including
rolling a bare time past midnight onto the next day. There is no separate `time` field; format `datetime` for
display. `price` is the outbound boarding surcharge exactly as upstream states it: `null` where upstream states
none. It is not levelled against the return leg. An id absent from
`GET /api/pickups` is emitted with `code`, `city` and `street` null and counted in `pickupsWithoutMasterData`. An id
of `0` is emitted as-is but not counted.
+5 -2
View File
@@ -79,6 +79,9 @@ trait TypeConversionTrait
* This method is used primarily to parse pickup-times since data is
* either provided with date and time or time only. In this case a
* date has to be provided which defaults to a travel's start date.
*
* Both shapes are read in the default date's zone, so a caller that needs plain clock
* readings can pass a UTC date and no daylight saving gap shifts the time.
*/
protected function stringToDateTimeFuzzy(?string $string, \DateTimeImmutable $defaultDate): ?\DateTimeImmutable
{
@@ -89,12 +92,12 @@ trait TypeConversionTrait
try {
if (1 === preg_match('/^\d{2}:\d{2}$/', $string)) {
// time only provided
$dateTime = Carbon::createFromFormat('H:i', $string);
$dateTime = Carbon::createFromFormat('H:i', $string, $defaultDate->getTimezone());
// assign default date
$dateTime->setDateFrom($defaultDate);
} else {
// date and time provided
$dateTime = Carbon::createFromFormat('d.m.Y H:i', substr($string, 0, 16));
$dateTime = Carbon::createFromFormat('d.m.Y H:i', substr($string, 0, 16), $defaultDate->getTimezone());
}
return $dateTime->toDateTimeImmutable();
+7 -2
View File
@@ -193,6 +193,10 @@ class ProductExportBuilder
* getPickups() resolves both zeit shapes and rolls a bare time past midnight onto the
* next day. Entries with id 0 are kept: the consumer drops them itself.
*
* `datetime` is BusPro's clock reading, without an offset. Resolving in Europe/Berlin
* would move a bare 02:45 on the spring-forward night to 03:45, so the dates are
* resolved in UTC, where no time is skipped or repeated.
*
* @param array<int|string, Pickup> $masterData
* @param array<int, true> $pickupsWithoutMasterData
*
@@ -201,8 +205,9 @@ class ProductExportBuilder
private function buildPickups(Crawler $dateNode, \DateTimeImmutable $dateFrom, array $masterData, array &$pickupsWithoutMasterData): array
{
$resolved = [];
$clockDate = new \DateTimeImmutable($dateFrom->format('Y-m-d'), new \DateTimeZone('UTC'));
foreach ($this->travelParser->getPickups($this->productExportParser->children($dateNode, 'zustiege/zustieg'), $dateFrom, true) as $pickup) {
foreach ($this->travelParser->getPickups($this->productExportParser->children($dateNode, 'zustiege/zustieg'), $clockDate, true) as $pickup) {
$pickupId = (int) $pickup->id;
$details = $masterData[$pickupId] ?? null;
@@ -216,7 +221,7 @@ class ProductExportBuilder
'city' => $details?->city,
'street' => $details?->street,
'price' => $pickup->price,
'datetime' => $pickup->time?->format('c'),
'datetime' => $pickup->time?->format('Y-m-d\TH:i:s'),
];
}
+11 -3
View File
@@ -144,8 +144,15 @@ class TravelExportBuilder
// getPickups() is what buys stringToDateTimeFuzzy() for both zeit shapes and
// adjustOvernightTimes(), which rolls an 02:15 listed after a 23:59 onto the next
// calendar day. Resolved once per travel date, then shared by its hotel rows.
//
// Resolved in UTC: the times are BusPro's clock readings, and in Europe/Berlin a
// bare 02:45 on the spring-forward night would move to 03:45.
$pickups = $this->buildPickups(
$this->travelParser->getPickups($node->filterXPath('.//zustiege/zustieg'), $dateFrom, true),
$this->travelParser->getPickups(
$node->filterXPath('.//zustiege/zustieg'),
new \DateTimeImmutable($dateFrom->format('Y-m-d'), new \DateTimeZone('UTC')),
true,
),
$pickupMasterData,
$pickupsWithoutMasterData,
);
@@ -234,8 +241,9 @@ class TravelExportBuilder
'street' => $details?->street,
'time' => $pickup->time?->format('H:i'),
// Emitted for time-only sources too: adjustOvernightTimes() has already
// worked out the real day, and a null here would throw that away.
'datetime' => $pickup->time?->format('c'),
// worked out the real day, and a null here would throw that away. A clock
// reading without an offset, like `time`.
'datetime' => $pickup->time?->format('Y-m-d\TH:i:s'),
];
}
+58 -2
View File
@@ -159,14 +159,56 @@ class ProductExportBuilderTest extends TestCase
'city' => 'Stadt 4',
'street' => 'Strasse 4',
'price' => 5.9,
'datetime' => '2026-12-03T23:30:00+01:00',
'datetime' => '2026-12-03T23:30:00',
], $pickups[0]);
self::assertNull($pickups[1]['price'], 'an absent price is null');
self::assertSame('2026-12-04T01:45:00+01:00', $pickups[1]['datetime']);
self::assertSame('2026-12-04T01:45:00', $pickups[1]['datetime']);
self::assertSame(0.0, $pickups[2]['price'], 'a stated 0,00 is kept as 0');
self::assertNull($pickups[2]['code']);
}
public function testPickupTimesInTheSpringForwardGapAreNotShifted(): void
{
// DPWUSMP280327 in the 30.09.2026 13:11 export. Berlin skips 02:00-02:59 that night.
$pickups = $this->pickupsFor('28.03.2027', <<<'XML'
<zustieg id="1" idbuspro="4" zeit="00:30" />
<zustieg id="2" idbuspro="27" zeit="02:45" />
<zustieg id="3" idbuspro="3" zeit="27.03.2027 22:00:00" />
<zustieg id="4" idbuspro="89" zeit="01:30" />
<zustieg id="5" idbuspro="86" zeit="27.03.2027 23:30:00" />
<zustieg id="6" idbuspro="74" zeit="03:45" />
<zustieg id="7" idbuspro="262" zeit="04:45" />
XML);
self::assertSame([
'2027-03-28T00:30:00',
'2027-03-28T02:45:00',
'2027-03-27T22:00:00',
'2027-03-28T01:30:00',
'2027-03-27T23:30:00',
'2027-03-28T03:45:00',
'2027-03-28T04:45:00',
], array_column($pickups, 'datetime'));
}
public function testPickupTimesInTheFallBackHourPassThroughWhenRolledOntoTheNextDay(): void
{
// Berlin runs 02:00-02:59 twice on 31.10.2027; the bare times roll onto it from the 30th.
$pickups = $this->pickupsFor('30.10.2027', <<<'XML'
<zustieg id="1" idbuspro="4" zeit="30.10.2027 23:30:00" />
<zustieg id="2" idbuspro="27" zeit="02:15" />
<zustieg id="3" idbuspro="55" zeit="02:59" />
<zustieg id="4" idbuspro="0" zeit="31.10.2027 02:30:00" />
XML);
self::assertSame([
'2027-10-30T23:30:00',
'2027-10-31T02:15:00',
'2027-10-31T02:59:00',
'2027-10-31T02:30:00',
], array_column($pickups, 'datetime'));
}
public function testDoesNotLevelBoardingPointPrices(): void
{
// Point 27 is priced on the return leg only. The booking path levels that onto the
@@ -200,6 +242,20 @@ class ProductExportBuilderTest extends TestCase
self::fail('No row for '.$key);
}
/**
* The fixture's first date moved to $dateFrom, with $zustiege as its boarding list.
*
* @return list<array<string, mixed>>
*/
private function pickupsFor(string $dateFrom, string $zustiege): array
{
$xml = (string) file_get_contents(__DIR__.'/../Resources/product_export_data.xml');
$xml = str_replace('termin="04.12.2026"', 'termin="'.$dateFrom.'"', $xml);
$xml = (string) preg_replace('#<zustiege>.*?</zustiege>#s', '<zustiege>'.$zustiege.'</zustiege>', $xml);
return $this->build($xml)->rows[0]['pickups'];
}
private function build(?string $xml = null, ?string $marker = null): Export
{
$xml ??= (string) file_get_contents(__DIR__.'/../Resources/product_export_data.xml');
+37 -6
View File
@@ -61,26 +61,57 @@ class TravelExportBuilderTest extends TestCase
$pickups = array_column($this->rowFor('900-1')['pickups'], null, 'busProId');
self::assertSame('23:59', $pickups[10]['time']);
self::assertSame('2026-12-26T23:59:00+01:00', $pickups[10]['datetime']);
self::assertSame('2026-12-26T23:59:00', $pickups[10]['datetime']);
// Time only, so it defaults to the departure date -- which would put it before the
// 23:59 above. The coach cannot board backwards, so it rolls onto the next day.
self::assertSame('02:15', $pickups[11]['time']);
self::assertSame('2026-12-27T02:15:00+01:00', $pickups[11]['datetime']);
self::assertSame('2026-12-27T02:15:00', $pickups[11]['datetime']);
}
public function testEmitsTheSummerOffset(): void
public function testEmitsNoOffsetInSummerEither(): void
{
$pickups = $this->rowFor('901-1')['pickups'];
self::assertSame('2027-07-15T08:00:00+02:00', $pickups[0]['datetime']);
self::assertSame('2027-07-15T08:00:00', $pickups[0]['datetime']);
}
public function testPickupTimesInTheSpringForwardGapAreNotShifted(): void
{
// Berlin skips 02:00-02:59 on 28.03.2027; BusPro still plans a stop at 02:45.
$pickups = $this->build(<<<'XML'
<?xml version="1.0"?>
<reisen><reise><termin idbuspro="1" termin="28.03.2027" bis="03.04.2027"><hotel idbuspro="1"/><zustiege>
<zustieg idbuspro="10" zeit="27.03.2027 23:30:00"/>
<zustieg idbuspro="11" zeit="02:45"/>
<zustieg idbuspro="12" zeit="03:45"/>
</zustiege></termin></reise></reisen>
XML)->rows[0]['pickups'];
self::assertSame(['23:30', '02:45', '03:45'], array_column($pickups, 'time'));
self::assertSame(['2027-03-27T23:30:00', '2027-03-28T02:45:00', '2027-03-28T03:45:00'], array_column($pickups, 'datetime'));
}
public function testPickupTimesInTheFallBackHourPassThroughWhenRolledOntoTheNextDay(): void
{
// Berlin runs 02:00-02:59 twice on 31.10.2027; the bare times roll onto it from the 30th.
$pickups = $this->build(<<<'XML'
<?xml version="1.0"?>
<reisen><reise><termin idbuspro="1" termin="30.10.2027" bis="06.11.2027"><hotel idbuspro="1"/><zustiege>
<zustieg idbuspro="10" zeit="30.10.2027 23:30:00"/>
<zustieg idbuspro="11" zeit="02:15"/>
<zustieg idbuspro="12" zeit="02:59"/>
</zustiege></termin></reise></reisen>
XML)->rows[0]['pickups'];
self::assertSame(['2027-10-30T23:30:00', '2027-10-31T02:15:00', '2027-10-31T02:59:00'], array_column($pickups, 'datetime'));
}
public function testATimeOnlyPickupThatAlreadyFollowsTheLastDatedOneKeepsTheDepartureDate(): void
{
$pickups = array_column($this->rowFor('901-1')['pickups'], null, 'busProId');
self::assertSame('2027-07-15T09:30:00+02:00', $pickups[11]['datetime'], 'no roll when the order already holds');
self::assertSame('2027-07-15T09:30:00', $pickups[11]['datetime'], 'no roll when the order already holds');
}
public function testKeepsAPickupWithoutAZeitAndNullsBothTimeFields(): void
@@ -104,7 +135,7 @@ class TravelExportBuilderTest extends TestCase
self::assertNull($pickups[99]['city']);
self::assertNull($pickups[99]['street']);
self::assertSame('21:00', $pickups[99]['time'], 'the time is from the travel XML, not master data');
self::assertSame('2026-12-26T21:00:00+01:00', $pickups[99]['datetime']);
self::assertSame('2026-12-26T21:00:00', $pickups[99]['datetime']);
// Counted once, not once per row that references it.
self::assertSame(1, $export->pickupsWithoutMasterData);