(int)$a['id']<=>(int)$b['id']); $fields=['id','enterprise_id','sku_id','quantity','status','driver_id','route_id','stop_order','scheduled_date','deadline_at','longitude','latitude','origin_longitude','origin_latitude']; return hash('sha256',json_encode(array_map(static function ($task) use ($fields) { return array_map(fn($field)=>isset($task[$field])?(string)$task[$field]:null,$fields); },$tasks))); } public function preview(array $data, int $actor): array { $ids=$data['task_ids']??[]; if (!is_array($ids) || !array_is_list($ids) || !$ids || count($ids)>8) throw new ApiException('请选择1至8个配送站点'); $ids=array_map(fn($id)=>WaterBusiness::positive($id,'配送任务'),$ids); if (count(array_unique($ids))!==count($ids)) throw new ApiException('配送任务不能重复'); $rows=WaterDelivery::query()->whereIn('id',$ids)->get()->keyBy('id'); $tasks=[]; foreach ($ids as $id) { if (!isset($rows[$id])) throw new ApiException('配送任务不存在'); $tasks[]=$rows[$id]->toArray(); } if (count(array_unique(array_column($tasks,'region')))>1) throw new ApiException('请在同一片区内规划路线'); $origin=WaterDispatch::coordinates(['longitude'=>$tasks[0]['origin_longitude'],'latitude'=>$tasks[0]['origin_latitude']]); if ($origin['longitude']===null) throw new ApiException('请先设置配送出发点坐标'); $points=[array_values($origin)]; foreach ($tasks as $task) { if ($task['status']==='cancelled') throw new ApiException('已取消任务不能规划'); if ((float)$task['origin_longitude']!==$origin['longitude'] || (float)$task['origin_latitude']!==$origin['latitude']) throw new ApiException('各站点须使用同一出发点'); $point=WaterDispatch::coordinates($task); if ($point['longitude']===null) throw new ApiException('部分配送站点缺少坐标'); $points[]=array_values($point); } if (json_encode($data['points']??[])!==json_encode($points)) { // JSON整数/小数表示可不同;逐项按数值比较,数量必须完全一致。 if (!is_array($data['points']??null) || count($data['points'])!==count($points)) throw new ApiException('坐标已变化,请重新读取任务'); 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('坐标已变化,请重新读取任务'); } $departure=(string)($data['departure_at']??''); $service=WaterBusiness::positive($data['service_minutes']??0,'配送停留分钟',true); if (!is_array($data['matrix']??null)) throw new ApiException('缺少高德道路数据'); $result=(new RouteOptimizer())->optimize($tasks,$data['matrix'],(string)($data['strategy']??''),$departure,$service); $result['points']=$points; $result['source']='amap_browser'; $result['measured_at']=date('Y-m-d H:i:s'); $token=bin2hex(random_bytes(32)); WaterRoutePreview::query()->insert(['token'=>$token,'actor_id'=>$actor,'tasks_hash'=>self::fingerprint($tasks), 'payload'=>json_encode($result,JSON_UNESCAPED_UNICODE),'expires_at'=>date('Y-m-d H:i:s',time()+600)]); return $result+['token'=>$token]; } public function validated(string $token, array $tasks, int $actor): array { $preview=WaterRoutePreview::query()->where('token',$token)->where('actor_id',$actor)->first(); if (!$preview || $preview->expires_attasks_hash,self::fingerprint($tasks))) throw new ApiException('任务或站序已变化,请重新计算优化方案'); return json_decode($preview->payload,true,512,JSON_THROW_ON_ERROR)+['token'=>$token]; } public function apply(array $data, int $actor): array { $id=WaterBusiness::positive($data['route_id']??null,'路线'); return WaterModel::transaction(function () use ($data,$actor,$id) { $route=WaterRoute::query()->find($id); if (!$route) throw new ApiException('路线不存在'); $skuIds=WaterDelivery::query()->where('route_id',$id)->pluck('sku_id')->all(); WaterSku::query()->whereIn('id',$skuIds)->orderBy('id')->lockForUpdate()->get(); WaterDriver::query()->where('id',$route->driver_id)->lockForUpdate()->first(); $tasks=WaterDelivery::query()->where('route_id',$id)->orderBy('stop_order')->lockForUpdate()->get()->toArray(); $route=WaterRoute::query()->where('id',$id)->lockForUpdate()->first(); $previous=json_decode($route->optimization??'null',true); if (!empty($data['token']) && ($previous['token']??null)===$data['token']) return ['id'=>$id,'replayed'=>true]; foreach ($tasks as $task) if ($task['status']!=='assigned') throw new ApiException('仅全部尚未出库的已派单路线可修改站序'); $plan=$this->validated((string)($data['token']??''),$tasks,$actor); if (substr($plan['departure_at'],0,10)!==$route->work_date) throw new ApiException('出发日期须与配送日期一致'); foreach ($plan['optimized']['task_ids'] as $order=>$taskId) WaterDelivery::query()->where('id',$taskId)->update(['stop_order'=>$order+1]); WaterRoute::query()->where('id',$id)->update(['optimization'=>json_encode($plan,JSON_UNESCAPED_UNICODE)]); 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]); return ['id'=>$id]; }); } }