diff --git a/public/typo3conf/ext/ep_products/Classes/Service/DateImportService.php b/public/typo3conf/ext/ep_products/Classes/Service/DateImportService.php index 1cab011e..418f83ae 100644 --- a/public/typo3conf/ext/ep_products/Classes/Service/DateImportService.php +++ b/public/typo3conf/ext/ep_products/Classes/Service/DateImportService.php @@ -458,7 +458,6 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface $date['services_general'] = serialize($combinedServices); $pickups = []; - $timezone = new \DateTimeZone('Europe/Berlin'); foreach ($row['pickups'] as $pickup) { $busProId = (int) $pickup['busProId']; if ($busProId === 0) { @@ -467,9 +466,7 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface $pickupDate = $dateStart->format('d.m.Y'); $pickupTime = ''; if ($pickup['datetime'] !== null) { - $pickupDateTime = (new \DateTimeImmutable($pickup['datetime']))->setTimezone($timezone); - $pickupDate = $pickupDateTime->format('d.m.Y'); - $pickupTime = $pickupDateTime->format('H:i'); + [$pickupDate, $pickupTime] = $this->parsePickupDateTime($pickup['datetime']); } $pickups[] = [ 'bus_pro_id' => $busProId, @@ -803,6 +800,26 @@ class DateImportService implements SingletonInterface, LoggerAwareInterface } } + /** + * A pickup time is the local date and time as BusPro states it + * ("2027-03-28T02:45:00"), taken over as is. An ISO-8601 time with an + * offset is still accepted and converted to Europe/Berlin, which cannot + * express a time inside the daylight saving gap. + * + * @param string $datetime + * @return string[] date as d.m.Y and time as H:i + */ + protected function parsePickupDateTime(string $datetime): array + { + if (preg_match('/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::\d{2}(?:\.\d+)?)?$/', $datetime, $parts)) { + return [$parts[3] . '.' . $parts[2] . '.' . $parts[1], $parts[4] . ':' . $parts[5]]; + } + + $pickupDateTime = (new \DateTimeImmutable($datetime))->setTimezone(new \DateTimeZone('Europe/Berlin')); + + return [$pickupDateTime->format('d.m.Y'), $pickupDateTime->format('H:i')]; + } + /** * Reads back a price parseService() formatted for display ("1.234,50"). * diff --git a/public/typo3conf/ext/ep_products/Classes/Service/XmlProductReader.php b/public/typo3conf/ext/ep_products/Classes/Service/XmlProductReader.php index 9e50c21c..1832bba1 100644 --- a/public/typo3conf/ext/ep_products/Classes/Service/XmlProductReader.php +++ b/public/typo3conf/ext/ep_products/Classes/Service/XmlProductReader.php @@ -25,6 +25,7 @@ final class XmlProductReader implements \IteratorAggregate private const MARKER_TS_FORMAT = 'd.m.Y H:i:s'; private const CODE_PSEUDOPRICE = 'PDGS'; private const TIMEZONE = 'Europe/Berlin'; + private const LOCAL_DATETIME_FORMAT = 'Y-m-d\TH:i:s'; /** * @var string @@ -41,6 +42,15 @@ final class XmlProductReader implements \IteratorAggregate */ private $timezone; + /** + * Pickup times are clock readings, not instants. They are handled in UTC, + * which has no daylight saving gaps, so a time BusPro plans inside one + * (02:45 on the night clocks go forward) is kept as stated. + * + * @var \DateTimeZone + */ + private $wallClock; + /** * @var array|null pickup master data by BusPro id */ @@ -51,6 +61,7 @@ final class XmlProductReader implements \IteratorAggregate $this->path = rtrim($path, '/'); $this->logger = $logger ?? new NullLogger(); $this->timezone = new \DateTimeZone(self::TIMEZONE); + $this->wallClock = new \DateTimeZone('UTC'); } public function getPath(): string @@ -245,7 +256,8 @@ final class XmlProductReader implements \IteratorAggregate /** * A bare HH:mm takes the travel date's start, and is rolled onto the next * day when it would otherwise fall before the latest full date-time listed - * before it: a coach does not board backwards. + * before it: a coach does not board backwards. The result is the local + * date and time as BusPro states it, without an offset. */ private function readDatePickups(\SimpleXMLElement $xmlDate, \DateTimeImmutable $from): array { @@ -257,12 +269,12 @@ final class XmlProductReader implements \IteratorAggregate $datetime = null; if ($time !== '' && strpos($time, ' ') !== false) { - $datetime = \DateTimeImmutable::createFromFormat('!d.m.Y H:i:s', $time, $this->timezone) ?: null; + $datetime = \DateTimeImmutable::createFromFormat('!d.m.Y H:i:s', $time, $this->wallClock) ?: null; if ($datetime !== null && ($latest === null || $datetime > $latest)) { $latest = $datetime; } } elseif ($time !== '') { - $datetime = \DateTimeImmutable::createFromFormat('!Y-m-d H:i', $from->format('Y-m-d') . ' ' . $time, $this->timezone) ?: null; + $datetime = \DateTimeImmutable::createFromFormat('!Y-m-d H:i', $from->format('Y-m-d') . ' ' . $time, $this->wallClock) ?: null; if ($datetime !== null && $latest !== null && $datetime < $latest) { $datetime = $datetime->modify('+1 day'); } @@ -275,7 +287,7 @@ final class XmlProductReader implements \IteratorAggregate 'city' => $masterData['city'] ?? null, 'street' => $masterData['street'] ?? null, 'price' => $this->parsePrice((string) $xmlPickup['preis']), - 'datetime' => $datetime !== null ? $datetime->format(\DateTimeInterface::ATOM) : null, + 'datetime' => $datetime !== null ? $datetime->format(self::LOCAL_DATETIME_FORMAT) : null, ]; }