water_routing_test.php 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384
  1. <?php
  2. require dirname(__DIR__).'/vendor/autoload.php';
  3. \Dotenv\Dotenv::createUnsafeMutable(dirname(__DIR__))->load();
  4. \support\App::loadAllConfig(['route']);
  5. date_default_timezone_set('Asia/Shanghai');
  6. use plugin\saiadmin\app\service\routing\RouteOptimizer;
  7. use plugin\saiadmin\app\service\{WaterRoutePlanning,WaterDispatch,WaterBusiness};
  8. use plugin\saiadmin\app\model\water\{WaterDelivery,WaterRoute,WaterAccount,WaterSku,WaterRoutePreview};
  9. use plugin\saiadmin\exception\ApiException;
  10. use support\Db;
  11. $checks=0;
  12. function check(bool $ok,string $message): void { global $checks; $checks++; if (!$ok) throw new RuntimeException($message); }
  13. function rejects(callable $fn,string $message): void { try { $fn(); } catch (ApiException $e) { check(true,$message); return; } throw new RuntimeException($message); }
  14. $date=date('Y-m-d'); $departure=$date.' 08:00:00';
  15. $tasks=[['id'=>1],['id'=>2],['id'=>3,'deadline_at'=>$date.' 08:00:02']];
  16. $matrix=array_fill(0,4,array_fill(0,4,['distance'=>9,'time'=>9]));
  17. foreach ([[0,1,1,2],[1,2,1,2],[2,3,1,2],[0,2,3,1],[2,1,3,1],[1,3,3,1],[0,3,4,1],[3,2,4,2],[3,1,4,3]] as [$a,$b,$distance,$time]) $matrix[$a][$b]=compact('distance','time');
  18. $engine=new RouteOptimizer();
  19. $short=$engine->optimize($tasks,$matrix,'shortest',$departure,0);
  20. $fast=$engine->optimize($tasks,$matrix,'fastest',$departure,0);
  21. $deadline=$engine->optimize($tasks,$matrix,'deadline',$departure,0);
  22. check($short['optimized']['task_ids']===[1,2,3],'shortest distance objective');
  23. check($fast['optimized']['task_ids']===[2,1,3],'fastest completion objective');
  24. check($deadline['optimized']['task_ids']===[3,2,1],'deadline objective differs from distance and time');
  25. check($deadline['optimized']['late_count']===0 && $fast['optimized']['late_count']===1,'deadline lateness uses actual path duration');
  26. check($engine->optimize($tasks,$matrix,'deadline',$departure,1)['optimized']['late_count']===1,'service time included in deadline calculation');
  27. $blocked=$matrix; foreach ($blocked as &$row) $row[3]=null; unset($row);
  28. rejects(fn()=>$engine->optimize($tasks,$blocked,'shortest',$departure,0),'unreachable station fails complete route');
  29. $bad=$matrix; $bad[0][1]['time']=-1;
  30. rejects(fn()=>$engine->optimize($tasks,$bad,'fastest',$departure,0),'negative costs rejected');
  31. rejects(fn()=>$engine->optimize($tasks,$matrix,'unknown',$departure,0),'strategy allowlist');
  32. rejects(fn()=>$engine->optimize($tasks,[],'shortest',$departure,0),'matrix dimensions');
  33. rejects(fn()=>$engine->optimize(array_fill(0,9,['id'=>1]),[],'shortest',$departure,0),'bounded exact optimization');
  34. $zero=$engine->optimize([['id'=>1]],[[null,['distance'=>0,'time'=>0]],[null,null]],'shortest',$departure,0);
  35. check($zero['optimized']['distance']==0 && $zero['optimized']['task_ids']===[1],'same-coordinate station retained');
  36. $before=WaterDelivery::query()->count(); $previews=WaterRoutePreview::query()->count();
  37. Db::beginTransaction();
  38. try {
  39. $tag='ROUTING-'.bin2hex(random_bytes(5)); $business=new WaterBusiness(); $dispatch=new WaterDispatch(); $planner=new WaterRoutePlanning();
  40. $sku=$business->save('sku',['name'=>$tag,'liters'=>18.9],1)['id'];
  41. $business->stock(['sku_id'=>$sku,'mode'=>'inbound','quantity'=>30,'reference'=>$tag,'note'=>'rollback fixture'],1);
  42. $ids=[]; $points=[[113.92,22.52]];
  43. foreach ([1,2,3] as $i) {
  44. $point=[113.93+$i/1000,22.53]; $points[]=$point;
  45. $enterprise=$business->save('enterprise',['name'=>$tag.$i,'contact'=>'测试','phone'=>'TEST','region'=>$tag,'address'=>'测试','longitude'=>$point[0],'latitude'=>$point[1]],1)['id'];
  46. WaterAccount::query()->insert(['enterprise_id'=>$enterprise,'sku_id'=>$sku,'entitlement'=>10,'updated_at'=>date('Y-m-d H:i:s')]);
  47. $id=$business->createDelivery(['enterprise_id'=>$enterprise,'sku_id'=>$sku,'quantity'=>2],1)['id'];
  48. $dispatch->schedule(['id'=>$id,'date'=>$date,'longitude'=>$point[0],'latitude'=>$point[1],'origin_longitude'=>113.92,'origin_latitude'=>22.52,'origin_address'=>'共同出发点'],1);
  49. $ids[]=$id;
  50. }
  51. $payload=['task_ids'=>$ids,'points'=>$points,'matrix'=>$matrix,'strategy'=>'fastest','departure_at'=>$departure,'service_minutes'=>0];
  52. $plan=$planner->preview($payload,1);
  53. check($plan['optimized']['task_ids']===[$ids[1],$ids[0],$ids[2]],'model records mapped to optimizer');
  54. $rows=WaterDelivery::query()->whereIn('id',$ids)->get()->toArray();
  55. rejects(fn()=>$planner->validated($plan['token'],$rows,2),'preview bound to actor');
  56. $badPoints=$payload; $badPoints['points'][1][0]+=0.01;
  57. rejects(fn()=>$planner->preview($badPoints,1),'stale coordinates rejected');
  58. WaterRoutePreview::query()->where('token',$plan['token'])->update(['expires_at'=>date('Y-m-d H:i:s',time()-1)]);
  59. rejects(fn()=>$planner->validated($plan['token'],$rows,1),'expired preview rejected');
  60. $plan=$planner->preview($payload,1);
  61. WaterDelivery::query()->where('id',$ids[0])->update(['deadline_at'=>$date.' 09:00:00']);
  62. rejects(fn()=>$planner->validated($plan['token'],WaterDelivery::query()->whereIn('id',$ids)->get()->toArray(),1),'deadline change invalidates preview');
  63. WaterDelivery::query()->where('id',$ids[0])->update(['deadline_at'=>null]);
  64. $driver=$dispatch->saveDriver(['name'=>$tag,'phone'=>$tag,'region'=>$tag,'capacity'=>20],1)['id'];
  65. $dispatch->shift(['driver_id'=>$driver,'date'=>$date,'state'=>'on'],1);
  66. $request=['date'=>$date,'request_key'=>$tag,'routes'=>[['driver_id'=>$driver,'task_ids'=>$plan['optimized']['task_ids'],'optimization_token'=>$plan['token']]]];
  67. $routeId=$dispatch->commit($request,1)['route_ids'][0];
  68. check($dispatch->commit($request,1)['replayed']===true,'optimized commit replay');
  69. check(WaterDelivery::query()->where('route_id',$routeId)->orderBy('stop_order')->pluck('id')->all()===$plan['optimized']['task_ids'],'optimized order persisted');
  70. $payload['strategy']='shortest'; $reorder=$planner->preview($payload,1);
  71. $stock=WaterSku::query()->find($sku)->toArray();
  72. $accounts=WaterAccount::query()->where('sku_id',$sku)->get()->toArray();
  73. $planner->apply(['route_id'=>$routeId,'token'=>$reorder['token']],1);
  74. check(WaterDelivery::query()->where('route_id',$routeId)->orderBy('stop_order')->pluck('id')->all()===$ids,'existing route reordered');
  75. check($planner->apply(['route_id'=>$routeId,'token'=>$reorder['token']],1)['replayed']===true,'reorder replay safe');
  76. check(WaterSku::query()->find($sku)->toArray()===$stock && WaterAccount::query()->where('sku_id',$sku)->get()->toArray()===$accounts,'reorder preserves stock and entitlement');
  77. check(json_decode(WaterRoute::query()->find($routeId)->optimization,true)['strategy']==='shortest','strategy metadata persisted');
  78. $next=$planner->preview($payload,1);
  79. $business->transition(['id'=>$ids[0],'action'=>'depart'],1);
  80. rejects(fn()=>$planner->apply(['route_id'=>$routeId,'token'=>$next['token']],1),'in-transit route cannot reorder');
  81. } finally { Db::rollBack(); }
  82. check(WaterDelivery::query()->count()===$before && WaterRoutePreview::query()->count()===$previews,'all fixtures and previews rolled back');
  83. echo "PASS: $checks routing strategy and model integration checks; fixtures rolled back.\n";