From ede8fd840a68a00e26c16218a7626b9aaad0e1cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Wed, 30 Sep 2026 14:51:25 +0200 Subject: [PATCH] fix: emit export pickup times as clock readings without an offset --- docs/api-consumer-guide.md | 30 ++++++---- src/BusProNet/Traits/TypeConversionTrait.php | 7 ++- src/Service/ProductExportBuilder.php | 9 ++- src/Service/TravelExportBuilder.php | 14 ++++- tests/Service/ProductExportBuilderTest.php | 60 +++++++++++++++++++- tests/Service/TravelExportBuilderTest.php | 43 ++++++++++++-- 6 files changed, 136 insertions(+), 27 deletions(-) diff --git a/docs/api-consumer-guide.md b/docs/api-consumer-guide.md index f8bbf549..4a4e1a91 100644 --- a/docs/api-consumer-guide.md +++ b/docs/api-consumer-guide.md @@ -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. diff --git a/src/BusProNet/Traits/TypeConversionTrait.php b/src/BusProNet/Traits/TypeConversionTrait.php index cf87f380..7cd7999e 100644 --- a/src/BusProNet/Traits/TypeConversionTrait.php +++ b/src/BusProNet/Traits/TypeConversionTrait.php @@ -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(); diff --git a/src/Service/ProductExportBuilder.php b/src/Service/ProductExportBuilder.php index 4daf6bf0..afa3550d 100644 --- a/src/Service/ProductExportBuilder.php +++ b/src/Service/ProductExportBuilder.php @@ -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 $masterData * @param array $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'), ]; } diff --git a/src/Service/TravelExportBuilder.php b/src/Service/TravelExportBuilder.php index e81fd996..ad4f05e5 100644 --- a/src/Service/TravelExportBuilder.php +++ b/src/Service/TravelExportBuilder.php @@ -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'), ]; } diff --git a/tests/Service/ProductExportBuilderTest.php b/tests/Service/ProductExportBuilderTest.php index 70db4943..db0baf0e 100644 --- a/tests/Service/ProductExportBuilderTest.php +++ b/tests/Service/ProductExportBuilderTest.php @@ -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' + + + + + + + + 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' + + + + + 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> + */ + 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('#.*?#s', ''.$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'); diff --git a/tests/Service/TravelExportBuilderTest.php b/tests/Service/TravelExportBuilderTest.php index f5bbab23..aca3818e 100644 --- a/tests/Service/TravelExportBuilderTest.php +++ b/tests/Service/TravelExportBuilderTest.php @@ -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)->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)->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);