feat: implement trip handover logic and tests

This commit is contained in:
ramram1515
2026-07-03 15:54:26 +08:00
parent 24665a9594
commit 8e93b889ee
4 changed files with 308 additions and 15 deletions

View File

@@ -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})";

View File

@@ -56,6 +56,12 @@
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6L6 18M6 6l12 12"/></svg>
</button>
</div>
<div id="conflict-banner" class="mb-4 hidden rounded border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
<div id="conflict-message" class="mb-2"></div>
<button type="button" id="override-btn" class="btn-primary py-1 px-3 text-xs" style="background-color: #d97706; border-color: #d97706;">Override & Schedule</button>
</div>
<form id="create-form" class="space-y-4">
<div><label class="form-label">Route</label>
<select name="route_id" id="route-select" required class="form-select"><option value=""> Select </option></select>
@@ -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();

View File

@@ -0,0 +1,154 @@
<?php
namespace Tests\Feature\Api\V1\Trip;
use App\Models\CollectionTeam;
use App\Models\DropOffPoint;
use App\Models\Dumpsite;
use App\Models\QrCode;
use App\Models\Route;
use App\Models\Trip;
use App\Models\User;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class E2EWasteCollectionTest extends TestCase
{
use RefreshDatabase;
public function test_full_waste_collection_lifecycle_end_to_end()
{
// 1. Setup Environment & Test Data
$this->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);
}
}

View File

@@ -0,0 +1,90 @@
<?php
namespace Tests\Feature\Api\V1\Trip;
use App\Models\CollectionTeam;
use App\Models\DropOffPoint;
use App\Models\Household;
use App\Models\Route;
use App\Models\Trip;
use App\Models\User;
use Database\Seeders\RoleSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class TripHandoverTest extends TestCase
{
use RefreshDatabase;
public function test_can_handover_trip_to_new_team()
{
$this->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);
}
}