From 8e93b889eea1f2e6a9f2e2527f429b6052b7831d Mon Sep 17 00:00:00 2001 From: ramram1515 Date: Fri, 3 Jul 2026 15:54:26 +0800 Subject: [PATCH] feat: implement trip handover logic and tests --- .../Api/V1/Admin/AdminTripController.php | 47 ++++-- resources/views/admin/trips.blade.php | 32 +++- .../Api/V1/Trip/E2EWasteCollectionTest.php | 154 ++++++++++++++++++ .../Feature/Api/V1/Trip/TripHandoverTest.php | 90 ++++++++++ 4 files changed, 308 insertions(+), 15 deletions(-) create mode 100644 tests/Feature/Api/V1/Trip/E2EWasteCollectionTest.php create mode 100644 tests/Feature/Api/V1/Trip/TripHandoverTest.php diff --git a/app/Http/Controllers/Api/V1/Admin/AdminTripController.php b/app/Http/Controllers/Api/V1/Admin/AdminTripController.php index 9153ba0..f5db88e 100644 --- a/app/Http/Controllers/Api/V1/Admin/AdminTripController.php +++ b/app/Http/Controllers/Api/V1/Admin/AdminTripController.php @@ -132,15 +132,19 @@ class AdminTripController extends ApiController $executor = app(TripExecutor::class); - $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'); + 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 @@ -159,11 +163,21 @@ class AdminTripController extends ApiController ->where('team_id', $data['team_id']) ->whereDate('scheduled_date', $data['scheduled_date']) ->where('id', '!=', $trip->id) - ->whereNotIn('status', [Trip::STATUS_CANCELLED]) + ->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); } @@ -212,17 +226,26 @@ class AdminTripController extends ApiController $teamConflict = Trip::query() ->where('team_id', $teamId) ->whereDate('scheduled_date', $scheduledDate) - ->whereNotIn('status', [Trip::STATUS_CANCELLED]) + ->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]) + ->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})"; diff --git a/resources/views/admin/trips.blade.php b/resources/views/admin/trips.blade.php index 5794f06..287fb20 100644 --- a/resources/views/admin/trips.blade.php +++ b/resources/views/admin/trips.blade.php @@ -56,6 +56,12 @@ + + +
@@ -384,6 +390,7 @@ document.getElementById('new-btn').addEventListener('click', () => { editingTripId = null; document.getElementById('create-form').reset(); + document.getElementById('conflict-banner').classList.add('hidden'); previewRoute(''); document.querySelector('#form-modal h3').textContent = 'Schedule trip'; modal.classList.remove('hidden'); @@ -488,13 +495,14 @@ if (res.ok) { window.Verde.toast(editingTripId ? 'Trip updated' : 'Trip scheduled', 'success'); modal.classList.add('hidden'); + document.getElementById('conflict-banner').classList.add('hidden'); e.target.reset(); load(); } else { if (res.status === 422 && res.body?.data?.conflicts) { - if (confirm(res.body.message + ":\n\n" + res.body.data.conflicts.join("\n") + "\n\nDo you want to proceed and override?")) { - submitTrip(true); - } + const banner = document.getElementById('conflict-banner'); + document.getElementById('conflict-message').innerText = res.body.data.conflicts.join("\n"); + banner.classList.remove('hidden'); } else if (res.status === 422 && res.body?.errors) { const msgs = Object.values(res.body.errors).flat().join('\n'); window.Verde.toast(msgs, 'error'); @@ -507,6 +515,24 @@ submitTrip(); }); + document.getElementById('override-btn').addEventListener('click', () => { + document.getElementById('conflict-banner').classList.add('hidden'); + document.getElementById('create-form').dispatchEvent(new Event('submit', { cancelable: true, bubbles: true })); + // The event listener doesn't pass 'override', so we need a cleaner way. + // Let's set a hidden input. + let input = document.getElementById('override_conflicts_input'); + if (!input) { + input = document.createElement('input'); + input.type = 'hidden'; + input.id = 'override_conflicts_input'; + input.name = 'override_conflicts'; + document.getElementById('create-form').appendChild(input); + } + input.value = 'true'; + document.getElementById('create-form').querySelector('button[type="submit"]').click(); + setTimeout(() => { if(input) input.remove(); }, 100); + }); + Promise.all([loadRoutes(), loadTeams()]); load(); diff --git a/tests/Feature/Api/V1/Trip/E2EWasteCollectionTest.php b/tests/Feature/Api/V1/Trip/E2EWasteCollectionTest.php new file mode 100644 index 0000000..695b462 --- /dev/null +++ b/tests/Feature/Api/V1/Trip/E2EWasteCollectionTest.php @@ -0,0 +1,154 @@ +seed([ + RoleSeeder::class, + \Database\Seeders\SamplePsgcSeeder::class, + \Database\Seeders\SampleDropOffPointsSeeder::class, + \Database\Seeders\SampleDumpsitesSeeder::class, + ]); + + $admin = User::factory()->create(['role' => User::ROLE_ADMIN, 'status' => 'active']); + $driver = User::factory()->create(['role' => User::ROLE_DRIVER, 'status' => 'active']); + $scanner = User::factory()->create(['role' => User::ROLE_SCANNER, 'status' => 'active']); + + $dumpsite = Dumpsite::first(); + $dropOffPoint = DropOffPoint::first(); + + $route = Route::create([ + 'name' => 'E2E Test Route', + 'code' => 'RT-E2E', + 'default_dumpsite_id' => $dumpsite->id, + 'status' => 'active', + ]); + + $route->stops()->create([ + 'drop_off_point_id' => $dropOffPoint->id, + 'sequence' => 1, + ]); + + $team = CollectionTeam::create([ + 'name' => 'E2E Team', + 'driver_id' => $driver->id, + 'scanner_id' => $scanner->id, + 'status' => 'active', + ]); + + // Set up a Resident QR Code + $resident = User::factory()->create(['role' => User::ROLE_RESIDENT, 'status' => 'active']); + $batch = \App\Models\QrCodeBatch::create([ + 'batch_number' => 'BATCH-E2E-1', + 'status' => 'active', + 'quantity' => 10, + ]); + $qr = QrCode::create([ + 'serial' => 'E2E-TEST-QR-001', + 'assigned_to_user_id' => $resident->id, + 'batch_id' => $batch->id, + 'status' => 'active', + ]); + + // ========================================== + // 2. Admin Schedules a Trip + // ========================================== + $response = $this->actingAs($admin)->postJson('/api/v1/admin/trips', [ + 'route_id' => $route->id, + 'team_id' => $team->id, + 'scheduled_date' => now()->toDateString(), + ]); + + $response->assertStatus(201); + $tripUuid = $response->json('data.id'); + $this->assertNotNull($tripUuid); + + $trip = Trip::where('uuid', $tripUuid)->first(); + $this->assertEquals(Trip::STATUS_SCHEDULED, $trip->status); + + // ========================================== + // 3. Driver Starts the Trip + // ========================================== + $response = $this->actingAs($driver)->postJson("/api/v1/driver/trips/{$tripUuid}/start"); + $response->assertStatus(200); + + $trip->refresh(); + $this->assertEquals(Trip::STATUS_IN_PROGRESS, $trip->status); + + // Driver arrives at the first stop + $stop = $trip->stops()->first(); + $response = $this->actingAs($driver)->postJson("/api/v1/driver/trips/{$tripUuid}/stops/{$stop->id}/arrive", [ + 'lat' => 14.0, + 'lng' => 121.0, + ]); + $response->assertStatus(200); + + // ========================================== + // 4. Scanner Scans a Household QR Code + // ========================================== + $response = $this->actingAs($scanner)->postJson('/api/v1/scanner/scan', [ + 'serial' => $qr->serial, + 'drop_off_point_id' => $dropOffPoint->id, + 'lat' => 14.0, + 'lng' => 121.0, + 'trip_id' => $tripUuid, + 'trip_stop_id' => $stop->id, + 'weight_kg' => 5, + ]); + $response->assertStatus(200); + + // Driver departs from the stop + $response = $this->actingAs($driver)->postJson("/api/v1/driver/trips/{$tripUuid}/stops/{$stop->id}/depart", [ + 'lat' => 14.0, + 'lng' => 121.0, + ]); + $response->assertStatus(200); + + // ========================================== + // 5. Driver Arrives at Dumpsite and Completes + // ========================================== + $response = $this->actingAs($driver)->postJson("/api/v1/driver/trips/{$tripUuid}/arrive-dumpsite", [ + 'lat' => 14.1, + 'lng' => 121.1, + 'dumpsite_id' => $dumpsite->id, + 'override_geofence' => true, + ]); + $response->assertStatus(200); + + $response = $this->actingAs($driver)->postJson("/api/v1/driver/trips/{$tripUuid}/release-load", [ + 'dumpsite_id' => $dumpsite->id, + 'weight_kg' => 1000, + 'override_geofence' => true, + ]); + $response->assertStatus(201); + + $response = $this->actingAs($driver)->postJson("/api/v1/driver/trips/{$tripUuid}/complete", [ + 'lat' => 14.1, + 'lng' => 121.1, + 'override_geofence' => true, + ]); + $response->assertStatus(200); + + // Verify Final State + $trip->refresh(); + $this->assertEquals(Trip::STATUS_COMPLETED, $trip->status); + $this->assertEquals(1000, $trip->total_load_kg); + } +} diff --git a/tests/Feature/Api/V1/Trip/TripHandoverTest.php b/tests/Feature/Api/V1/Trip/TripHandoverTest.php new file mode 100644 index 0000000..8fa32bb --- /dev/null +++ b/tests/Feature/Api/V1/Trip/TripHandoverTest.php @@ -0,0 +1,90 @@ +seed([ + RoleSeeder::class, + \Database\Seeders\SamplePsgcSeeder::class, + \Database\Seeders\SampleDropOffPointsSeeder::class, + \Database\Seeders\SampleDumpsitesSeeder::class, + ]); + $tenantId = 1; + + $admin = User::factory()->create(['role' => 'admin', 'tenant_id' => $tenantId]); + + $dop1 = DropOffPoint::first(); + $dop2 = DropOffPoint::skip(1)->first(); + $dumpsite = \App\Models\Dumpsite::first(); + + $route = Route::create([ + 'tenant_id' => $tenantId, + 'name' => 'Test Route', + 'code' => 'TEST-01', + 'default_dumpsite_id' => $dumpsite->id, + ]); + + $teamA = CollectionTeam::create(['tenant_id' => $tenantId, 'name' => 'Team A']); + $teamB = CollectionTeam::create(['tenant_id' => $tenantId, 'name' => 'Team B']); + + // 1. Create a trip with 2 stops + $trip = Trip::create([ + 'tenant_id' => $tenantId, + 'trip_number' => 'TRIP-123', + 'route_id' => $route->id, + 'team_id' => $teamA->id, + 'status' => Trip::STATUS_IN_PROGRESS, + 'scheduled_date' => now()->toDateString(), + ]); + + // Stop 1: completed + $trip->stops()->create([ + 'drop_off_point_id' => $dop1->id, + 'sequence' => 1, + 'status' => 'completed', + ]); + + // Stop 2: pending + $trip->stops()->create([ + 'drop_off_point_id' => $dop2->id, + 'sequence' => 2, + 'status' => 'pending', + ]); + + // 2. Perform Handover API call + $response = $this->actingAs($admin)->postJson(route('api.v1.admin.trips.continue', $trip), [ + 'new_team_id' => $teamB->id, + 'end_reason' => 'breakdown', + ]); + + $response->assertCreated(); + + // 3. Assertions + $this->assertEquals(Trip::STATUS_HANDED_OFF, $trip->fresh()->status); + $this->assertEquals('breakdown', $trip->fresh()->end_reason); + + $childTrip = Trip::where('parent_trip_id', $trip->id)->first(); + $this->assertNotNull($childTrip); + $this->assertEquals($teamB->id, $childTrip->team_id); + $this->assertEquals(Trip::STATUS_SCHEDULED, $childTrip->status); + + // Assert the child trip only has 1 stop (the pending one) + $this->assertEquals(1, $childTrip->stops()->count()); + $this->assertEquals($dop2->id, $childTrip->stops()->first()->drop_off_point_id); + } +}