in_progress, write timeline event. */ public function start(Trip $trip, User $driver, ?float $lat = null, ?float $lng = null): Trip { if ($trip->status !== Trip::STATUS_SCHEDULED) { throw new \DomainException('Trip cannot be started from status '.$trip->status); } return DB::transaction(function () use ($trip, $driver, $lat, $lng) { $trip->forceFill([ 'status' => Trip::STATUS_IN_PROGRESS, 'actual_start_time' => now(), ])->save(); $this->log($trip, TripTimelineEvent::TYPE_TRIP_STARTED, $driver, $lat, $lng); return $trip->fresh(); }); } public function arriveAtStop(TripStop $stop, User $user, float $lat, float $lng): TripStop { if ($stop->status !== TripStop::STATUS_PENDING) { throw new \DomainException('Stop status must be pending to arrive'); } return DB::transaction(function () use ($stop, $user, $lat, $lng) { $stop->forceFill([ 'status' => TripStop::STATUS_ARRIVED, 'actual_arrival' => now(), 'coordinates_at_arrival' => new Point($lat, $lng, 4326), ])->save(); $this->log( $stop->trip, TripTimelineEvent::TYPE_ARRIVED_AT_STOP, $user, $lat, $lng, relatedType: TripStop::class, relatedId: $stop->id, ); return $stop->fresh(); }); } public function departStop(TripStop $stop, User $user, float $lat, float $lng): TripStop { if (! in_array($stop->status, [TripStop::STATUS_ARRIVED, TripStop::STATUS_COMPLETED], true)) { throw new \DomainException('Stop must be arrived/completed before departing'); } return DB::transaction(function () use ($stop, $user, $lat, $lng) { $stop->forceFill([ 'status' => TripStop::STATUS_COMPLETED, 'actual_departure' => now(), ])->save(); $this->log( $stop->trip, TripTimelineEvent::TYPE_DEPARTED_STOP, $user, $lat, $lng, relatedType: TripStop::class, relatedId: $stop->id, ); return $stop->fresh(); }); } public function skipStop(TripStop $stop, User $user, string $reason): TripStop { return DB::transaction(function () use ($stop, $user, $reason) { $stop->forceFill([ 'status' => TripStop::STATUS_SKIPPED, 'skip_reason' => $reason, ])->save(); $this->log( $stop->trip, TripTimelineEvent::TYPE_STOP_SKIPPED, $user, metadata: ['reason' => $reason, 'stop_id' => $stop->id], relatedType: TripStop::class, relatedId: $stop->id, ); return $stop->fresh(); }); } public function reportIncident(Trip $trip, User $user, string $kind, string $notes, ?float $lat = null, ?float $lng = null): TripTimelineEvent { return $this->log( $trip, $kind === 'breakdown' ? TripTimelineEvent::TYPE_BREAKDOWN : TripTimelineEvent::TYPE_INCIDENT_REPORTED, $user, $lat, $lng, metadata: ['kind' => $kind], notes: $notes, ); } public function arriveAtDumpsite(Trip $trip, User $user, float $lat, float $lng): Trip { if (! in_array($trip->status, [Trip::STATUS_IN_PROGRESS], true)) { throw new \DomainException('Trip must be in progress before arriving at dumpsite'); } return DB::transaction(function () use ($trip, $user, $lat, $lng) { $trip->forceFill([ 'status' => Trip::STATUS_AT_DUMPSITE, 'dumpsite_arrival_time' => now(), ])->save(); $this->log($trip, TripTimelineEvent::TYPE_ARRIVED_AT_DUMPSITE, $user, $lat, $lng); return $trip->fresh(); }); } public function releaseLoad(Trip $trip, User $driver, array $data): DumpsiteRelease { if ($trip->status !== Trip::STATUS_AT_DUMPSITE) { throw new \DomainException('Trip must be at dumpsite to release load'); } return DB::transaction(function () use ($trip, $driver, $data) { $release = DumpsiteRelease::create([ 'trip_id' => $trip->id, 'dumpsite_id' => $data['dumpsite_id'] ?? $trip->dumpsite_id, 'released_at' => now(), 'released_by_driver_id' => $driver->id, 'weight_kg' => $data['weight_kg'], 'waste_type_breakdown' => $data['waste_type_breakdown'] ?? null, 'gate_pass_number' => $data['gate_pass_number'] ?? null, 'dumpsite_attendant_name' => $data['dumpsite_attendant_name'] ?? null, 'photo_evidence_path' => $data['photo_evidence_path'] ?? null, 'coordinates_at_release' => isset($data['lat'], $data['lng']) ? new Point((float) $data['lat'], (float) $data['lng'], 4326) : null, 'notes' => $data['notes'] ?? null, ]); $trip->forceFill([ 'total_load_kg' => (int) ($trip->total_load_kg ?? 0) + (int) $data['weight_kg'], ])->save(); $this->log( $trip, TripTimelineEvent::TYPE_LOAD_RELEASED, $driver, $data['lat'] ?? null, $data['lng'] ?? null, relatedType: DumpsiteRelease::class, relatedId: $release->id, metadata: ['weight_kg' => (int) $data['weight_kg']], ); return $release; }); } public function complete(Trip $trip, User $user, ?float $lat = null, ?float $lng = null): Trip { if (! in_array($trip->status, [Trip::STATUS_AT_DUMPSITE, Trip::STATUS_IN_PROGRESS], true)) { throw new \DomainException('Trip cannot be completed from status '.$trip->status); } return DB::transaction(function () use ($trip, $user, $lat, $lng) { $trip->forceFill([ 'status' => Trip::STATUS_COMPLETED, 'actual_end_time' => now(), 'dumpsite_departure_time' => $trip->dumpsite_departure_time ?? now(), ])->save(); $this->log($trip, TripTimelineEvent::TYPE_TRIP_COMPLETED, $user, $lat, $lng); return $trip->fresh(); }); } public function log( Trip $trip, string $eventType, ?User $user, ?float $lat = null, ?float $lng = null, ?string $relatedType = null, ?int $relatedId = null, ?array $metadata = null, ?string $notes = null, ): TripTimelineEvent { return TripTimelineEvent::create([ 'trip_id' => $trip->id, 'event_type' => $eventType, 'event_at' => now(), 'coordinates' => $lat !== null && $lng !== null ? new Point($lat, $lng, 4326) : null, 'recorded_by_user_id' => $user?->id, 'related_type' => $relatedType, 'related_id' => $relatedId, 'metadata' => $metadata, 'notes' => $notes, 'created_at' => Carbon::now(), ]); } }