load(); \support\App::loadAllConfig(['route']); date_default_timezone_set('Asia/Shanghai'); use support\Db; use plugin\saiadmin\app\model\water\WaterModel; use plugin\saiadmin\app\service\WaterBusiness; use plugin\saiadmin\app\service\WaterDispatch; use plugin\saiadmin\exception\ApiException; $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); } $service=new WaterDispatch(); $business=new WaterBusiness(); $tag='DISPATCH-'.bin2hex(random_bytes(5)); $date=date('Y-m-d'); $before=\plugin\saiadmin\app\model\water\WaterDelivery::query()->count(); Db::beginTransaction(); try { $sku=$business->save('sku',['name'=>$tag,'liters'=>18.9],1)['id']; $business->stock(['sku_id'=>$sku,'mode'=>'inbound','quantity'=>100,'reference'=>$tag,'note'=>'test'],1); $tasks=[]; $enterpriseIds=[]; foreach ([4,6,8] as $i=>$qty) { $e=$business->save('enterprise',['name'=>$tag.'-'.$i,'contact'=>'测试','phone'=>'TEST','address'=>'测试地址','region'=>$tag,'longitude'=>121.5+$i*0.01,'latitude'=>31.2],1)['id']; $enterpriseIds[]=$e; \plugin\saiadmin\app\model\water\WaterAccount::query()->insert(['enterprise_id'=>$e,'sku_id'=>$sku,'entitlement'=>20,'threshold_qty'=>2,'updated_at'=>date('Y-m-d H:i:s')]); $tasks[]=$business->createDelivery(['enterprise_id'=>$e,'sku_id'=>$sku,'quantity'=>$qty],1)['id']; } $driver=$service->saveDriver(['name'=>$tag,'phone'=>$tag,'region'=>$tag,'capacity'=>10],1)['id']; $driver2=$service->saveDriver(['name'=>$tag.'b','phone'=>$tag.'b','region'=>$tag,'capacity'=>6],1)['id']; check($service->board(['date'=>$date,'keyword'=>$tag])['summary']['tasks']===3,'board tasks from existing delivery'); check($service->board(['date'=>$date,'keyword'=>$tag])['summary']['buckets']===18,'board quantities from whole filtered scope'); check($service->board(['date'=>$date,'keyword'=>$tag.'-1'])['summary']['tasks']===1,'enterprise search'); check($service->board(['date'=>$date,'region'=>$tag])['summary']['tasks']===3,'region search'); check($service->board(['date'=>$date,'keyword'=>$tag])['tasks'][0]['longitude']!==null,'enterprise location snapshotted'); $service->shift(['driver_id'=>$driver,'date'=>$date,'state'=>'on'],1); $service->shift(['driver_id'=>$driver2,'date'=>$date,'state'=>'rest'],1); $service->schedule(['id'=>$tasks[1],'date'=>$date,'deadline_at'=>$date.' 09:00:00','longitude'=>121.51,'latitude'=>31.2],1); $plan=$service->plan(['date'=>$date,'keyword'=>$tag]); check(count($plan['routes'])===1 && $plan['routes'][0]['buckets']===10,'capacity bounded regional merge'); check($plan['routes'][0]['task_ids']===[$tasks[1],$tasks[0]],'deadline prioritized'); check(count($plan['unplanned'])===1 && (int)$plan['unplanned'][0]['id']===$tasks[2],'oversized remainder explained'); $request=['date'=>$date,'request_key'=>$tag,'routes'=>[['driver_id'=>$driver,'task_ids'=>$plan['routes'][0]['task_ids']]]]; rejects(fn()=>$service->commit(['date'=>$date,'request_key'=>$tag.'too','routes'=>[['driver_id'=>$driver,'task_ids'=>$tasks]]],1),'overcapacity rejected'); rejects(fn()=>$service->commit(['date'=>$date,'request_key'=>$tag.'rest','routes'=>[['driver_id'=>$driver2,'task_ids'=>[$tasks[0]]]]],1),'resting driver rejected'); $result=$service->commit($request,1); $routeId=$result['route_ids'][0]; check(count($service->board(['date'=>$date,'keyword'=>$tag.'-1'])['tasks'])===1 && count($service->route(['id'=>$routeId])['tasks'])===2,'route detail preserves stations excluded by keyword'); check(count($service->route(['id'=>$routeId])['tasks'])===2,'complete route detail'); check($service->commit($request,1)['replayed']===true,'same request idempotent'); rejects(fn()=>$service->commit(['date'=>$date,'request_key'=>$tag,'routes'=>[['driver_id'=>$driver,'task_ids'=>[$tasks[2]]]]],1),'request key cannot reuse different tasks'); rejects(fn()=>$service->commit(['date'=>$date,'request_key'=>$tag.'stale','routes'=>$request['routes']],1),'stale proposal rejected'); $skuRow=\plugin\saiadmin\app\model\water\WaterSku::query()->where('id',$sku)->first()?->toArray(); check((int)$skuRow['locked']===18 && (int)$skuRow['available']===82,'merge does not freeze stock twice'); check(\plugin\saiadmin\app\model\water\WaterDelivery::query()->where('route_id',$routeId)->where('status','assigned')->count()===2,'merge assigns tasks'); $drivers=array_column($service->drivers($date),null,'id'); check($drivers[$driver]['state']==='busy' && $drivers[$driver]['buckets']===10,'driver load linked to tasks'); rejects(fn()=>$service->shift(['driver_id'=>$driver,'date'=>$date,'state'=>'rest'],1),'cannot rest while delivering'); rejects(fn()=>$service->saveDriver(['id'=>$driver,'name'=>$tag,'phone'=>$tag,'region'=>$tag,'capacity'=>9],1),'cannot change capacity while delivering'); rejects(fn()=>$service->schedule(['id'=>$tasks[0],'date'=>$date],1),'assigned schedule immutable'); $business->transition(['id'=>$tasks[1],'action'=>'depart'],1); check((int)\plugin\saiadmin\app\model\water\WaterSku::query()->where('id',$sku)->value('transit')===6,'depart updates warehouse transit'); $business->transition(['id'=>$tasks[1],'action'=>'complete','empty_returned'=>0],1); $account=\plugin\saiadmin\app\model\water\WaterAccount::query()->where('enterprise_id',$enterpriseIds[1])->first()?->toArray(); check((int)$account['entitlement']===14 && (int)$account['full_qty']===6 && (int)$account['frozen']===0,'completion synchronizes entitlement and site stock'); check($business->listing('alerts',['keyword'=>$tag.'-1'])['total']===0,'completion resolves quantity alert'); check($service->board(['date'=>$date,'keyword'=>$tag])['summary']['completed']===1,'board completion summary synchronized'); $business->transition(['id'=>$tasks[0],'action'=>'cancel'],1); check((int)\plugin\saiadmin\app\model\water\WaterSku::query()->where('id',$sku)->value('available')===86,'cancel releases warehouse stock'); $drivers=array_column($service->drivers($date),null,'id'); check($drivers[$driver]['state']==='busy','driver remains responsible until warehouse settlement'); (new \plugin\saiadmin\app\service\WaterFulfillment())->settleRoute(['route_id'=>$routeId,'items'=>[['sku_id'=>$sku,'actual'=>0,'lost'=>0]],'note'=>'test return handover'],1); $drivers=array_column($service->drivers($date),null,'id'); check($drivers[$driver]['state']==='available','driver available after route settlement'); rejects(fn()=>$service->commit(['date'=>$date,'request_key'=>$tag.'duplicate','routes'=>[['driver_id'=>$driver,'task_ids'=>[$tasks[2],$tasks[2]]]]],1),'duplicate tasks rejected'); $service->shift(['driver_id'=>$driver2,'date'=>$date,'state'=>'on'],1); $extra=$business->createDelivery(['enterprise_id'=>$enterpriseIds[0],'sku_id'=>$sku,'quantity'=>2],1)['id']; $routesBefore=\plugin\saiadmin\app\model\water\WaterRoute::query()->count(); rejects(fn()=>$service->commit(['date'=>$date,'request_key'=>$tag.'atomic','routes'=>[['driver_id'=>$driver,'task_ids'=>[$extra]],['driver_id'=>$driver2,'task_ids'=>[$tasks[2]]]]],1),'later group overcapacity aborts all routes'); check(\plugin\saiadmin\app\model\water\WaterDelivery::query()->where('id',$extra)->value('status')==='pending' && \plugin\saiadmin\app\model\water\WaterRoute::query()->count()===$routesBefore,'failed multi-route commit rolls back first assignment and route'); $business->transition(['id'=>$extra,'action'=>'cancel'],1); check($service->commit($request,1)['replayed']===true,'completed request replay no duplicate assignment'); $future=date('Y-m-d',strtotime('+1 day')); $service->schedule(['id'=>$tasks[2],'date'=>$future],1); check($service->board(['date'=>$date,'keyword'=>$tag])['summary']['tasks']===0,'future task excluded from today'); $service->shift(['driver_id'=>$driver,'date'=>$future,'state'=>'on'],1); $service->commit(['date'=>$future,'request_key'=>$tag.'future','routes'=>[['driver_id'=>$driver,'task_ids'=>[$tasks[2]]]]],1); rejects(fn()=>$business->transition(['id'=>$tasks[2],'action'=>'depart'],1),'future route cannot depart early'); foreach ([['date'=>'2026-02-30'],['date'=>'bad']] as $invalid) rejects(fn()=>$service->board($invalid),'invalid date rejected'); rejects(fn()=>WaterDispatch::coordinates(['longitude'=>181,'latitude'=>20]),'invalid coordinates rejected'); rejects(fn()=>WaterDispatch::coordinates(['longitude'=>121]),'unpaired coordinates rejected'); $reflection=new ReflectionClass(\plugin\saiadmin\app\controller\water\WaterController::class); $controller=$reflection->newInstanceWithoutConstructor(); $reflection->getProperty('adminId')->setValue($controller,0); $http=new \support\Request("GET /core/water/dispatch/board HTTP/1.1\r\nHost: localhost\r\n\r\n"); foreach (['dispatchBoard','dispatchPlan','dispatchRoute'] as $method) rejects(fn()=>$controller->$method($http),'dispatch endpoint requires authorization'); } finally { Db::rollback(); } check(\plugin\saiadmin\app\model\water\WaterDelivery::query()->count()===$before,'fixtures rolled back'); echo "PASS: $checks dispatch checks; all fixtures rolled back.\n";