DriverService.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260
  1. <?php
  2. declare(strict_types=1);
  3. namespace app\miniapi\service;
  4. use app\miniapi\http\Fields;
  5. use app\miniapi\http\Pagination;
  6. use app\miniapi\exception\ApiException;
  7. use app\miniapi\model\MiniTaskRecord;
  8. use app\miniapi\model\MiniPhoto;
  9. use app\miniapi\service\driver\TaskActionFactory;
  10. use plugin\saiadmin\app\model\water\WaterDelivery;
  11. use plugin\saiadmin\app\model\water\WaterEnterprise;
  12. use plugin\saiadmin\app\model\water\WaterSku;
  13. use plugin\saiadmin\app\model\water\WaterDriver;
  14. use plugin\saiadmin\app\model\water\WaterRoute;
  15. use plugin\saiadmin\app\model\water\WaterDriverShift;
  16. use plugin\saiadmin\app\model\water\WaterDevice;
  17. final class DriverService
  18. {
  19. public function __construct(private readonly Access $access)
  20. {
  21. }
  22. public function tasks(int $driver, array $input): array
  23. {
  24. $this->access->resource('driver', $driver);
  25. $page = new Pagination($input);
  26. $status = Fields::choice($input, 'status', [
  27. 'all',
  28. 'pending',
  29. 'transit',
  30. 'completed',
  31. 'exception',
  32. ], 'all');
  33. $query = WaterDelivery::query()->with(['sku', 'enterprise'])->where('driver_id', $driver);
  34. $states = [
  35. 'pending' => ['assigned'],
  36. 'transit' => ['transit'],
  37. 'completed' => ['completed'],
  38. 'exception' => ['returning', 'returned'],
  39. ];
  40. if ($status !== 'all') {
  41. $query->whereIn('status', $states[$status]);
  42. }
  43. $total = (clone $query)->count();
  44. $items = $query->orderByDesc('scheduled_date')->orderBy('route_id')->orderBy('stop_order')->orderBy('id')
  45. ->offset($page->offset())->limit($page->pageSize)->get()->map(function ($delivery) {
  46. return WaterPresenter::delivery($delivery) + [
  47. 'enterprise_name' => $delivery->enterprise?->name,
  48. 'route_id' => $delivery->route_id,
  49. 'stop_order' => (int) $delivery->stop_order,
  50. ];
  51. })->all();
  52. return $page->result($items, $total);
  53. }
  54. public function dashboard(int $driver): array
  55. {
  56. $driverRow = $this->access->resource('driver', $driver);
  57. $query = WaterDelivery::query()->with(['sku', 'enterprise'])->where('driver_id', $driver);
  58. return [
  59. 'driver' => [
  60. 'id' => (int) $driverRow->id,
  61. 'name' => $driverRow->name,
  62. 'vehicle' => $driverRow->vehicle,
  63. ],
  64. 'shift' => WaterDriverShift::query()->where([
  65. 'driver_id' => $driver,
  66. 'work_date' => date('Y-m-d'),
  67. ])->value('state'),
  68. 'summary' => [
  69. 'pending' => (clone $query)->where('status', 'assigned')->count(),
  70. 'transit' => (clone $query)->where('status', 'transit')->count(),
  71. '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(),
  72. ],
  73. 'tasks' => $this->tasks($driver, [
  74. 'status' => 'pending',
  75. 'page_size' => 10,
  76. ]),
  77. 'routes' => $this->routes($driver),
  78. 'capabilities' => [
  79. 'live_gps' => false,
  80. 'self_accept_unassigned' => false,
  81. ],
  82. ];
  83. }
  84. public function routes(int $driver): array
  85. {
  86. $this->access->resource('driver', $driver);
  87. return WaterRoute::query()->where('driver_id', $driver)->whereNull('settled_at')->orderBy('work_date')->orderBy('id')->limit(50)
  88. ->get([
  89. 'id',
  90. 'code',
  91. 'work_date',
  92. 'region',
  93. 'optimization',
  94. ])->map(function ($routeRow) {
  95. $route = $routeRow->toArray();
  96. $route['optimization'] = is_string($routeRow->optimization) ? json_decode($routeRow->optimization, true) : $routeRow->optimization;
  97. $route['stops'] = WaterDelivery::query()->where('route_id', $routeRow->id)->orderBy('stop_order')->orderBy('id')
  98. ->get([
  99. 'id',
  100. 'address',
  101. 'status',
  102. 'quantity',
  103. 'longitude',
  104. 'latitude',
  105. 'origin_address',
  106. 'origin_longitude',
  107. 'origin_latitude',
  108. 'stop_order',
  109. ])->toArray();
  110. return $route;
  111. })->all();
  112. }
  113. public function detail(int $driver, int $id): array
  114. {
  115. $delivery = $this->access->driverTask($driver, $id);
  116. $enterprise = WaterEnterprise::query()->find($delivery->enterprise_id);
  117. return WaterPresenter::delivery($delivery) + [
  118. 'enterprise_name' => $enterprise?->name,
  119. 'contact' => $enterprise?->contact,
  120. 'phone' => $enterprise ? WaterPresenter::phone($enterprise->phone) : null,
  121. 'contact_phone' => $enterprise && in_array($delivery->status, ['assigned', 'transit', 'returning'], true) ? $enterprise->phone : null,
  122. 'longitude' => $delivery->longitude,
  123. 'latitude' => $delivery->latitude,
  124. 'route_id' => $delivery->route_id,
  125. 'records' => MiniTaskRecord::query()->where('delivery_id', $id)->orderBy('id')->get([
  126. 'id',
  127. 'kind',
  128. 'data',
  129. 'created_at',
  130. ])->toArray(),
  131. 'photos' => MiniPhoto::query()->where('delivery_id', $id)->orderBy('id')->get(['id', 'created_at'])->toArray(),
  132. 'capabilities' => [
  133. 'checkin' => true,
  134. 'photos' => true,
  135. 'fulfillment' => true,
  136. 'device_binding' => false,
  137. ],
  138. ];
  139. }
  140. private function lockTask(int $driver, int $id)
  141. {
  142. $preview = $this->access->driverTask($driver, $id);
  143. WaterSku::query()->where('id', $preview->sku_id)->lockForUpdate()->first();
  144. $driverRow = WaterDriver::query()->where('id', $driver)->lockForUpdate()->first();
  145. $task = WaterDelivery::query()->where('id', $id)->lockForUpdate()->firstOrFail();
  146. if (!$driverRow || (int) $driverRow->status !== 1 || (int) $task->driver_id !== $driver) {
  147. throw new ApiException(40301, '任务分配或司机状态已变更,请刷新', 403);
  148. }
  149. return $task;
  150. }
  151. public function command(int $driver, int $id, string $action, array $input): array
  152. {
  153. $this->access->driverTask($driver, $id);
  154. $handler = TaskActionFactory::make($action);
  155. Fields::only($input, $handler->fields());
  156. return Commands::run(
  157. $this->access->actor,
  158. Fields::text($input, 'request_key', 80),
  159. 'driver.' . $action . ':' . $id,
  160. $input,
  161. fn () => $handler->execute($this->lockTask($driver, $id), $input, (int) $this->access->actor->userId),
  162. fn () => $this->lockTask($driver, $id)
  163. );
  164. }
  165. public function photo(int $driver, int $id, array $input): array
  166. {
  167. $this->access->driverTask($driver, $id);
  168. Fields::only($input, [
  169. 'request_key',
  170. 'image_base64',
  171. 'latitude',
  172. 'longitude',
  173. ]);
  174. $location = Fields::location($input);
  175. $encoded = $input['image_base64'] ?? null;
  176. if (!is_string($encoded) || strlen($encoded) > 2800000) {
  177. throw new ApiException(41301, '照片不能超过2MB', 413);
  178. }
  179. $bytes = base64_decode($encoded, true);
  180. $info = $bytes === false ? false : @getimagesizefromstring($bytes);
  181. if ($bytes === false || strlen($bytes) > 2097152 || !$info || !in_array($info['mime'], ['image/jpeg', 'image/png'], true)
  182. || $info[0] > 4096 || $info[1] > 4096 || $info[0] * $info[1] > 8000000) {
  183. throw new ApiException(42201, '请上传有效JPEG/PNG图片,最长边4096像素', 422);
  184. }
  185. return Commands::run($this->access->actor, Fields::text($input, 'request_key', 80), 'driver.photo:' . $id, $input, function () use ($driver, $id, $bytes, $location) {
  186. $task = $this->lockTask($driver, $id);
  187. if ($task->status !== 'transit') {
  188. throw new ApiException(40902, '仅配送中的任务可上传照片', 409);
  189. }
  190. if (MiniPhoto::query()->where('delivery_id', $id)->count() >= 20) {
  191. throw new ApiException(40902, '该任务照片已达到20张上限', 409);
  192. }
  193. $image = @imagecreatefromstring($bytes);
  194. if (!$image) {
  195. throw new ApiException(42201, '图片无法解码', 422);
  196. }
  197. $stamp = 'DS' . $id . ' ' . date('Y-m-d H:i:s') . ' GCJ02 ' . $location['latitude'] . ',' . $location['longitude'];
  198. $height = imagesy($image);
  199. $color = imagecolorallocate($image, 255, 255, 255);
  200. $background = imagecolorallocate($image, 0, 0, 0);
  201. imagefilledrectangle($image, 0, max(0, $height - 22), imagesx($image), $height, $background);
  202. imagestring($image, 2, 4, max(0, $height - 18), $stamp, $color);
  203. ob_start();
  204. imagejpeg($image, null, 85);
  205. $sanitized = ob_get_clean();
  206. imagedestroy($image);
  207. $photo = MiniPhoto::query()->create([
  208. 'delivery_id' => $id,
  209. 'user_id' => $this->access->actor->userId,
  210. 'mime' => 'image/jpeg',
  211. 'image_data' => $sanitized,
  212. 'created_at' => date('Y-m-d H:i:s'),
  213. ] + $location);
  214. return [
  215. 'id' => (int) $photo->id,
  216. 'delivery_id' => $id,
  217. 'location_verified' => false,
  218. 'watermark' => 'server_time_and_client_location',
  219. ];
  220. }, fn () => $this->lockTask($driver, $id));
  221. }
  222. public function deviceCheck(int $driver, int $task, string $sn): array
  223. {
  224. $delivery = $this->access->driverTask($driver, $task);
  225. $device = WaterDevice::query()->where('enterprise_id', $delivery->enterprise_id)->where('sn', $sn)->first();
  226. if (!$device) {
  227. throw new ApiException(40401, '设备未绑定当前企业,请联系后台登记', 404);
  228. }
  229. return [
  230. 'id' => (int) $device->id,
  231. 'sn' => $device->sn,
  232. 'last_seen' => $device->last_seen,
  233. 'online' => $device->last_seen !== null && $device->last_seen >= date('Y-m-d H:i:s', time() - 900) && trim($device->fault) === '',
  234. 'network_test_passed' => null,
  235. 'binding_enabled' => false,
  236. ];
  237. }
  238. }