validate([ 'status' => ['nullable', 'in:scheduled,in_progress,at_dumpsite,completed,cancelled'], 'date' => ['nullable', 'date'], 'team_id' => ['nullable', 'integer'], 'per_page' => ['nullable', 'integer', 'min:1', 'max:500'], ]); $perPage = (int) $request->input('per_page', 25); $trips = Trip::query() ->with(['route', 'team', 'truck', 'dumpsite', 'tenant']) ->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status'))) ->when($request->filled('date'), fn ($q) => $q->whereDate('scheduled_date', $request->string('date'))) ->when($request->filled('team_id'), fn ($q) => $q->where('team_id', $request->integer('team_id'))) ->orderByDesc('scheduled_date') ->orderBy('scheduled_start_time') ->paginate($perPage); return $this->ok( TripResource::collection($trips), null, [ 'page' => $trips->currentPage(), 'per_page' => $trips->perPage(), 'total' => $trips->total(), 'last_page' => $trips->lastPage(), ], ); } public function store(StoreTripRequest $request): JsonResponse { $data = $request->validated(); $override = (bool) ($data['override_conflicts'] ?? false); unset($data['override_conflicts']); $route = Route::with('stops.dropOffPoint')->findOrFail($data['route_id']); $data['dumpsite_id'] = $data['dumpsite_id'] ?? $route->default_dumpsite_id; $data['truck_id'] = $data['truck_id'] ?? null; $data['created_by_admin_id'] = $request->user()->id; if (! $override) { $conflicts = $this->detectScheduleConflicts( (int) $data['team_id'], $data['scheduled_date'], $data['truck_id'] ?? null, ); if (! empty($conflicts)) { return $this->fail( 'Trip has scheduling conflicts', ['conflicts' => $conflicts], 422, ); } } $trip = DB::transaction(function () use ($data, $route) { $trip = Trip::create($data); foreach ($route->stops as $rs) { TripStop::create([ 'trip_id' => $trip->id, 'drop_off_point_id' => $rs->drop_off_point_id, 'sequence' => $rs->sequence, 'status' => TripStop::STATUS_PENDING, ]); } return $trip; }); return $this->created( new TripResource( $trip->fresh()->load(['route', 'team', 'truck', 'dumpsite', 'stops.dropOffPoint']), ), 'Trip scheduled', ); } public function show(Trip $trip): JsonResponse { $trip->load([ 'route', 'team.driver', 'team.scanner', 'truck', 'dumpsite', 'stops.dropOffPoint', 'timelineEvents.recordedBy', 'dumpsiteReleases.dumpsite', 'dumpsiteReleases.releasedByDriver', 'parentTrip', 'continuationTrips.team', ]); return $this->ok(new TripResource($trip)); } public function cancel(Request $request, Trip $trip): JsonResponse { if (in_array($trip->status, [Trip::STATUS_COMPLETED, Trip::STATUS_CANCELLED, Trip::STATUS_HANDED_OFF], true)) { return $this->fail('Trip is already finished', null, 422); } $trip->forceFill(['status' => Trip::STATUS_CANCELLED])->save(); return $this->ok(new TripResource($trip->fresh()->load(['route', 'team'])), 'Trip cancelled'); } public function createContinuation(Request $request, Trip $trip): JsonResponse { $data = $request->validate([ 'new_team_id' => ['required', 'integer', 'exists:collection_teams,id'], 'new_truck_id' => ['nullable', 'integer', 'exists:trucks,id'], 'end_reason' => ['required', 'string', 'in:truck_full,breakdown,incident'], ]); if (! in_array($trip->status, [Trip::STATUS_IN_PROGRESS, Trip::STATUS_AT_DUMPSITE], true)) { return $this->fail('Trip must be in_progress or at_dumpsite to hand off', null, 422); } $executor = app(TripExecutor::class); try { $childTrip = $executor->createContinuation( originalTrip: $trip, admin: $request->user(), newTeamId: (int) $data['new_team_id'], newTruckId: isset($data['new_truck_id']) ? (int) $data['new_truck_id'] : null, endReason: $data['end_reason'], ); return $this->created(new TripResource($childTrip), 'Continuation trip created'); } catch (\Exception $e) { return $this->fail('Hand off failed: ' . $e->getMessage(), null, 422); } } public function update(StoreTripRequest $request, Trip $trip): JsonResponse { if ($trip->status !== Trip::STATUS_SCHEDULED) { return $this->fail('Only scheduled trips can be edited', null, 422); } $data = $request->validated(); $override = (bool) ($data['override_conflicts'] ?? false); unset($data['override_conflicts']); if (! $override) { $conflicts = []; $teamConflict = Trip::query() ->where('team_id', $data['team_id']) ->whereDate('scheduled_date', $data['scheduled_date']) ->where('id', '!=', $trip->id) ->whereNotIn('status', [Trip::STATUS_CANCELLED, Trip::STATUS_COMPLETED, Trip::STATUS_HANDED_OFF]) ->first(); if ($teamConflict) { $conflicts[] = "Team is already scheduled on {$data['scheduled_date']} (trip {$teamConflict->trip_number})"; } $completedTrip = Trip::query() ->where('team_id', $data['team_id']) ->whereDate('scheduled_date', $data['scheduled_date']) ->where('id', '!=', $trip->id) ->where('status', Trip::STATUS_COMPLETED) ->first(); if ($completedTrip) { $conflicts[] = "Team is already done with their route for today (trip {$completedTrip->trip_number})"; } if (! empty($conflicts)) { return $this->fail('Trip has scheduling conflicts', ['conflicts' => $conflicts], 422); } } DB::transaction(function () use ($trip, $data) { if ((int) $trip->route_id !== (int) $data['route_id']) { $trip->stops()->delete(); $route = Route::with('stops')->findOrFail($data['route_id']); $data['dumpsite_id'] = $route->default_dumpsite_id; foreach ($route->stops as $rs) { TripStop::create([ 'trip_id' => $trip->id, 'drop_off_point_id' => $rs->drop_off_point_id, 'sequence' => $rs->sequence, 'status' => TripStop::STATUS_PENDING, ]); } } $trip->update($data); }); return $this->ok(new TripResource($trip->fresh()->load(['route', 'team', 'truck', 'dumpsite', 'stops.dropOffPoint'])), 'Trip updated'); } public function destroy(Trip $trip): JsonResponse { if ($trip->status !== Trip::STATUS_SCHEDULED) { return $this->fail('Only scheduled trips can be deleted', null, 422); } $trip->delete(); return $this->ok(null, 'Trip deleted'); } public function suggestAssignment(Request $request): JsonResponse { $request->validate([ 'scheduled_date' => ['required', 'date'], ]); $date = $request->string('scheduled_date'); // Find first active team that doesn't have conflict $teams = CollectionTeam::where('status', 'active')->get(); $suggestedTeam = null; foreach ($teams as $team) { $hasConflict = Trip::where('team_id', $team->id) ->whereDate('scheduled_date', $date) ->whereNotIn('status', [Trip::STATUS_CANCELLED, Trip::STATUS_HANDED_OFF]) ->exists(); if (! $hasConflict) { $suggestedTeam = $team; break; } } // Find first active truck that doesn't have conflict $trucks = Truck::where('status', 'active')->get(); $suggestedTruck = null; foreach ($trucks as $truck) { $hasConflict = Trip::where('truck_id', $truck->id) ->whereDate('scheduled_date', $date) ->whereNotIn('status', [Trip::STATUS_CANCELLED, Trip::STATUS_HANDED_OFF]) ->exists(); if (! $hasConflict) { $suggestedTruck = $truck; break; } } return $this->ok([ 'team_id' => $suggestedTeam?->id, 'team_name' => $suggestedTeam?->name, 'truck_id' => $suggestedTruck?->id, 'truck_name' => $suggestedTruck?->plate_number, ]); } public function incidents(Request $request): JsonResponse { $request->validate([ 'event_type' => ['nullable', 'string', 'in:incident_reported,breakdown'], 'date' => ['nullable', 'date'], 'per_page' => ['nullable', 'integer', 'min:1', 'max:500'], ]); $perPage = (int) $request->input('per_page', 25); $events = TripTimelineEvent::query() ->with(['trip.route', 'trip.team.driver', 'trip.truck', 'recordedBy']) ->whereIn('event_type', [ TripTimelineEvent::TYPE_INCIDENT_REPORTED, TripTimelineEvent::TYPE_BREAKDOWN, ]) ->when($request->filled('event_type'), fn ($q) => $q->where('event_type', $request->string('event_type'))) ->when($request->filled('date'), fn ($q) => $q->whereDate('event_at', $request->string('date'))) ->orderByDesc('event_at') ->paginate($perPage); return $this->ok( TripTimelineEventResource::collection($events), null, [ 'page' => $events->currentPage(), 'per_page' => $events->perPage(), 'total' => $events->total(), 'last_page' => $events->lastPage(), ] ); } /** * Returns human-readable conflict messages for the proposed trip. * Cancelled trips don't conflict; everything else on the same date * for the same team or truck does. */ private function detectScheduleConflicts(int $teamId, string $scheduledDate, ?int $truckId): array { $conflicts = []; $teamConflict = Trip::query() ->where('team_id', $teamId) ->whereDate('scheduled_date', $scheduledDate) ->whereNotIn('status', [Trip::STATUS_CANCELLED, Trip::STATUS_COMPLETED, Trip::STATUS_HANDED_OFF]) ->first(); if ($teamConflict) { $conflicts[] = "Team is already scheduled on {$scheduledDate} (trip {$teamConflict->trip_number})"; } $completedTrip = Trip::query() ->where('team_id', $teamId) ->whereDate('scheduled_date', $scheduledDate) ->where('status', Trip::STATUS_COMPLETED) ->first(); if ($completedTrip) { $conflicts[] = "Team is already done with their route for today (trip {$completedTrip->trip_number})"; } if ($truckId) { $truckConflict = Trip::query() ->where('truck_id', $truckId) ->whereDate('scheduled_date', $scheduledDate) ->whereNotIn('status', [Trip::STATUS_CANCELLED, Trip::STATUS_COMPLETED, Trip::STATUS_HANDED_OFF]) ->first(); if ($truckConflict) { $conflicts[] = "Truck is already on a trip on {$scheduledDate} ({$truckConflict->trip_number})"; } } return $conflicts; } }