| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091 |
- <?php
- declare(strict_types=1);
- namespace plugin\saiadmin\app\service;
- use plugin\saiadmin\app\model\water\{WaterDelivery,WaterRoute,WaterRoutePreview,WaterSku,WaterDriver,WaterLedger,WaterModel};
- use plugin\saiadmin\app\service\routing\RouteOptimizer;
- use plugin\saiadmin\exception\ApiException;
- final class WaterRoutePlanning
- {
- public static function fingerprint(array $tasks): string
- {
- usort($tasks, fn($a,$b)=>(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_at<date('Y-m-d H:i:s')) throw new ApiException('优化方案已过期,请重新计算');
- if (!hash_equals($preview->tasks_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];
- });
- }
- }
|