schneespur/release/schneespur-1.0.2/app/Services/JobLifecycleService.php
Michael 7288b93500 Release v1.0.2: diagnostic infrastructure core
Add neutral diagnostic framework for future reporting modules:
- DiagnosticReporterInterface, Registry, Manager, PayloadSanitizer
- Laravel exception hook in bootstrap/app.php
- Module permission declarations (requires_permissions in module.json)
- Core diagnostic report points (module boot/install/update failures)
- Module documentation update (moduldoku.md)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-18 16:54:11 +00:00

234 lines
7 KiB
PHP

<?php
namespace App\Services;
use App\Enums\JobType;
use App\Enums\WeatherMoment;
use App\Events\JobCompleted;
use App\Events\JobStarted;
use App\Events\WeatherSnapshotCreated;
use App\Exceptions\JobLifecycleException;
use App\Jobs\FetchWeather;
use App\Models\CustomerObject;
use App\Models\Job;
use App\Models\User;
use App\Models\Vehicle;
use App\Models\WeatherSnapshot;
use App\Models\WorkShift;
use Carbon\Carbon;
class JobLifecycleService
{
public function startShift(User $user): WorkShift
{
if ($this->findActiveShift($user)) {
throw JobLifecycleException::shiftAlreadyActive();
}
return WorkShift::create([
'user_id' => $user->id,
'started_at' => now(),
]);
}
public function endShift(User $user): WorkShift
{
$shift = $this->findActiveShift($user);
if (! $shift) {
throw JobLifecycleException::noActiveShift();
}
if ($this->findActiveJob($user)) {
throw JobLifecycleException::activeJobMustEndFirst();
}
$shift->ended_at = now();
$shift->save();
return $shift;
}
public function startJob(User $user, CustomerObject $customerObject, JobType $type, ?Vehicle $vehicle = null): Job
{
$shift = $this->findActiveShift($user);
if (! $shift) {
throw JobLifecycleException::noActiveShift();
}
if ($this->findActiveJob($user)) {
throw JobLifecycleException::jobAlreadyActive();
}
$job = Job::create([
'work_shift_id' => $shift->id,
'customer_id' => $customerObject->customer_id,
'customer_object_id' => $customerObject->id,
'user_id' => $user->id,
'vehicle_id' => $vehicle?->id,
'type' => $type,
'started_at' => now(),
'is_manual' => false,
]);
JobStarted::dispatch($job);
if ($customerObject->lat !== null && $customerObject->lon !== null) {
$this->dispatchWeather($job->id, WeatherMoment::Start, (float) $customerObject->lat, (float) $customerObject->lon);
}
return $job;
}
public function endJob(User $user, ?string $notes = null): Job
{
$job = $this->findActiveJob($user);
if (! $job) {
throw JobLifecycleException::noActiveJob();
}
$job->ended_at = now();
if ($notes !== null) {
$job->notes = $notes;
}
$job->save();
$lat = null;
$lon = null;
$latestGps = $job->gpsPoints()->latest('timestamp')->first();
if ($latestGps !== null && $latestGps->lat !== null && $latestGps->lon !== null) {
$lat = (float) $latestGps->lat;
$lon = (float) $latestGps->lon;
} else {
$object = $job->customerObject;
if ($object !== null && $object->lat !== null && $object->lon !== null) {
$lat = (float) $object->lat;
$lon = (float) $object->lon;
}
}
if ($lat !== null && $lon !== null) {
$this->dispatchWeather($job->id, WeatherMoment::End, $lat, $lon);
if (! $this->isCronActive()) {
JobCompleted::dispatch($job, WeatherSnapshot::where('job_id', $job->id)->where('moment', WeatherMoment::End)->exists());
}
} else {
JobCompleted::dispatch($job, false);
}
return $job->loadCount('gpsPoints');
}
public function createManualJob(
User $driver,
CustomerObject $customerObject,
JobType $type,
Carbon $startedAt,
Carbon $endedAt,
?string $notes = null,
?Vehicle $vehicle = null,
): Job {
$shift = WorkShift::create([
'user_id' => $driver->id,
'started_at' => $startedAt,
'ended_at' => $endedAt,
]);
$job = Job::create([
'work_shift_id' => $shift->id,
'customer_id' => $customerObject->customer_id,
'customer_object_id' => $customerObject->id,
'user_id' => $driver->id,
'vehicle_id' => $vehicle?->id,
'type' => $type,
'started_at' => $startedAt,
'ended_at' => $endedAt,
'notes' => $notes,
'is_manual' => true,
]);
JobStarted::dispatch($job);
if ($customerObject->lat !== null && $customerObject->lon !== null) {
$this->dispatchWeather($job->id, WeatherMoment::Start, (float) $customerObject->lat, (float) $customerObject->lon);
$this->dispatchWeather($job->id, WeatherMoment::End, (float) $customerObject->lat, (float) $customerObject->lon);
if (! $this->isCronActive()) {
JobCompleted::dispatch($job, WeatherSnapshot::where('job_id', $job->id)->where('moment', WeatherMoment::End)->exists());
}
} else {
JobCompleted::dispatch($job, false);
}
return $job;
}
public function findActiveJob(User $user): ?Job
{
return Job::where('user_id', $user->id)
->whereNull('ended_at')
->whereHas('workShift', fn ($q) => $q->whereNull('ended_at'))
->first();
}
public function findActiveShift(User $user): ?WorkShift
{
return WorkShift::where('user_id', $user->id)
->whereNull('ended_at')
->first();
}
private function dispatchWeather(int $jobId, WeatherMoment $moment, float $lat, float $lon): void
{
if ($this->isCronActive()) {
FetchWeather::dispatch($jobId, $moment, $lat, $lon);
return;
}
$this->fetchWeatherSync($jobId, $moment, $lat, $lon);
}
private function isCronActive(): bool
{
$lastRun = cache()->get('cron.last_run');
return $lastRun && $lastRun->diffInMinutes(now()) < 5;
}
private function fetchWeatherSync(int $jobId, WeatherMoment $moment, float $lat, float $lon): void
{
try {
$registry = app(Weather\WeatherProviderRegistry::class);
$provider = $registry->resolve();
$result = $provider->fetchCurrent($lat, $lon);
if ($result === null) {
return;
}
$snapshot = WeatherSnapshot::updateOrCreate(
['job_id' => $jobId, 'moment' => $moment],
[
'provider' => $result->provider,
'temperature' => $result->temperature_c,
'precipitation' => $result->precipitation_mm,
'snow_depth' => $result->snowfall_mm,
'wind_speed' => $result->wind_kmh,
'humidity' => $result->humidity_percent,
'weather_code' => $result->weather_code,
'raw_response' => $result->toArray(),
'fetched_at' => now(),
]
);
WeatherSnapshotCreated::dispatch($snapshot);
} catch (\Throwable) {
// Weather is never blocking — silently fail
}
}
}