trips, trip_stops, trip_timeline_events. Admin: schedule/show/cancel. Driver: start, arrive/depart/skip stops, report incident, arrive at dumpsite, release load (creates dumpsite_release row + tallies trip load), complete. Every action writes a typed timeline event with GPS. Trip number auto-generated TRIP-YYYYMMDD-NNN. Promotes dumpsite_releases.trip_id to a real FK now that trips exists. 148 feature tests passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
224 lines
7.7 KiB
PHP
224 lines
7.7 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Trip;
|
|
|
|
use App\Models\DumpsiteRelease;
|
|
use App\Models\Trip;
|
|
use App\Models\TripStop;
|
|
use App\Models\TripTimelineEvent;
|
|
use App\Models\User;
|
|
use Illuminate\Support\Carbon;
|
|
use Illuminate\Support\Facades\DB;
|
|
use MatanYadaev\EloquentSpatial\Objects\Point;
|
|
|
|
class TripExecutor
|
|
{
|
|
/**
|
|
* Driver starts the trip. trip status -> in_progress, write timeline event.
|
|
*/
|
|
public function start(Trip $trip, User $driver, ?float $lat = null, ?float $lng = null): Trip
|
|
{
|
|
if ($trip->status !== Trip::STATUS_SCHEDULED) {
|
|
throw new \DomainException('Trip cannot be started from status '.$trip->status);
|
|
}
|
|
|
|
return DB::transaction(function () use ($trip, $driver, $lat, $lng) {
|
|
$trip->forceFill([
|
|
'status' => Trip::STATUS_IN_PROGRESS,
|
|
'actual_start_time' => now(),
|
|
])->save();
|
|
|
|
$this->log($trip, TripTimelineEvent::TYPE_TRIP_STARTED, $driver, $lat, $lng);
|
|
|
|
return $trip->fresh();
|
|
});
|
|
}
|
|
|
|
public function arriveAtStop(TripStop $stop, User $user, float $lat, float $lng): TripStop
|
|
{
|
|
if ($stop->status !== TripStop::STATUS_PENDING) {
|
|
throw new \DomainException('Stop status must be pending to arrive');
|
|
}
|
|
|
|
return DB::transaction(function () use ($stop, $user, $lat, $lng) {
|
|
$stop->forceFill([
|
|
'status' => TripStop::STATUS_ARRIVED,
|
|
'actual_arrival' => now(),
|
|
'coordinates_at_arrival' => new Point($lat, $lng, 4326),
|
|
])->save();
|
|
|
|
$this->log(
|
|
$stop->trip,
|
|
TripTimelineEvent::TYPE_ARRIVED_AT_STOP,
|
|
$user, $lat, $lng,
|
|
relatedType: TripStop::class,
|
|
relatedId: $stop->id,
|
|
);
|
|
|
|
return $stop->fresh();
|
|
});
|
|
}
|
|
|
|
public function departStop(TripStop $stop, User $user, float $lat, float $lng): TripStop
|
|
{
|
|
if (! in_array($stop->status, [TripStop::STATUS_ARRIVED, TripStop::STATUS_COMPLETED], true)) {
|
|
throw new \DomainException('Stop must be arrived/completed before departing');
|
|
}
|
|
|
|
return DB::transaction(function () use ($stop, $user, $lat, $lng) {
|
|
$stop->forceFill([
|
|
'status' => TripStop::STATUS_COMPLETED,
|
|
'actual_departure' => now(),
|
|
])->save();
|
|
|
|
$this->log(
|
|
$stop->trip,
|
|
TripTimelineEvent::TYPE_DEPARTED_STOP,
|
|
$user, $lat, $lng,
|
|
relatedType: TripStop::class,
|
|
relatedId: $stop->id,
|
|
);
|
|
|
|
return $stop->fresh();
|
|
});
|
|
}
|
|
|
|
public function skipStop(TripStop $stop, User $user, string $reason): TripStop
|
|
{
|
|
return DB::transaction(function () use ($stop, $user, $reason) {
|
|
$stop->forceFill([
|
|
'status' => TripStop::STATUS_SKIPPED,
|
|
'skip_reason' => $reason,
|
|
])->save();
|
|
|
|
$this->log(
|
|
$stop->trip,
|
|
TripTimelineEvent::TYPE_STOP_SKIPPED,
|
|
$user,
|
|
metadata: ['reason' => $reason, 'stop_id' => $stop->id],
|
|
relatedType: TripStop::class,
|
|
relatedId: $stop->id,
|
|
);
|
|
|
|
return $stop->fresh();
|
|
});
|
|
}
|
|
|
|
public function reportIncident(Trip $trip, User $user, string $kind, string $notes, ?float $lat = null, ?float $lng = null): TripTimelineEvent
|
|
{
|
|
return $this->log(
|
|
$trip,
|
|
$kind === 'breakdown' ? TripTimelineEvent::TYPE_BREAKDOWN : TripTimelineEvent::TYPE_INCIDENT_REPORTED,
|
|
$user, $lat, $lng,
|
|
metadata: ['kind' => $kind],
|
|
notes: $notes,
|
|
);
|
|
}
|
|
|
|
public function arriveAtDumpsite(Trip $trip, User $user, float $lat, float $lng): Trip
|
|
{
|
|
if (! in_array($trip->status, [Trip::STATUS_IN_PROGRESS], true)) {
|
|
throw new \DomainException('Trip must be in progress before arriving at dumpsite');
|
|
}
|
|
|
|
return DB::transaction(function () use ($trip, $user, $lat, $lng) {
|
|
$trip->forceFill([
|
|
'status' => Trip::STATUS_AT_DUMPSITE,
|
|
'dumpsite_arrival_time' => now(),
|
|
])->save();
|
|
|
|
$this->log($trip, TripTimelineEvent::TYPE_ARRIVED_AT_DUMPSITE, $user, $lat, $lng);
|
|
|
|
return $trip->fresh();
|
|
});
|
|
}
|
|
|
|
public function releaseLoad(Trip $trip, User $driver, array $data): DumpsiteRelease
|
|
{
|
|
if ($trip->status !== Trip::STATUS_AT_DUMPSITE) {
|
|
throw new \DomainException('Trip must be at dumpsite to release load');
|
|
}
|
|
|
|
return DB::transaction(function () use ($trip, $driver, $data) {
|
|
$release = DumpsiteRelease::create([
|
|
'trip_id' => $trip->id,
|
|
'dumpsite_id' => $data['dumpsite_id'] ?? $trip->dumpsite_id,
|
|
'released_at' => now(),
|
|
'released_by_driver_id' => $driver->id,
|
|
'weight_kg' => $data['weight_kg'],
|
|
'waste_type_breakdown' => $data['waste_type_breakdown'] ?? null,
|
|
'gate_pass_number' => $data['gate_pass_number'] ?? null,
|
|
'dumpsite_attendant_name' => $data['dumpsite_attendant_name'] ?? null,
|
|
'photo_evidence_path' => $data['photo_evidence_path'] ?? null,
|
|
'coordinates_at_release' => isset($data['lat'], $data['lng'])
|
|
? new Point((float) $data['lat'], (float) $data['lng'], 4326)
|
|
: null,
|
|
'notes' => $data['notes'] ?? null,
|
|
]);
|
|
|
|
$trip->forceFill([
|
|
'total_load_kg' => (int) ($trip->total_load_kg ?? 0) + (int) $data['weight_kg'],
|
|
])->save();
|
|
|
|
$this->log(
|
|
$trip,
|
|
TripTimelineEvent::TYPE_LOAD_RELEASED,
|
|
$driver,
|
|
$data['lat'] ?? null,
|
|
$data['lng'] ?? null,
|
|
relatedType: DumpsiteRelease::class,
|
|
relatedId: $release->id,
|
|
metadata: ['weight_kg' => (int) $data['weight_kg']],
|
|
);
|
|
|
|
return $release;
|
|
});
|
|
}
|
|
|
|
public function complete(Trip $trip, User $user, ?float $lat = null, ?float $lng = null): Trip
|
|
{
|
|
if (! in_array($trip->status, [Trip::STATUS_AT_DUMPSITE, Trip::STATUS_IN_PROGRESS], true)) {
|
|
throw new \DomainException('Trip cannot be completed from status '.$trip->status);
|
|
}
|
|
|
|
return DB::transaction(function () use ($trip, $user, $lat, $lng) {
|
|
$trip->forceFill([
|
|
'status' => Trip::STATUS_COMPLETED,
|
|
'actual_end_time' => now(),
|
|
'dumpsite_departure_time' => $trip->dumpsite_departure_time ?? now(),
|
|
])->save();
|
|
|
|
$this->log($trip, TripTimelineEvent::TYPE_TRIP_COMPLETED, $user, $lat, $lng);
|
|
|
|
return $trip->fresh();
|
|
});
|
|
}
|
|
|
|
public function log(
|
|
Trip $trip,
|
|
string $eventType,
|
|
?User $user,
|
|
?float $lat = null,
|
|
?float $lng = null,
|
|
?string $relatedType = null,
|
|
?int $relatedId = null,
|
|
?array $metadata = null,
|
|
?string $notes = null,
|
|
): TripTimelineEvent {
|
|
return TripTimelineEvent::create([
|
|
'trip_id' => $trip->id,
|
|
'event_type' => $eventType,
|
|
'event_at' => now(),
|
|
'coordinates' => $lat !== null && $lng !== null
|
|
? new Point($lat, $lng, 4326)
|
|
: null,
|
|
'recorded_by_user_id' => $user?->id,
|
|
'related_type' => $relatedType,
|
|
'related_id' => $relatedId,
|
|
'metadata' => $metadata,
|
|
'notes' => $notes,
|
|
'created_at' => Carbon::now(),
|
|
]);
|
|
}
|
|
}
|