feat: implement trip handover logic and tests
This commit is contained in:
@@ -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})";
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
154
tests/Feature/Api/V1/Trip/E2EWasteCollectionTest.php
Normal file
154
tests/Feature/Api/V1/Trip/E2EWasteCollectionTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
90
tests/Feature/Api/V1/Trip/TripHandoverTest.php
Normal file
90
tests/Feature/Api/V1/Trip/TripHandoverTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user