Files
Verde-Web/app/Http/Controllers/Api/V1/Driver/DriverTripController.php
admin 15d56d0e98 feat(backend): finish Module 13 sub-modules + flow fixes
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>
2026-05-01 14:01:38 +08:00

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');
}
}