won't happen (rejected). * Active -> Used. Writes timeline event when trip context provided. */ public function scan( string $serial, DropOffPoint $dropOff, User $scanner, float $lat, float $lng, ?Trip $trip = null, ?TripStop $tripStop = null, ?int $weightKg = null, ?string $wasteType = null, ?string $photoPath = null, ?string $notes = null, ?\DateTimeInterface $scannedAt = null, ): ScanResult { $code = QrCode::where('serial', $serial)->first(); $when = $scannedAt ?? now(); if (! $code) { return ScanResult::reject('not_found'); } $statusName = (string) $code->status; if ($statusName === Used::$name) { return ScanResult::reject('duplicate', $code, CollectionLog::STATUS_DUPLICATE); } if ($statusName !== Active::$name) { return ScanResult::reject('invalid_state:'.$statusName, $code, CollectionLog::STATUS_INVALID); } if ($code->expires_at && $code->expires_at->isPast()) { return ScanResult::reject('expired', $code, CollectionLog::STATUS_EXPIRED); } // Geo proximity check if ($dropOff->coordinates) { $row = DB::selectOne( 'SELECT ST_Distance_Sphere(coordinates, ST_SRID(POINT(?, ?), 4326)) AS m FROM drop_off_points WHERE id = ?', [$lng, $lat, $dropOff->id], ); $distance = (float) ($row->m ?? 999999); if ($distance > self::MAX_DISTANCE_METERS) { return ScanResult::reject('out_of_range:'.(int) $distance.'m', $code, CollectionLog::STATUS_INVALID); } } return DB::transaction(function () use ( $code, $dropOff, $scanner, $lat, $lng, $trip, $tripStop, $weightKg, $wasteType, $photoPath, $notes, $when, ) { $code->status->transitionTo(Used::class); $code->forceFill([ 'used_at' => $when, 'used_at_drop_off_id' => $dropOff->id, 'scanned_by_user_id' => $scanner->id, ])->save(); $log = CollectionLog::create([ 'qr_code_id' => $code->id, 'household_id' => $code->assigned_to_household_id, 'drop_off_point_id' => $dropOff->id, 'scanned_by_user_id' => $scanner->id, 'trip_id' => $trip?->id, 'trip_stop_id' => $tripStop?->id, 'scanned_at' => $when, 'coordinates_at_scan' => new Point($lat, $lng, 4326), 'weight_kg' => $weightKg, 'waste_type' => $wasteType, 'photo_path' => $photoPath, 'notes' => $notes, 'verification_status' => CollectionLog::STATUS_VALID, ]); if ($tripStop) { $tripStop->increment('total_scans'); } if ($trip) { $this->tripExecutor->log( $trip, \App\Models\TripTimelineEvent::TYPE_QR_SCANNED, $scanner, $lat, $lng, relatedType: CollectionLog::class, relatedId: $log->id, metadata: ['serial' => $code->serial], ); } // Notify if household balance is low after this scan if ($code->household) { $this->qrAllocator->notifyBalanceIfLow($code->household); } return ScanResult::accepted($code->fresh(), $log); }); } }