Notifications: notification_preferences + Laravel notifications inbox.
SmsChannel adapter for our SmsService. RoutesByPreferences trait reads
per-user toggles. HouseholdApproved/Rejected, QrBalanceLow, and
CodesPurchased notifications wired in via auto-discovered listeners
or direct dispatch from controllers/StoreOperations.
Payments: payments table + PaymentDriver interface. ManualPaymentDriver
works out of the box; PayMongoDriver activates when
PAYMONGO_SECRET_KEY is set, falls back to manual otherwise. Resident
initiates code-purchase, admin can mark paid manually, webhook applies
real provider events. Fulfillment runs StoreOperations::sellToHousehold.
Live tracking (HTTP polling): truck_location_history (with SPATIAL
INDEX + 7-day retention plan). Driver POST /driver/trucks/{uuid}/location
writes history, updates trucks.last_known_coordinates, caches in Redis,
flags geofence-trigger when entering active trip dumpsite. Admin
GET /admin/live/trucks returns active truck positions. Reverb broadcast
deferred.
Flow corrections:
- QrAllocator now idempotent — re-approving a household no longer
re-dispenses free codes.
- arrive-dumpsite enforces dumpsite geofence via ST_Contains; can be
bypassed with override_geofence: true.
171 feature tests passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
209 lines
7.6 KiB
PHP
209 lines
7.6 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Api\V1\Driver;
|
|
|
|
use App\Http\Controllers\Api\V1\ApiController;
|
|
use App\Http\Resources\TripResource;
|
|
use App\Models\Trip;
|
|
use App\Models\TripStop;
|
|
use App\Services\Trip\TripExecutor;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Http\Request;
|
|
|
|
class DriverTripController extends ApiController
|
|
{
|
|
public function __construct(private readonly TripExecutor $executor) {}
|
|
|
|
public function index(Request $request): JsonResponse
|
|
{
|
|
$user = $request->user();
|
|
$trips = Trip::query()
|
|
->with(['route', 'team', 'dumpsite'])
|
|
->whereIn('status', [Trip::STATUS_SCHEDULED, Trip::STATUS_IN_PROGRESS, Trip::STATUS_AT_DUMPSITE])
|
|
->whereHas('team', fn ($q) => $q->where('driver_id', $user->id))
|
|
->orderBy('scheduled_date')
|
|
->get();
|
|
|
|
return $this->ok(TripResource::collection($trips));
|
|
}
|
|
|
|
public function show(Request $request, Trip $trip): JsonResponse
|
|
{
|
|
$this->authorizeDriver($request, $trip);
|
|
$trip->load(['route', 'team', 'dumpsite', 'stops.dropOffPoint', 'timelineEvents.recordedBy']);
|
|
|
|
return $this->ok(new TripResource($trip));
|
|
}
|
|
|
|
public function start(Request $request, Trip $trip): JsonResponse
|
|
{
|
|
$this->authorizeDriver($request, $trip);
|
|
$data = $request->validate([
|
|
'lat' => ['nullable', 'numeric', 'between:-90,90'],
|
|
'lng' => ['nullable', 'numeric', 'between:-180,180'],
|
|
]);
|
|
|
|
try {
|
|
$trip = $this->executor->start($trip, $request->user(), $data['lat'] ?? null, $data['lng'] ?? null);
|
|
} catch (\DomainException $e) {
|
|
return $this->fail($e->getMessage(), null, 422);
|
|
}
|
|
|
|
return $this->ok(new TripResource($trip->load(['route', 'team', 'stops.dropOffPoint'])), 'Trip started');
|
|
}
|
|
|
|
public function arrive(Request $request, Trip $trip, TripStop $stop): JsonResponse
|
|
{
|
|
$this->authorizeDriver($request, $trip);
|
|
$this->ensureStopBelongs($stop, $trip);
|
|
$data = $request->validate([
|
|
'lat' => ['required', 'numeric', 'between:-90,90'],
|
|
'lng' => ['required', 'numeric', 'between:-180,180'],
|
|
]);
|
|
|
|
try {
|
|
$stop = $this->executor->arriveAtStop($stop, $request->user(), (float) $data['lat'], (float) $data['lng']);
|
|
} catch (\DomainException $e) {
|
|
return $this->fail($e->getMessage(), null, 422);
|
|
}
|
|
|
|
return $this->ok(['stop' => $stop], 'Arrived at stop');
|
|
}
|
|
|
|
public function depart(Request $request, Trip $trip, TripStop $stop): JsonResponse
|
|
{
|
|
$this->authorizeDriver($request, $trip);
|
|
$this->ensureStopBelongs($stop, $trip);
|
|
$data = $request->validate([
|
|
'lat' => ['required', 'numeric'],
|
|
'lng' => ['required', 'numeric'],
|
|
]);
|
|
|
|
try {
|
|
$stop = $this->executor->departStop($stop, $request->user(), (float) $data['lat'], (float) $data['lng']);
|
|
} catch (\DomainException $e) {
|
|
return $this->fail($e->getMessage(), null, 422);
|
|
}
|
|
|
|
return $this->ok(['stop' => $stop], 'Departed stop');
|
|
}
|
|
|
|
public function skip(Request $request, Trip $trip, TripStop $stop): JsonResponse
|
|
{
|
|
$this->authorizeDriver($request, $trip);
|
|
$this->ensureStopBelongs($stop, $trip);
|
|
$data = $request->validate([
|
|
'reason' => ['required', 'string', 'min:3', 'max:255'],
|
|
]);
|
|
|
|
$stop = $this->executor->skipStop($stop, $request->user(), $data['reason']);
|
|
|
|
return $this->ok(['stop' => $stop], 'Stop skipped');
|
|
}
|
|
|
|
public function reportIncident(Request $request, Trip $trip): JsonResponse
|
|
{
|
|
$this->authorizeDriver($request, $trip);
|
|
$data = $request->validate([
|
|
'kind' => ['required', 'in:incident,breakdown'],
|
|
'notes' => ['required', 'string', 'min:3', 'max:1000'],
|
|
'lat' => ['nullable', 'numeric'],
|
|
'lng' => ['nullable', 'numeric'],
|
|
]);
|
|
|
|
$event = $this->executor->reportIncident(
|
|
$trip, $request->user(), $data['kind'], $data['notes'],
|
|
$data['lat'] ?? null, $data['lng'] ?? null,
|
|
);
|
|
|
|
return $this->created(['event' => $event], 'Incident logged');
|
|
}
|
|
|
|
public function arriveDumpsite(Request $request, Trip $trip): JsonResponse
|
|
{
|
|
$this->authorizeDriver($request, $trip);
|
|
$data = $request->validate([
|
|
'lat' => ['required', 'numeric', 'between:-90,90'],
|
|
'lng' => ['required', 'numeric', 'between:-180,180'],
|
|
'override_geofence' => ['nullable', 'boolean'],
|
|
]);
|
|
|
|
// If the trip's dumpsite has a boundary configured, enforce that
|
|
// the driver's GPS is inside it — unless they explicitly override
|
|
// (e.g., GPS is reading wrong; admin can audit later).
|
|
$trip->loadMissing('dumpsite');
|
|
$dumpsite = $trip->dumpsite;
|
|
if ($dumpsite && $dumpsite->boundary_polygon && empty($data['override_geofence'])) {
|
|
$inside = $dumpsite->containsPoint((float) $data['lat'], (float) $data['lng']);
|
|
if (! $inside) {
|
|
return $this->fail(
|
|
"GPS is outside the {$dumpsite->name} geofence. Pass override_geofence: true if the reading is wrong.",
|
|
['geofence' => ['outside']],
|
|
422,
|
|
);
|
|
}
|
|
}
|
|
|
|
try {
|
|
$trip = $this->executor->arriveAtDumpsite($trip, $request->user(), (float) $data['lat'], (float) $data['lng']);
|
|
} catch (\DomainException $e) {
|
|
return $this->fail($e->getMessage(), null, 422);
|
|
}
|
|
|
|
return $this->ok(new TripResource($trip), 'Arrived at dumpsite');
|
|
}
|
|
|
|
public function releaseLoad(Request $request, Trip $trip): JsonResponse
|
|
{
|
|
$this->authorizeDriver($request, $trip);
|
|
$data = $request->validate([
|
|
'weight_kg' => ['required', 'integer', 'min:0'],
|
|
'gate_pass_number' => ['nullable', 'string', 'max:100'],
|
|
'dumpsite_attendant_name' => ['nullable', 'string', 'max:191'],
|
|
'photo_evidence_path' => ['nullable', 'string', 'max:255'],
|
|
'waste_type_breakdown' => ['nullable', 'array'],
|
|
'lat' => ['nullable', 'numeric'],
|
|
'lng' => ['nullable', 'numeric'],
|
|
'notes' => ['nullable', 'string', 'max:1000'],
|
|
]);
|
|
|
|
try {
|
|
$release = $this->executor->releaseLoad($trip, $request->user(), $data);
|
|
} catch (\DomainException $e) {
|
|
return $this->fail($e->getMessage(), null, 422);
|
|
}
|
|
|
|
return $this->created(['release' => $release], 'Load released');
|
|
}
|
|
|
|
public function complete(Request $request, Trip $trip): JsonResponse
|
|
{
|
|
$this->authorizeDriver($request, $trip);
|
|
$data = $request->validate([
|
|
'lat' => ['nullable', 'numeric'],
|
|
'lng' => ['nullable', 'numeric'],
|
|
]);
|
|
|
|
try {
|
|
$trip = $this->executor->complete($trip, $request->user(), $data['lat'] ?? null, $data['lng'] ?? null);
|
|
} catch (\DomainException $e) {
|
|
return $this->fail($e->getMessage(), null, 422);
|
|
}
|
|
|
|
return $this->ok(new TripResource($trip->load(['route', 'team', 'stops'])), 'Trip completed');
|
|
}
|
|
|
|
private function authorizeDriver(Request $request, Trip $trip): void
|
|
{
|
|
$trip->loadMissing('team');
|
|
if ($trip->team?->driver_id !== $request->user()->id) {
|
|
abort(403, 'You are not the driver for this trip');
|
|
}
|
|
}
|
|
|
|
private function ensureStopBelongs(TripStop $stop, Trip $trip): void
|
|
{
|
|
if ($stop->trip_id !== $trip->id) abort(404, 'Stop not found in this trip');
|
|
}
|
|
}
|