access->resource('driver', $driver); $page = new Pagination($input); $status = Fields::choice($input, 'status', [ 'all', 'pending', 'transit', 'completed', 'exception', ], 'all'); $query = WaterDelivery::query()->with(['sku', 'enterprise'])->where('driver_id', $driver); $states = [ 'pending' => ['assigned'], 'transit' => ['transit'], 'completed' => ['completed'], 'exception' => ['returning', 'returned'], ]; if ($status !== 'all') { $query->whereIn('status', $states[$status]); } $total = (clone $query)->count(); $items = $query->orderByDesc('scheduled_date')->orderBy('route_id')->orderBy('stop_order')->orderBy('id') ->offset($page->offset())->limit($page->pageSize)->get()->map(function ($delivery) { return WaterPresenter::delivery($delivery) + [ 'enterprise_name' => $delivery->enterprise?->name, 'route_id' => $delivery->route_id, 'stop_order' => (int) $delivery->stop_order, ]; })->all(); return $page->result($items, $total); } public function dashboard(int $driver): array { $driverRow = $this->access->resource('driver', $driver); $query = WaterDelivery::query()->with(['sku', 'enterprise'])->where('driver_id', $driver); return [ 'driver' => [ 'id' => (int) $driverRow->id, 'name' => $driverRow->name, 'vehicle' => $driverRow->vehicle, ], 'shift' => WaterDriverShift::query()->where([ 'driver_id' => $driver, 'work_date' => date('Y-m-d'), ])->value('state'), 'summary' => [ 'pending' => (clone $query)->where('status', 'assigned')->count(), 'transit' => (clone $query)->where('status', 'transit')->count(), 'completed_today' => (clone $query)->where('status', 'completed')->whereBetween('completed_at', [date('Y-m-d') . ' 00:00:00', date('Y-m-d H:i:s')])->count(), ], 'tasks' => $this->tasks($driver, [ 'status' => 'pending', 'page_size' => 10, ]), 'routes' => $this->routes($driver), 'capabilities' => [ 'live_gps' => false, 'self_accept_unassigned' => false, ], ]; } public function routes(int $driver): array { $this->access->resource('driver', $driver); return WaterRoute::query()->where('driver_id', $driver)->whereNull('settled_at')->orderBy('work_date')->orderBy('id')->limit(50) ->get([ 'id', 'code', 'work_date', 'region', 'optimization', ])->map(function ($routeRow) { $route = $routeRow->toArray(); $route['optimization'] = is_string($routeRow->optimization) ? json_decode($routeRow->optimization, true) : $routeRow->optimization; $route['stops'] = WaterDelivery::query()->where('route_id', $routeRow->id)->orderBy('stop_order')->orderBy('id') ->get([ 'id', 'address', 'status', 'quantity', 'longitude', 'latitude', 'origin_address', 'origin_longitude', 'origin_latitude', 'stop_order', ])->toArray(); return $route; })->all(); } public function detail(int $driver, int $id): array { $delivery = $this->access->driverTask($driver, $id); $enterprise = WaterEnterprise::query()->find($delivery->enterprise_id); return WaterPresenter::delivery($delivery) + [ 'enterprise_name' => $enterprise?->name, 'contact' => $enterprise?->contact, 'phone' => $enterprise ? WaterPresenter::phone($enterprise->phone) : null, 'contact_phone' => $enterprise && in_array($delivery->status, ['assigned', 'transit', 'returning'], true) ? $enterprise->phone : null, 'longitude' => $delivery->longitude, 'latitude' => $delivery->latitude, 'route_id' => $delivery->route_id, 'records' => MiniTaskRecord::query()->where('delivery_id', $id)->orderBy('id')->get([ 'id', 'kind', 'data', 'created_at', ])->toArray(), 'photos' => MiniPhoto::query()->where('delivery_id', $id)->orderBy('id')->get(['id', 'created_at'])->toArray(), 'capabilities' => [ 'checkin' => true, 'photos' => true, 'fulfillment' => true, 'device_binding' => false, ], ]; } private function lockTask(int $driver, int $id) { $preview = $this->access->driverTask($driver, $id); WaterSku::query()->where('id', $preview->sku_id)->lockForUpdate()->first(); $driverRow = WaterDriver::query()->where('id', $driver)->lockForUpdate()->first(); $task = WaterDelivery::query()->where('id', $id)->lockForUpdate()->firstOrFail(); if (!$driverRow || (int) $driverRow->status !== 1 || (int) $task->driver_id !== $driver) { throw new ApiException(40301, '任务分配或司机状态已变更,请刷新', 403); } return $task; } public function command(int $driver, int $id, string $action, array $input): array { $this->access->driverTask($driver, $id); $handler = TaskActionFactory::make($action); Fields::only($input, $handler->fields()); return Commands::run( $this->access->actor, Fields::text($input, 'request_key', 80), 'driver.' . $action . ':' . $id, $input, fn () => $handler->execute($this->lockTask($driver, $id), $input, (int) $this->access->actor->userId), fn () => $this->lockTask($driver, $id) ); } public function photo(int $driver, int $id, array $input): array { $this->access->driverTask($driver, $id); Fields::only($input, [ 'request_key', 'image_base64', 'latitude', 'longitude', ]); $location = Fields::location($input); $encoded = $input['image_base64'] ?? null; if (!is_string($encoded) || strlen($encoded) > 2800000) { throw new ApiException(41301, '照片不能超过2MB', 413); } $bytes = base64_decode($encoded, true); $info = $bytes === false ? false : @getimagesizefromstring($bytes); if ($bytes === false || strlen($bytes) > 2097152 || !$info || !in_array($info['mime'], ['image/jpeg', 'image/png'], true) || $info[0] > 4096 || $info[1] > 4096 || $info[0] * $info[1] > 8000000) { throw new ApiException(42201, '请上传有效JPEG/PNG图片,最长边4096像素', 422); } return Commands::run($this->access->actor, Fields::text($input, 'request_key', 80), 'driver.photo:' . $id, $input, function () use ($driver, $id, $bytes, $location) { $task = $this->lockTask($driver, $id); if ($task->status !== 'transit') { throw new ApiException(40902, '仅配送中的任务可上传照片', 409); } if (MiniPhoto::query()->where('delivery_id', $id)->count() >= 20) { throw new ApiException(40902, '该任务照片已达到20张上限', 409); } $image = @imagecreatefromstring($bytes); if (!$image) { throw new ApiException(42201, '图片无法解码', 422); } $stamp = 'DS' . $id . ' ' . date('Y-m-d H:i:s') . ' GCJ02 ' . $location['latitude'] . ',' . $location['longitude']; $height = imagesy($image); $color = imagecolorallocate($image, 255, 255, 255); $background = imagecolorallocate($image, 0, 0, 0); imagefilledrectangle($image, 0, max(0, $height - 22), imagesx($image), $height, $background); imagestring($image, 2, 4, max(0, $height - 18), $stamp, $color); ob_start(); imagejpeg($image, null, 85); $sanitized = ob_get_clean(); imagedestroy($image); $photo = MiniPhoto::query()->create([ 'delivery_id' => $id, 'user_id' => $this->access->actor->userId, 'mime' => 'image/jpeg', 'image_data' => $sanitized, 'created_at' => date('Y-m-d H:i:s'), ] + $location); return [ 'id' => (int) $photo->id, 'delivery_id' => $id, 'location_verified' => false, 'watermark' => 'server_time_and_client_location', ]; }, fn () => $this->lockTask($driver, $id)); } public function deviceCheck(int $driver, int $task, string $sn): array { $delivery = $this->access->driverTask($driver, $task); $device = WaterDevice::query()->where('enterprise_id', $delivery->enterprise_id)->where('sn', $sn)->first(); if (!$device) { throw new ApiException(40401, '设备未绑定当前企业,请联系后台登记', 404); } return [ 'id' => (int) $device->id, 'sn' => $device->sn, 'last_seen' => $device->last_seen, 'online' => $device->last_seen !== null && $device->last_seen >= date('Y-m-d H:i:s', time() - 900) && trim($device->fault) === '', 'network_test_passed' => null, 'binding_enabled' => false, ]; } }