WaterRoutePlanning.php 6.4 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091
  1. <?php
  2. declare(strict_types=1);
  3. namespace plugin\saiadmin\app\service;
  4. use plugin\saiadmin\app\model\water\{WaterDelivery,WaterRoute,WaterRoutePreview,WaterSku,WaterDriver,WaterLedger,WaterModel};
  5. use plugin\saiadmin\app\service\routing\RouteOptimizer;
  6. use plugin\saiadmin\exception\ApiException;
  7. final class WaterRoutePlanning
  8. {
  9. public static function fingerprint(array $tasks): string
  10. {
  11. usort($tasks, fn($a,$b)=>(int)$a['id']<=>(int)$b['id']);
  12. $fields=['id','enterprise_id','sku_id','quantity','status','driver_id','route_id','stop_order','scheduled_date','deadline_at','longitude','latitude','origin_longitude','origin_latitude'];
  13. return hash('sha256',json_encode(array_map(static function ($task) use ($fields) {
  14. return array_map(fn($field)=>isset($task[$field])?(string)$task[$field]:null,$fields);
  15. },$tasks)));
  16. }
  17. public function preview(array $data, int $actor): array
  18. {
  19. $ids=$data['task_ids']??[];
  20. if (!is_array($ids) || !array_is_list($ids) || !$ids || count($ids)>8) throw new ApiException('请选择1至8个配送站点');
  21. $ids=array_map(fn($id)=>WaterBusiness::positive($id,'配送任务'),$ids);
  22. if (count(array_unique($ids))!==count($ids)) throw new ApiException('配送任务不能重复');
  23. $rows=WaterDelivery::query()->whereIn('id',$ids)->get()->keyBy('id');
  24. $tasks=[];
  25. foreach ($ids as $id) {
  26. if (!isset($rows[$id])) throw new ApiException('配送任务不存在');
  27. $tasks[]=$rows[$id]->toArray();
  28. }
  29. if (count(array_unique(array_column($tasks,'region')))>1) throw new ApiException('请在同一片区内规划路线');
  30. $origin=WaterDispatch::coordinates(['longitude'=>$tasks[0]['origin_longitude'],'latitude'=>$tasks[0]['origin_latitude']]);
  31. if ($origin['longitude']===null) throw new ApiException('请先设置配送出发点坐标');
  32. $points=[array_values($origin)];
  33. foreach ($tasks as $task) {
  34. if ($task['status']==='cancelled') throw new ApiException('已取消任务不能规划');
  35. if ((float)$task['origin_longitude']!==$origin['longitude'] || (float)$task['origin_latitude']!==$origin['latitude']) throw new ApiException('各站点须使用同一出发点');
  36. $point=WaterDispatch::coordinates($task);
  37. if ($point['longitude']===null) throw new ApiException('部分配送站点缺少坐标');
  38. $points[]=array_values($point);
  39. }
  40. if (json_encode($data['points']??[])!==json_encode($points)) {
  41. // JSON整数/小数表示可不同;逐项按数值比较,数量必须完全一致。
  42. if (!is_array($data['points']??null) || count($data['points'])!==count($points)) throw new ApiException('坐标已变化,请重新读取任务');
  43. foreach ($points as $i=>$point) if (!is_array($data['points'][$i]) || count($data['points'][$i])!==2 || !is_numeric($data['points'][$i][0]??null) || !is_numeric($data['points'][$i][1]??null) || abs((float)$data['points'][$i][0]-$point[0])>0.0000001 || abs((float)$data['points'][$i][1]-$point[1])>0.0000001) throw new ApiException('坐标已变化,请重新读取任务');
  44. }
  45. $departure=(string)($data['departure_at']??'');
  46. $service=WaterBusiness::positive($data['service_minutes']??0,'配送停留分钟',true);
  47. if (!is_array($data['matrix']??null)) throw new ApiException('缺少高德道路数据');
  48. $result=(new RouteOptimizer())->optimize($tasks,$data['matrix'],(string)($data['strategy']??''),$departure,$service);
  49. $result['points']=$points;
  50. $result['source']='amap_browser';
  51. $result['measured_at']=date('Y-m-d H:i:s');
  52. $token=bin2hex(random_bytes(32));
  53. WaterRoutePreview::query()->insert(['token'=>$token,'actor_id'=>$actor,'tasks_hash'=>self::fingerprint($tasks),
  54. 'payload'=>json_encode($result,JSON_UNESCAPED_UNICODE),'expires_at'=>date('Y-m-d H:i:s',time()+600)]);
  55. return $result+['token'=>$token];
  56. }
  57. public function validated(string $token, array $tasks, int $actor): array
  58. {
  59. $preview=WaterRoutePreview::query()->where('token',$token)->where('actor_id',$actor)->first();
  60. if (!$preview || $preview->expires_at<date('Y-m-d H:i:s')) throw new ApiException('优化方案已过期,请重新计算');
  61. if (!hash_equals($preview->tasks_hash,self::fingerprint($tasks))) throw new ApiException('任务或站序已变化,请重新计算优化方案');
  62. return json_decode($preview->payload,true,512,JSON_THROW_ON_ERROR)+['token'=>$token];
  63. }
  64. public function apply(array $data, int $actor): array
  65. {
  66. $id=WaterBusiness::positive($data['route_id']??null,'路线');
  67. return WaterModel::transaction(function () use ($data,$actor,$id) {
  68. $route=WaterRoute::query()->find($id);
  69. if (!$route) throw new ApiException('路线不存在');
  70. $skuIds=WaterDelivery::query()->where('route_id',$id)->pluck('sku_id')->all();
  71. WaterSku::query()->whereIn('id',$skuIds)->orderBy('id')->lockForUpdate()->get();
  72. WaterDriver::query()->where('id',$route->driver_id)->lockForUpdate()->first();
  73. $tasks=WaterDelivery::query()->where('route_id',$id)->orderBy('stop_order')->lockForUpdate()->get()->toArray();
  74. $route=WaterRoute::query()->where('id',$id)->lockForUpdate()->first();
  75. $previous=json_decode($route->optimization??'null',true);
  76. if (!empty($data['token']) && ($previous['token']??null)===$data['token']) return ['id'=>$id,'replayed'=>true];
  77. foreach ($tasks as $task) if ($task['status']!=='assigned') throw new ApiException('仅全部尚未出库的已派单路线可修改站序');
  78. $plan=$this->validated((string)($data['token']??''),$tasks,$actor);
  79. if (substr($plan['departure_at'],0,10)!==$route->work_date) throw new ApiException('出发日期须与配送日期一致');
  80. foreach ($plan['optimized']['task_ids'] as $order=>$taskId) WaterDelivery::query()->where('id',$taskId)->update(['stop_order'=>$order+1]);
  81. WaterRoute::query()->where('id',$id)->update(['optimization'=>json_encode($plan,JSON_UNESCAPED_UNICODE)]);
  82. WaterLedger::query()->insert(['kind'=>'dispatch_config','reference'=>'route:'.$id,'note'=>'应用线路优化策略:'.$plan['strategy'],'created_by'=>$actor,'created_at'=>date('Y-m-d H:i:s'),'quantity'=>0]);
  83. return ['id'=>$id];
  84. });
  85. }
  86. }