fix: emit export pickup times as clock readings without an offset
This commit is contained in:
1 parent
6c700ea18b
commit
ede8fd840a
6 files changed
+136
-27
No files matched your search
+18
-12
@@ -190,7 +190,7 @@ an error: it returns `200` with an empty `data` array and the usual `meta`.
|
|||||||
"city": "Essen",
|
"city": "Essen",
|
||||||
"street": "Am Hauptbahnhof (Südausgang Fernbushaltestelle)",
|
"street": "Am Hauptbahnhof (Südausgang Fernbushaltestelle)",
|
||||||
"time": "20:15",
|
"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.
|
**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
|
- `time` is `"HH:MM"` for display; `datetime` is the same reading with its date resolved, as
|
||||||
`Europe/Berlin` offset (`+01:00` / `+02:00` depending on DST). **Prefer `datetime`.**
|
`YYYY-MM-DDTHH:MM:SS` **without an offset**. **Prefer `datetime`.**
|
||||||
- Upstream states a boarding time either as a full date and time or as a bare time. A bare time is resolved
|
- Both are upstream's local clock readings, not instants: coaches are timetabled in plain clock times. A stop at
|
||||||
against the departure date and, where the sequence demands it, rolled onto the following calendar day — so an
|
`02:45` on the night the clocks go forward comes back as `…T02:45:00`, although that hour does not exist in
|
||||||
`02:15` listed after a `23:59` comes back on the next date, not the same one. `datetime` is emitted for
|
`Europe/Berlin`, and a stop in the hour repeated when the clocks go back passes through unchanged. Do not attach
|
||||||
bare-time sources too, and carries that resolution.
|
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
|
- A boarding point with no time upstream comes back with **both** `time` and `datetime` null. They are always
|
||||||
null together.
|
null together.
|
||||||
- Caveat: where upstream states full datetimes for a whole boarding list, those dates are taken verbatim. Some
|
- 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",
|
"city": "Frankfurt",
|
||||||
"street": "Flughafen (P36 zwischen Terminal 1 und 2)",
|
"street": "Flughafen (P36 zwischen Terminal 1 und 2)",
|
||||||
"price": 5.9,
|
"price": 5.9,
|
||||||
"datetime": "2026-12-03T23:30:00+01:00"
|
"datetime": "2026-12-03T23:30:00"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"guide": null
|
"guide": null
|
||||||
@@ -380,10 +385,11 @@ such as for arriving by car.
|
|||||||
| 89 | Leistungen Events |
|
| 89 | Leistungen Events |
|
||||||
| 90 | Leistungen Transport |
|
| 90 | Leistungen Transport |
|
||||||
|
|
||||||
**Pickups.** Outbound boarding points only, in source order. `datetime` follows the same resolution rules as in
|
**Pickups.** Outbound boarding points only, in source order. `datetime` is resolved and formatted exactly as in
|
||||||
`GET /api/exports/travels`, including rolling a bare time past midnight onto the next day. There is no separate
|
`GET /api/exports/travels` — a clock reading without an offset, `null` where upstream states no time — including
|
||||||
`time` field; format `datetime` for display. `price` is the outbound boarding surcharge exactly as upstream states
|
rolling a bare time past midnight onto the next day. There is no separate `time` field; format `datetime` for
|
||||||
it: `null` where upstream states none. It is not levelled against the return leg. An id absent from
|
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
|
`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.
|
of `0` is emitted as-is but not counted.
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,9 @@ trait TypeConversionTrait
|
|||||||
* This method is used primarily to parse pickup-times since data is
|
* 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
|
* 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.
|
* 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
|
protected function stringToDateTimeFuzzy(?string $string, \DateTimeImmutable $defaultDate): ?\DateTimeImmutable
|
||||||
{
|
{
|
||||||
@@ -89,12 +92,12 @@ trait TypeConversionTrait
|
|||||||
try {
|
try {
|
||||||
if (1 === preg_match('/^\d{2}:\d{2}$/', $string)) {
|
if (1 === preg_match('/^\d{2}:\d{2}$/', $string)) {
|
||||||
// time only provided
|
// time only provided
|
||||||
$dateTime = Carbon::createFromFormat('H:i', $string);
|
$dateTime = Carbon::createFromFormat('H:i', $string, $defaultDate->getTimezone());
|
||||||
// assign default date
|
// assign default date
|
||||||
$dateTime->setDateFrom($defaultDate);
|
$dateTime->setDateFrom($defaultDate);
|
||||||
} else {
|
} else {
|
||||||
// date and time provided
|
// 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();
|
return $dateTime->toDateTimeImmutable();
|
||||||
|
|||||||
@@ -193,6 +193,10 @@ class ProductExportBuilder
|
|||||||
* getPickups() resolves both zeit shapes and rolls a bare time past midnight onto the
|
* 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.
|
* 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|string, Pickup> $masterData
|
||||||
* @param array<int, true> $pickupsWithoutMasterData
|
* @param array<int, true> $pickupsWithoutMasterData
|
||||||
*
|
*
|
||||||
@@ -201,8 +205,9 @@ class ProductExportBuilder
|
|||||||
private function buildPickups(Crawler $dateNode, \DateTimeImmutable $dateFrom, array $masterData, array &$pickupsWithoutMasterData): array
|
private function buildPickups(Crawler $dateNode, \DateTimeImmutable $dateFrom, array $masterData, array &$pickupsWithoutMasterData): array
|
||||||
{
|
{
|
||||||
$resolved = [];
|
$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;
|
$pickupId = (int) $pickup->id;
|
||||||
$details = $masterData[$pickupId] ?? null;
|
$details = $masterData[$pickupId] ?? null;
|
||||||
|
|
||||||
@@ -216,7 +221,7 @@ class ProductExportBuilder
|
|||||||
'city' => $details?->city,
|
'city' => $details?->city,
|
||||||
'street' => $details?->street,
|
'street' => $details?->street,
|
||||||
'price' => $pickup->price,
|
'price' => $pickup->price,
|
||||||
'datetime' => $pickup->time?->format('c'),
|
'datetime' => $pickup->time?->format('Y-m-d\TH:i:s'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -144,8 +144,15 @@ class TravelExportBuilder
|
|||||||
// getPickups() is what buys stringToDateTimeFuzzy() for both zeit shapes and
|
// getPickups() is what buys stringToDateTimeFuzzy() for both zeit shapes and
|
||||||
// adjustOvernightTimes(), which rolls an 02:15 listed after a 23:59 onto the next
|
// 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.
|
// 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(
|
$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,
|
$pickupMasterData,
|
||||||
$pickupsWithoutMasterData,
|
$pickupsWithoutMasterData,
|
||||||
);
|
);
|
||||||
@@ -234,8 +241,9 @@ class TravelExportBuilder
|
|||||||
'street' => $details?->street,
|
'street' => $details?->street,
|
||||||
'time' => $pickup->time?->format('H:i'),
|
'time' => $pickup->time?->format('H:i'),
|
||||||
// Emitted for time-only sources too: adjustOvernightTimes() has already
|
// Emitted for time-only sources too: adjustOvernightTimes() has already
|
||||||
// worked out the real day, and a null here would throw that away.
|
// worked out the real day, and a null here would throw that away. A clock
|
||||||
'datetime' => $pickup->time?->format('c'),
|
// reading without an offset, like `time`.
|
||||||
|
'datetime' => $pickup->time?->format('Y-m-d\TH:i:s'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -159,14 +159,56 @@ class ProductExportBuilderTest extends TestCase
|
|||||||
'city' => 'Stadt 4',
|
'city' => 'Stadt 4',
|
||||||
'street' => 'Strasse 4',
|
'street' => 'Strasse 4',
|
||||||
'price' => 5.9,
|
'price' => 5.9,
|
||||||
'datetime' => '2026-12-03T23:30:00+01:00',
|
'datetime' => '2026-12-03T23:30:00',
|
||||||
], $pickups[0]);
|
], $pickups[0]);
|
||||||
self::assertNull($pickups[1]['price'], 'an absent price is null');
|
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::assertSame(0.0, $pickups[2]['price'], 'a stated 0,00 is kept as 0');
|
||||||
self::assertNull($pickups[2]['code']);
|
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
|
public function testDoesNotLevelBoardingPointPrices(): void
|
||||||
{
|
{
|
||||||
// Point 27 is priced on the return leg only. The booking path levels that onto the
|
// 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);
|
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
|
private function build(?string $xml = null, ?string $marker = null): Export
|
||||||
{
|
{
|
||||||
$xml ??= (string) file_get_contents(__DIR__.'/../Resources/product_export_data.xml');
|
$xml ??= (string) file_get_contents(__DIR__.'/../Resources/product_export_data.xml');
|
||||||
|
|||||||
@@ -61,26 +61,57 @@ class TravelExportBuilderTest extends TestCase
|
|||||||
$pickups = array_column($this->rowFor('900-1')['pickups'], null, 'busProId');
|
$pickups = array_column($this->rowFor('900-1')['pickups'], null, 'busProId');
|
||||||
|
|
||||||
self::assertSame('23:59', $pickups[10]['time']);
|
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
|
// 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.
|
// 23:59 above. The coach cannot board backwards, so it rolls onto the next day.
|
||||||
self::assertSame('02:15', $pickups[11]['time']);
|
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'];
|
$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
|
public function testATimeOnlyPickupThatAlreadyFollowsTheLastDatedOneKeepsTheDepartureDate(): void
|
||||||
{
|
{
|
||||||
$pickups = array_column($this->rowFor('901-1')['pickups'], null, 'busProId');
|
$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
|
public function testKeepsAPickupWithoutAZeitAndNullsBothTimeFields(): void
|
||||||
@@ -104,7 +135,7 @@ class TravelExportBuilderTest extends TestCase
|
|||||||
self::assertNull($pickups[99]['city']);
|
self::assertNull($pickups[99]['city']);
|
||||||
self::assertNull($pickups[99]['street']);
|
self::assertNull($pickups[99]['street']);
|
||||||
self::assertSame('21:00', $pickups[99]['time'], 'the time is from the travel XML, not master data');
|
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.
|
// Counted once, not once per row that references it.
|
||||||
self::assertSame(1, $export->pickupsWithoutMasterData);
|
self::assertSame(1, $export->pickupsWithoutMasterData);
|
||||||
|
|||||||
Reference in new issue
Block a user