load(); \support\App::loadAllConfig(['route']); date_default_timezone_set('Asia/Shanghai'); use plugin\saiadmin\app\service\routing\RouteOptimizer; use plugin\saiadmin\app\service\{WaterRoutePlanning,WaterDispatch,WaterBusiness}; use plugin\saiadmin\app\model\water\{WaterDelivery,WaterRoute,WaterAccount,WaterSku,WaterRoutePreview}; use plugin\saiadmin\exception\ApiException; use support\Db; $checks=0; function check(bool $ok,string $message): void { global $checks; $checks++; if (!$ok) throw new RuntimeException($message); } function rejects(callable $fn,string $message): void { try { $fn(); } catch (ApiException $e) { check(true,$message); return; } throw new RuntimeException($message); } $date=date('Y-m-d'); $departure=$date.' 08:00:00'; $tasks=[['id'=>1],['id'=>2],['id'=>3,'deadline_at'=>$date.' 08:00:02']]; $matrix=array_fill(0,4,array_fill(0,4,['distance'=>9,'time'=>9])); 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'); $engine=new RouteOptimizer(); $short=$engine->optimize($tasks,$matrix,'shortest',$departure,0); $fast=$engine->optimize($tasks,$matrix,'fastest',$departure,0); $deadline=$engine->optimize($tasks,$matrix,'deadline',$departure,0); check($short['optimized']['task_ids']===[1,2,3],'shortest distance objective'); check($fast['optimized']['task_ids']===[2,1,3],'fastest completion objective'); check($deadline['optimized']['task_ids']===[3,2,1],'deadline objective differs from distance and time'); check($deadline['optimized']['late_count']===0 && $fast['optimized']['late_count']===1,'deadline lateness uses actual path duration'); check($engine->optimize($tasks,$matrix,'deadline',$departure,1)['optimized']['late_count']===1,'service time included in deadline calculation'); $blocked=$matrix; foreach ($blocked as &$row) $row[3]=null; unset($row); rejects(fn()=>$engine->optimize($tasks,$blocked,'shortest',$departure,0),'unreachable station fails complete route'); $bad=$matrix; $bad[0][1]['time']=-1; rejects(fn()=>$engine->optimize($tasks,$bad,'fastest',$departure,0),'negative costs rejected'); rejects(fn()=>$engine->optimize($tasks,$matrix,'unknown',$departure,0),'strategy allowlist'); rejects(fn()=>$engine->optimize($tasks,[],'shortest',$departure,0),'matrix dimensions'); rejects(fn()=>$engine->optimize(array_fill(0,9,['id'=>1]),[],'shortest',$departure,0),'bounded exact optimization'); $zero=$engine->optimize([['id'=>1]],[[null,['distance'=>0,'time'=>0]],[null,null]],'shortest',$departure,0); check($zero['optimized']['distance']==0 && $zero['optimized']['task_ids']===[1],'same-coordinate station retained'); $before=WaterDelivery::query()->count(); $previews=WaterRoutePreview::query()->count(); Db::beginTransaction(); try { $tag='ROUTING-'.bin2hex(random_bytes(5)); $business=new WaterBusiness(); $dispatch=new WaterDispatch(); $planner=new WaterRoutePlanning(); $sku=$business->save('sku',['name'=>$tag,'liters'=>18.9],1)['id']; $business->stock(['sku_id'=>$sku,'mode'=>'inbound','quantity'=>30,'reference'=>$tag,'note'=>'rollback fixture'],1); $ids=[]; $points=[[113.92,22.52]]; foreach ([1,2,3] as $i) { $point=[113.93+$i/1000,22.53]; $points[]=$point; $enterprise=$business->save('enterprise',['name'=>$tag.$i,'contact'=>'测试','phone'=>'TEST','region'=>$tag,'address'=>'测试','longitude'=>$point[0],'latitude'=>$point[1]],1)['id']; WaterAccount::query()->insert(['enterprise_id'=>$enterprise,'sku_id'=>$sku,'entitlement'=>10,'updated_at'=>date('Y-m-d H:i:s')]); $id=$business->createDelivery(['enterprise_id'=>$enterprise,'sku_id'=>$sku,'quantity'=>2],1)['id']; $dispatch->schedule(['id'=>$id,'date'=>$date,'longitude'=>$point[0],'latitude'=>$point[1],'origin_longitude'=>113.92,'origin_latitude'=>22.52,'origin_address'=>'共同出发点'],1); $ids[]=$id; } $payload=['task_ids'=>$ids,'points'=>$points,'matrix'=>$matrix,'strategy'=>'fastest','departure_at'=>$departure,'service_minutes'=>0]; $plan=$planner->preview($payload,1); check($plan['optimized']['task_ids']===[$ids[1],$ids[0],$ids[2]],'model records mapped to optimizer'); $rows=WaterDelivery::query()->whereIn('id',$ids)->get()->toArray(); rejects(fn()=>$planner->validated($plan['token'],$rows,2),'preview bound to actor'); $badPoints=$payload; $badPoints['points'][1][0]+=0.01; rejects(fn()=>$planner->preview($badPoints,1),'stale coordinates rejected'); WaterRoutePreview::query()->where('token',$plan['token'])->update(['expires_at'=>date('Y-m-d H:i:s',time()-1)]); rejects(fn()=>$planner->validated($plan['token'],$rows,1),'expired preview rejected'); $plan=$planner->preview($payload,1); WaterDelivery::query()->where('id',$ids[0])->update(['deadline_at'=>$date.' 09:00:00']); rejects(fn()=>$planner->validated($plan['token'],WaterDelivery::query()->whereIn('id',$ids)->get()->toArray(),1),'deadline change invalidates preview'); WaterDelivery::query()->where('id',$ids[0])->update(['deadline_at'=>null]); $driver=$dispatch->saveDriver(['name'=>$tag,'phone'=>$tag,'region'=>$tag,'capacity'=>20],1)['id']; $dispatch->shift(['driver_id'=>$driver,'date'=>$date,'state'=>'on'],1); $request=['date'=>$date,'request_key'=>$tag,'routes'=>[['driver_id'=>$driver,'task_ids'=>$plan['optimized']['task_ids'],'optimization_token'=>$plan['token']]]]; $routeId=$dispatch->commit($request,1)['route_ids'][0]; check($dispatch->commit($request,1)['replayed']===true,'optimized commit replay'); check(WaterDelivery::query()->where('route_id',$routeId)->orderBy('stop_order')->pluck('id')->all()===$plan['optimized']['task_ids'],'optimized order persisted'); $payload['strategy']='shortest'; $reorder=$planner->preview($payload,1); $stock=WaterSku::query()->find($sku)->toArray(); $accounts=WaterAccount::query()->where('sku_id',$sku)->get()->toArray(); $planner->apply(['route_id'=>$routeId,'token'=>$reorder['token']],1); check(WaterDelivery::query()->where('route_id',$routeId)->orderBy('stop_order')->pluck('id')->all()===$ids,'existing route reordered'); check($planner->apply(['route_id'=>$routeId,'token'=>$reorder['token']],1)['replayed']===true,'reorder replay safe'); check(WaterSku::query()->find($sku)->toArray()===$stock && WaterAccount::query()->where('sku_id',$sku)->get()->toArray()===$accounts,'reorder preserves stock and entitlement'); check(json_decode(WaterRoute::query()->find($routeId)->optimization,true)['strategy']==='shortest','strategy metadata persisted'); $next=$planner->preview($payload,1); $business->transition(['id'=>$ids[0],'action'=>'depart'],1); rejects(fn()=>$planner->apply(['route_id'=>$routeId,'token'=>$next['token']],1),'in-transit route cannot reorder'); } finally { Db::rollBack(); } check(WaterDelivery::query()->count()===$before && WaterRoutePreview::query()->count()===$previews,'all fixtures and previews rolled back'); echo "PASS: $checks routing strategy and model integration checks; fixtures rolled back.\n";