diff --git a/app/DataProviders/HafasStopoverService.php b/app/DataProviders/HafasStopoverService.php index 5deca73e7..92233b8bf 100644 --- a/app/DataProviders/HafasStopoverService.php +++ b/app/DataProviders/HafasStopoverService.php @@ -85,6 +85,9 @@ public static function refreshStopovers(stdClass $rawHafas): stdClass { * @throws HafasException */ public function refreshStopover(Stopover $stopover): void { + if($stopover->departure_planned === null) { + return; + } $departure = $this->dataProvider->getDepartures( station: $stopover->station, when: $stopover->departure_planned, diff --git a/app/Dto/Transport/Departure.php b/app/Dto/Transport/Departure.php new file mode 100644 index 000000000..b87d20d01 --- /dev/null +++ b/app/Dto/Transport/Departure.php @@ -0,0 +1,28 @@ +station = $station; + $this->plannedDeparture = $plannedDeparture; + $this->realDeparture = $realDeparture; + $this->trip = $trip; + } + + public function getDelay(): ?int { + if($this->realDeparture === null) { + return null; + } + return $this->plannedDeparture->diffInMinutes($this->realDeparture); + } +} diff --git a/app/Enum/ReiseloesungCategory.php b/app/Enum/ReiseloesungCategory.php new file mode 100644 index 000000000..a94431a40 --- /dev/null +++ b/app/Enum/ReiseloesungCategory.php @@ -0,0 +1,36 @@ +name) { + 'ICE' => HafasTravelType::NATIONAL_EXPRESS, + 'EC_IC' => HafasTravelType::NATIONAL, + 'IR' => HafasTravelType::REGIONAL_EXP, + 'UNKNOWN', 'REGIONAL' => HafasTravelType::REGIONAL, + 'SBAHN' => HafasTravelType::SUBURBAN, + 'BUS' => HafasTravelType::BUS, + 'SCHIFF' => HafasTravelType::FERRY, + 'UBAHN' => HafasTravelType::SUBWAY, + 'TRAM' => HafasTravelType::TRAM, + 'ANRUFPFLICHTIG' => HafasTravelType::TAXI, + default => HafasTravelType::REGIONAL, + }; + } +} diff --git a/app/Enum/TripSource.php b/app/Enum/TripSource.php index 6e705d19f..f852c0410 100644 --- a/app/Enum/TripSource.php +++ b/app/Enum/TripSource.php @@ -10,6 +10,8 @@ enum TripSource: string */ case HAFAS = 'hafas'; + case BAHN_WEB_API = 'bahn-web-api'; + /** * Trips created by the user - with manual data. */ diff --git a/app/Http/Controllers/API/v1/TransportController.php b/app/Http/Controllers/API/v1/TransportController.php index a6b8a9f6b..2795906bb 100644 --- a/app/Http/Controllers/API/v1/TransportController.php +++ b/app/Http/Controllers/API/v1/TransportController.php @@ -11,10 +11,12 @@ use App\Exceptions\CheckInCollisionException; use App\Exceptions\HafasException; use App\Exceptions\StationNotOnTripException; +use App\Http\Controllers\Backend\Transport\BahnWebApiController; use App\Http\Controllers\Backend\Transport\StationController; use App\Http\Controllers\Backend\Transport\TrainCheckinController; use App\Http\Controllers\TransportController as TransportBackend; use App\Http\Resources\CheckinSuccessResource; +use App\Http\Resources\DepartureResource; use App\Http\Resources\StationResource; use App\Http\Resources\TripResource; use App\Hydrators\CheckinRequestHydrator; @@ -176,6 +178,20 @@ public function getDepartures(Request $request, int $stationId): JsonResponse { ] ); } catch (HafasException) { + return $this->sendResponse( + data: DepartureResource::collection(BahnWebApiController::getDepartures($station)), + additional: [ + 'meta' => [ + 'station' => StationDto::fromModel($station), + 'times' => [ + 'now' => $timestamp, + 'prev' => $timestamp->clone()->subMinutes(15), + 'next' => $timestamp->clone()->addMinutes(15) + ], + ] + ] + ); + return $this->sendError(__('messages.exception.generalHafas', [], 'en'), 502); } catch (ModelNotFoundException) { return $this->sendError(__('controller.transport.no-station-found', [], 'en')); diff --git a/app/Http/Controllers/Backend/Transport/BahnWebApiController.php b/app/Http/Controllers/Backend/Transport/BahnWebApiController.php index fb01d68f5..40b1b813b 100644 --- a/app/Http/Controllers/Backend/Transport/BahnWebApiController.php +++ b/app/Http/Controllers/Backend/Transport/BahnWebApiController.php @@ -2,22 +2,26 @@ namespace App\Http\Controllers\Backend\Transport; +use App\Dto\Transport\Departure; +use App\Enum\ReiseloesungCategory; +use App\Enum\TripSource; use App\Http\Controllers\Controller; use App\Models\Station; -use App\Models\User; +use App\Models\Stopover; +use App\Models\Trip; +use Carbon\Carbon; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Http; -abstract class BahnWebApiController extends Controller -{ +abstract class BahnWebApiController extends Controller { public static function searchStation(string $query, int $limit = 10): Collection { $url = "https://www.bahn.de/web/api/reiseloesung/orte?suchbegriff=" . urlencode($query) . "&typ=ALL&limit=" . $limit; $response = Http::get($url); $json = $response->json(); $extIds = []; - foreach ($json as $rawStation) { - if (!isset($rawStation['extId'])) { + foreach($json as $rawStation) { + if(!isset($rawStation['extId'])) { continue; } $extIds[] = $rawStation['extId']; @@ -25,12 +29,12 @@ public static function searchStation(string $query, int $limit = 10): Collection $stationCache = Station::whereIn('ibnr', $extIds)->get(); $stations = collect(); - foreach ($json as $rawStation) { - if (!isset($rawStation['extId'])) { + foreach($json as $rawStation) { + if(!isset($rawStation['extId'])) { continue; } $station = $stationCache->where('ibnr', $rawStation['extId'])->first(); - if ($station === null) { + if($station === null) { $station = Station::create([ 'name' => $rawStation['name'], 'latitude' => $rawStation['lat'], @@ -44,4 +48,124 @@ public static function searchStation(string $query, int $limit = 10): Collection return $stations; } + + public static function getDepartures(Station $station, Carbon|null $timestamp = null): Collection { + $timezone = "Europe/Berlin"; + if($timestamp === null) { + $timestamp = now(); + } + $timestamp->tz($timezone); + $response = Http::get("https://www.bahn.de/web/api/reiseloesung/abfahrten", [ + 'ortExtId' => $station->ibnr, + 'datum' => $timestamp->format('Y-m-d'), + 'zeit' => $timestamp->format('H:i'), + ]); + $departures = collect(); + foreach($response->json('entries') as $rawDeparture) { + $journey = Trip::where('trip_id', $rawDeparture['journeyId'])->first(); + if($journey) { + $departures->push(new Departure( + station: $station, + plannedDeparture: Carbon::parse($rawDeparture['zeit'], $timezone), + realDeparture: isset($rawDeparture['ezZeit']) ? Carbon::parse($rawDeparture['ezZeit'], $timezone) : null, + trip: $journey, + )); + continue; + } + + $rawJourney = self::fetchJourney($rawDeparture['journeyId']); + if($rawJourney === null) { + // sorry + continue; + } + $stopoverCacheFromDB = Station::whereIn('ibnr', collect($rawJourney['halte'])->pluck('extId'))->get(); + + $originStation = $stopoverCacheFromDB->where('ibnr', $rawJourney['halte'][0]['extId'])->first() ?? self::getStationFromHalt($rawJourney['halte'][0]); + $destinationStation = $stopoverCacheFromDB->where('ibnr', $rawJourney['halte'][count($rawJourney['halte']) - 1]['extId'])->first() ?? self::getStationFromHalt($rawJourney['halte'][count($rawJourney['halte']) - 1]); + $departure = isset($rawJourney['halte'][0]['abfahrtsZeitpunkt']) ? Carbon::parse($rawJourney['halte'][0]['abfahrtsZeitpunkt'], $timezone) : null; + $arrival = isset($rawJourney['halte'][count($rawJourney['halte']) - 1]['ankunftsZeitpunkt']) ? Carbon::parse($rawJourney['halte'][count($rawJourney['halte']) - 1]['ankunftsZeitpunkt'], $timezone) : null; + $category = isset($rawDeparture['verkehrmittel']['produktGattung']) ? ReiseloesungCategory::tryFrom($rawDeparture['verkehrmittel']['produktGattung']) : ReiseloesungCategory::UNKNOWN; + $category = $category ?? ReiseloesungCategory::UNKNOWN; + + //trip + $tripLineName = $rawDeparture['verkehrmittel']['name'] ?? ''; + $tripNumber = preg_replace('/\s/', '-', strtolower($tripLineName)) ?? ''; + $tripJourneyNumber = preg_replace('/\D/', '', $rawDeparture['verkehrmittel']['name']); + + $journey = Trip::create([ + 'trip_id' => $rawDeparture['journeyId'], + 'category' => $category->getHTT(), + 'number' => $tripNumber, + 'linename' => $tripLineName, + 'journey_number' => !empty($tripJourneyNumber) ? $tripJourneyNumber : 0, + 'operator_id' => null, //TODO + 'origin_id' => $originStation->id, + 'destination_id' => $destinationStation->id, + 'polyline_id' => null, + 'departure' => $departure, + 'arrival' => $arrival, + 'source' => TripSource::BAHN_WEB_API, + ]); + + + $stopovers = collect(); + foreach($rawJourney['halte'] as $rawHalt) { + $station = $stopoverCacheFromDB->where('ibnr', $rawHalt['extId'])->first() ?? self::getStationFromHalt($rawHalt); + + $departurePlanned = isset($rawHalt['abfahrtsZeitpunkt']) ? Carbon::parse($rawHalt['abfahrtsZeitpunkt'], $timezone) : null; + $departureReal = isset($rawHalt['ezAbfahrtsZeitpunkt']) ? Carbon::parse($rawHalt['ezAbfahrtsZeitpunkt'], $timezone) : null; + $arrivalPlanned = isset($rawHalt['ankunftsZeitpunkt']) ? Carbon::parse($rawHalt['ankunftsZeitpunkt'], $timezone) : null; + $arrivalReal = isset($rawHalt['ezAnkunftsZeitpunkt']) ? Carbon::parse($rawHalt['ezAnkunftsZeitpunkt'], $timezone) : null; + + $stopover = new Stopover([ + 'train_station_id' => $station->id, + 'arrival_planned' => $arrivalPlanned ?? $departurePlanned, + 'arrival_real' => $arrivalReal ?? $departureReal ?? null, + 'departure_planned' => $departurePlanned ?? $arrivalPlanned, + 'departure_real' => $departureReal ?? $arrivalReal ?? null, + ]); + $stopovers->push($stopover); + } + $journey->stopovers()->saveMany($stopovers); + + $departures->push(new Departure( + station: $station, + plannedDeparture: Carbon::parse($rawDeparture['zeit'], $timezone), + realDeparture: isset($rawDeparture['ezZeit']) ? Carbon::parse($rawDeparture['ezZeit'], $timezone) : null, + trip: $journey, + )); + } + return $departures; + } + + private static function getStationFromHalt(array $rawHalt) { + //$station = Station::where('ibnr', $rawHalt['extId'])->first(); + //if($station !== null) { + // return $station; + // } + + //urgh, there is no lat/lon - extract it from id + // example id: A=1@O=Druseltal, Kassel@X=9414484@Y=51301106@U=81@L=714800@ + $matches = []; + preg_match('/@X=(\d+)@Y=(\d+)/', $rawHalt['id'], $matches); + $latitude = $matches[2] / 1000000; + $longitude = $matches[1] / 1000000; + + return Station::updateOrCreate([ + 'ibnr' => $rawHalt['extId'], + ], [ + 'name' => $rawHalt['name'], + 'latitude' => $latitude ?? 0, // Hello Null-Island + 'longitude' => $longitude ?? 0, // Hello Null-Island + 'source' => TripSource::BAHN_WEB_API->value, + ]); + } + + public static function fetchJourney(string $journeyId, bool $poly = false): array|null { + $response = Http::get("https://www.bahn.de/web/api/reiseloesung/fahrt", [ + 'journeyId' => $journeyId, + 'poly' => $poly ? 'true' : 'false', + ]); + return $response->json(); + } } diff --git a/app/Http/Controllers/Backend/Transport/StatusTagController.php b/app/Http/Controllers/Backend/Transport/StatusTagController.php index 19f5f75aa..a2e676e9c 100644 --- a/app/Http/Controllers/Backend/Transport/StatusTagController.php +++ b/app/Http/Controllers/Backend/Transport/StatusTagController.php @@ -11,7 +11,7 @@ abstract class StatusTagController extends Controller { - public static function getVisibleTagsForUser(Status $status, User $user = null): Collection { + public static function getVisibleTagsForUser(Status $status, ?User $user = null): Collection { return $status->tags->filter(function(StatusTag $tag) use ($user) { return Gate::forUser($user)->allows('view', $tag); }); diff --git a/app/Http/Controllers/FrontendStatusController.php b/app/Http/Controllers/FrontendStatusController.php index 1c3a8df29..63212bca3 100644 --- a/app/Http/Controllers/FrontendStatusController.php +++ b/app/Http/Controllers/FrontendStatusController.php @@ -8,6 +8,7 @@ use App\Http\Controllers\Backend\User\ProfilePictureController; use App\Http\Controllers\StatusController as StatusBackend; use App\Models\Event; +use App\Models\Station; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Contracts\Support\Renderable; use Illuminate\Http\RedirectResponse; @@ -20,6 +21,7 @@ class FrontendStatusController extends Controller { public function getDashboard(): Renderable|RedirectResponse { + $statuses = DashboardController::getPrivateDashboard(auth()->user()); return view('dashboard', [ diff --git a/app/Http/Resources/DepartureResource.php b/app/Http/Resources/DepartureResource.php new file mode 100644 index 000000000..f6d61f6f7 --- /dev/null +++ b/app/Http/Resources/DepartureResource.php @@ -0,0 +1,99 @@ + $this->trip->trip_id, + "stop" => [ + "type" => "stop", + "id" => $this->station->ibnr, + "name" => $this->station->name, + "location" => [ + "type" => "location", + "id" => $this->station->ibnr, + "latitude" => $this->station->latitude, + "longitude" => $this->station->longitude + ], + "products" => [ + "nationalExpress" => true, //TODO + "national" => true, //TODO + "regionalExp" => true, //TODO + "regional" => true, //TODO + "suburban" => true, //TODO + "bus" => true, //TODO + "ferry" => true, //TODO + "subway" => true, //TODO + "tram" => true, //TODO + "taxi" => true, //TODO + ] + ], + "when" => $this->realDeparture?->toIso8601String(), + "plannedWhen" => $this->plannedDeparture->toIso8601String(), + "delay" => $this->getDelay(), //TODO: make it deprecated + "platform" => null, + "plannedPlatform" => null, + "direction" => $this->trip->destinationStation->name, + "provenance" => null, + "line" => [ + "type" => "line", + "id" => $this->trip->linename, + "fahrtNr" => $this->trip->number, + "name" => $this->trip->linename, + "public" => true, + "adminCode" => "80____", + "productName" => $this->trip->linename, //TODO + "mode" => "train", //TODO + "product" => $this->trip->category, + "operator" => null,/*[ //TODO + "type" => "operator", + "id" => "db-fernverkehr-ag", + "name" => "DB Fernverkehr AG" + ]*/ + ], + "remarks" => null, + "origin" => null, + "destination" => [ + "type" => "stop", + "id" => $this->trip->destinationStation->ibnr, + "name" => $this->trip->destinationStation->name, + "location" => [ + "type" => "location", + "id" => $this->trip->destinationStation->ibnr, + "latitude" => $this->trip->destinationStation->latitude, + "longitude" => $this->trip->destinationStation->longitude + ], + "products" => [ + "nationalExpress" => true, //TODO + "national" => true, //TODO + "regionalExp" => true, //TODO + "regional" => true, //TODO + "suburban" => true, //TODO + "bus" => true, //TODO + "ferry" => true, //TODO + "subway" => true, //TODO + "tram" => true, //TODO + "taxi" => true, //TODO + ] + ], + "currentTripPosition" => null, //TODO + /*[ + "type" => "location", + "latitude" => 48.725382, + "longitude" => 8.142888 + ],*/ + "loadFactor" => null, + "station" => new StationResource($this->station) + ]; + } +} diff --git a/app/Jobs/RefreshStopover.php b/app/Jobs/RefreshStopover.php index 9d43328bb..c8d18807b 100644 --- a/app/Jobs/RefreshStopover.php +++ b/app/Jobs/RefreshStopover.php @@ -23,10 +23,11 @@ public function __construct(Stopover $stopover) { $this->stopover = $stopover; } - /** - * @throws HafasException - */ public function handle(): void { - (new HafasStopoverService(Hafas::class))->refreshStopover($this->stopover); + try { + (new HafasStopoverService(Hafas::class))->refreshStopover($this->stopover); + } catch (\Exception $exception) { + report($exception); + } } } diff --git a/app/Models/Stopover.php b/app/Models/Stopover.php index 6801b717f..976d69cc5 100644 --- a/app/Models/Stopover.php +++ b/app/Models/Stopover.php @@ -87,11 +87,11 @@ public function trainStation(): BelongsTo { // These two methods are a ticking time bomb and I hope we'll never see it explode. 💣 public function getArrivalAttribute(): ?Carbon { - return ($this->arrival_real ?? $this->arrival_planned) ?? $this->departure; + return ($this->arrival_real ?? $this->arrival_planned) ?? $this?->departure; } public function getDepartureAttribute(): ?Carbon { - return ($this->departure_real ?? $this->departure_planned) ?? $this->arrival; + return ($this->departure_real ?? $this->departure_planned) ?? $this?->arrival; } public function getPlatformAttribute(): ?string {