Files
Verde-Web/app/Http/Controllers/Api/V1/Admin/AdminTripController.php
admin 58a7d3680a feat(backend): hardening sprint — docs, schedulers, validation, email + pickup
1. API docs via dedoc/scramble at /docs/api (scoped to api/v1).
   Linked from the admin sidebar Settings group.

2. Scheduled commands registered in routes/console.php:
   - reports:aggregate (02:00) — daily/weekly/monthly aggregations
   - qr:expire (02:30) — flips past-due allocated/active codes to expired
   - trucks:prune-locations (03:00) — drops history older than retention
     window (default 7 days, config('verde.location_retention_days'))
   All idempotent + withoutOverlapping. --dry flags on qr:expire and
   trucks:prune-locations for safe inspection.

3. Trip double-booking validation: AdminTripController::store rejects
   new trips when the team or truck already has a non-cancelled trip on
   the same date. override_conflicts: true bypasses for emergencies.
   Cancelled trips don't block rebooking.

4a. Email verification: User implements MustVerifyEmail.
    VerifyEmailNotification overrides verificationUrl() for our
    namespaced route. Register sends the link automatically (best
    effort, won't block signup). POST /auth/email/resend (auth) +
    GET /auth/email/verify/{id}/{hash} (signed URL).

4b. Password change while logged in: POST /me/password validates
    current_password, requires the new password to differ, revokes
    every other active token on success — current session stays.

5a. PickupImminent notification: when TripStop -> arrived,
    TripExecutor::notifyAssignedHouseholds() finds households whose
    assigned_drop_off_point_id matches and sends DB + SMS.

5b. Auto-geofence on truck location: TruckTracker::record() now
    auto-fires TripExecutor::arriveAtDumpsite() when an in-progress
    trip's truck pings inside its dumpsite boundary. The executor's
    status guard prevents duplicate timeline events if the driver also
    presses arrive-dumpsite manually.

190 feature tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 17:13:00 +08:00

150 lines
5.2 KiB
PHP

<?php
namespace App\Http\Controllers\Api\V1\Admin;
use App\Http\Controllers\Api\V1\ApiController;
use App\Http\Requests\Trip\StoreTripRequest;
use App\Http\Resources\TripResource;
use App\Models\Route;
use App\Models\Trip;
use App\Models\TripStop;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class AdminTripController extends ApiController
{
public function index(Request $request): JsonResponse
{
$request->validate([
'status' => ['nullable', 'in:scheduled,in_progress,at_dumpsite,completed,cancelled'],
'date' => ['nullable', 'date'],
'team_id' => ['nullable', 'integer'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
]);
$perPage = (int) $request->input('per_page', 25);
$trips = Trip::query()
->with(['route', 'team', 'truck', 'dumpsite'])
->when($request->filled('status'), fn ($q) => $q->where('status', $request->string('status')))
->when($request->filled('date'), fn ($q) => $q->whereDate('scheduled_date', $request->string('date')))
->when($request->filled('team_id'), fn ($q) => $q->where('team_id', $request->integer('team_id')))
->orderByDesc('scheduled_date')
->orderBy('scheduled_start_time')
->paginate($perPage);
return $this->ok(
TripResource::collection($trips),
null,
[
'page' => $trips->currentPage(),
'per_page' => $trips->perPage(),
'total' => $trips->total(),
'last_page' => $trips->lastPage(),
],
);
}
public function store(StoreTripRequest $request): JsonResponse
{
$data = $request->validated();
$override = (bool) ($data['override_conflicts'] ?? false);
unset($data['override_conflicts']);
$route = Route::with('stops.dropOffPoint')->findOrFail($data['route_id']);
$data['dumpsite_id'] = $data['dumpsite_id'] ?? $route->default_dumpsite_id;
$data['truck_id'] = $data['truck_id'] ?? null;
$data['created_by_admin_id'] = $request->user()->id;
if (! $override) {
$conflicts = $this->detectScheduleConflicts(
(int) $data['team_id'],
$data['scheduled_date'],
$data['truck_id'] ?? null,
);
if (! empty($conflicts)) {
return $this->fail(
'Trip has scheduling conflicts',
['conflicts' => $conflicts],
422,
);
}
}
$trip = DB::transaction(function () use ($data, $route) {
$trip = Trip::create($data);
foreach ($route->stops as $rs) {
TripStop::create([
'trip_id' => $trip->id,
'drop_off_point_id' => $rs->drop_off_point_id,
'sequence' => $rs->sequence,
'status' => TripStop::STATUS_PENDING,
]);
}
return $trip;
});
return $this->created(
new TripResource(
$trip->fresh()->load(['route', 'team', 'truck', 'dumpsite', 'stops.dropOffPoint']),
),
'Trip scheduled',
);
}
public function show(Trip $trip): JsonResponse
{
$trip->load([
'route', 'team.driver', 'team.scanner', 'truck', 'dumpsite',
'stops.dropOffPoint', 'timelineEvents.recordedBy',
]);
return $this->ok(new TripResource($trip));
}
public function cancel(Request $request, Trip $trip): JsonResponse
{
if (in_array($trip->status, [Trip::STATUS_COMPLETED, Trip::STATUS_CANCELLED], true)) {
return $this->fail('Trip is already finished', null, 422);
}
$trip->forceFill(['status' => Trip::STATUS_CANCELLED])->save();
return $this->ok(new TripResource($trip->fresh()->load(['route', 'team'])), 'Trip cancelled');
}
/**
* Returns human-readable conflict messages for the proposed trip.
* Cancelled trips don't conflict; everything else on the same date
* for the same team or truck does.
*/
private function detectScheduleConflicts(int $teamId, string $scheduledDate, ?int $truckId): array
{
$conflicts = [];
$teamConflict = Trip::query()
->where('team_id', $teamId)
->whereDate('scheduled_date', $scheduledDate)
->whereNotIn('status', [Trip::STATUS_CANCELLED])
->first();
if ($teamConflict) {
$conflicts[] = "Team is already scheduled on {$scheduledDate} (trip {$teamConflict->trip_number})";
}
if ($truckId) {
$truckConflict = Trip::query()
->where('truck_id', $truckId)
->whereDate('scheduled_date', $scheduledDate)
->whereNotIn('status', [Trip::STATUS_CANCELLED])
->first();
if ($truckConflict) {
$conflicts[] = "Truck is already on a trip on {$scheduledDate} ({$truckConflict->trip_number})";
}
}
return $conflicts;
}
}