Files
Verde-Web/app/Services/Trip/TripExecutor.php
admin 13148fab97 feat(backend): complete Module 10 (trips + timeline)
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>
2026-04-30 02:51:52 +08:00

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(),
]);
}
}