338 lines
13 KiB
PHP
338 lines
13 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Api\V1\Admin;
|
|
|
|
use App\Http\Controllers\Api\V1\ApiController;
|
|
use App\Http\Requests\Trip\StoreTripRequest;
|
|
use App\Http\Resources\TripResource;
|
|
use App\Http\Resources\TripTimelineEventResource;
|
|
use App\Models\Route;
|
|
use App\Models\Trip;
|
|
use App\Models\TripStop;
|
|
use App\Models\CollectionTeam;
|
|
use App\Models\Truck;
|
|
use App\Models\TripTimelineEvent;
|
|
use App\Services\Trip\TripExecutor;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\DB;
|
|
|
|
class AdminTripController extends ApiController
|
|
{
|
|
public function index(Request $request): JsonResponse
|
|
{
|
|
$request->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;
|
|
}
|
|
}
|