water_dispatch_test.php 9.1 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495
  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 support\Db;
  7. use plugin\saiadmin\app\model\water\WaterModel;
  8. use plugin\saiadmin\app\service\WaterBusiness;
  9. use plugin\saiadmin\app\service\WaterDispatch;
  10. use plugin\saiadmin\exception\ApiException;
  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. $service=new WaterDispatch(); $business=new WaterBusiness(); $tag='DISPATCH-'.bin2hex(random_bytes(5)); $date=date('Y-m-d');
  15. $before=\plugin\saiadmin\app\model\water\WaterDelivery::query()->count();
  16. Db::beginTransaction();
  17. try {
  18. $sku=$business->save('sku',['name'=>$tag,'liters'=>18.9],1)['id'];
  19. $business->stock(['sku_id'=>$sku,'mode'=>'inbound','quantity'=>100,'reference'=>$tag,'note'=>'test'],1);
  20. $tasks=[]; $enterpriseIds=[];
  21. foreach ([4,6,8] as $i=>$qty) {
  22. $e=$business->save('enterprise',['name'=>$tag.'-'.$i,'contact'=>'测试','phone'=>'TEST','address'=>'测试地址','region'=>$tag,'longitude'=>121.5+$i*0.01,'latitude'=>31.2],1)['id'];
  23. $enterpriseIds[]=$e;
  24. \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')]);
  25. $tasks[]=$business->createDelivery(['enterprise_id'=>$e,'sku_id'=>$sku,'quantity'=>$qty],1)['id'];
  26. }
  27. $driver=$service->saveDriver(['name'=>$tag,'phone'=>$tag,'region'=>$tag,'capacity'=>10],1)['id'];
  28. $driver2=$service->saveDriver(['name'=>$tag.'b','phone'=>$tag.'b','region'=>$tag,'capacity'=>6],1)['id'];
  29. check($service->board(['date'=>$date,'keyword'=>$tag])['summary']['tasks']===3,'board tasks from existing delivery');
  30. check($service->board(['date'=>$date,'keyword'=>$tag])['summary']['buckets']===18,'board quantities from whole filtered scope');
  31. check($service->board(['date'=>$date,'keyword'=>$tag.'-1'])['summary']['tasks']===1,'enterprise search');
  32. check($service->board(['date'=>$date,'region'=>$tag])['summary']['tasks']===3,'region search');
  33. check($service->board(['date'=>$date,'keyword'=>$tag])['tasks'][0]['longitude']!==null,'enterprise location snapshotted');
  34. $service->shift(['driver_id'=>$driver,'date'=>$date,'state'=>'on'],1);
  35. $service->shift(['driver_id'=>$driver2,'date'=>$date,'state'=>'rest'],1);
  36. $service->schedule(['id'=>$tasks[1],'date'=>$date,'deadline_at'=>$date.' 09:00:00','longitude'=>121.51,'latitude'=>31.2],1);
  37. $plan=$service->plan(['date'=>$date,'keyword'=>$tag]);
  38. check(count($plan['routes'])===1 && $plan['routes'][0]['buckets']===10,'capacity bounded regional merge');
  39. check($plan['routes'][0]['task_ids']===[$tasks[1],$tasks[0]],'deadline prioritized');
  40. check(count($plan['unplanned'])===1 && (int)$plan['unplanned'][0]['id']===$tasks[2],'oversized remainder explained');
  41. $request=['date'=>$date,'request_key'=>$tag,'routes'=>[['driver_id'=>$driver,'task_ids'=>$plan['routes'][0]['task_ids']]]];
  42. rejects(fn()=>$service->commit(['date'=>$date,'request_key'=>$tag.'too','routes'=>[['driver_id'=>$driver,'task_ids'=>$tasks]]],1),'overcapacity rejected');
  43. rejects(fn()=>$service->commit(['date'=>$date,'request_key'=>$tag.'rest','routes'=>[['driver_id'=>$driver2,'task_ids'=>[$tasks[0]]]]],1),'resting driver rejected');
  44. $result=$service->commit($request,1); $routeId=$result['route_ids'][0];
  45. 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');
  46. check(count($service->route(['id'=>$routeId])['tasks'])===2,'complete route detail');
  47. check($service->commit($request,1)['replayed']===true,'same request idempotent');
  48. rejects(fn()=>$service->commit(['date'=>$date,'request_key'=>$tag,'routes'=>[['driver_id'=>$driver,'task_ids'=>[$tasks[2]]]]],1),'request key cannot reuse different tasks');
  49. rejects(fn()=>$service->commit(['date'=>$date,'request_key'=>$tag.'stale','routes'=>$request['routes']],1),'stale proposal rejected');
  50. $skuRow=\plugin\saiadmin\app\model\water\WaterSku::query()->where('id',$sku)->first()?->toArray();
  51. check((int)$skuRow['locked']===18 && (int)$skuRow['available']===82,'merge does not freeze stock twice');
  52. check(\plugin\saiadmin\app\model\water\WaterDelivery::query()->where('route_id',$routeId)->where('status','assigned')->count()===2,'merge assigns tasks');
  53. $drivers=array_column($service->drivers($date),null,'id');
  54. check($drivers[$driver]['state']==='busy' && $drivers[$driver]['buckets']===10,'driver load linked to tasks');
  55. rejects(fn()=>$service->shift(['driver_id'=>$driver,'date'=>$date,'state'=>'rest'],1),'cannot rest while delivering');
  56. rejects(fn()=>$service->saveDriver(['id'=>$driver,'name'=>$tag,'phone'=>$tag,'region'=>$tag,'capacity'=>9],1),'cannot change capacity while delivering');
  57. rejects(fn()=>$service->schedule(['id'=>$tasks[0],'date'=>$date],1),'assigned schedule immutable');
  58. $business->transition(['id'=>$tasks[1],'action'=>'depart'],1);
  59. check((int)\plugin\saiadmin\app\model\water\WaterSku::query()->where('id',$sku)->value('transit')===6,'depart updates warehouse transit');
  60. $business->transition(['id'=>$tasks[1],'action'=>'complete','empty_returned'=>0],1);
  61. $account=\plugin\saiadmin\app\model\water\WaterAccount::query()->where('enterprise_id',$enterpriseIds[1])->first()?->toArray();
  62. check((int)$account['entitlement']===14 && (int)$account['full_qty']===6 && (int)$account['frozen']===0,'completion synchronizes entitlement and site stock');
  63. check($business->listing('alerts',['keyword'=>$tag.'-1'])['total']===0,'completion resolves quantity alert');
  64. check($service->board(['date'=>$date,'keyword'=>$tag])['summary']['completed']===1,'board completion summary synchronized');
  65. $business->transition(['id'=>$tasks[0],'action'=>'cancel'],1);
  66. check((int)\plugin\saiadmin\app\model\water\WaterSku::query()->where('id',$sku)->value('available')===86,'cancel releases warehouse stock');
  67. $drivers=array_column($service->drivers($date),null,'id');
  68. check($drivers[$driver]['state']==='busy','driver remains responsible until warehouse settlement');
  69. (new \plugin\saiadmin\app\service\WaterFulfillment())->settleRoute(['route_id'=>$routeId,'items'=>[['sku_id'=>$sku,'actual'=>0,'lost'=>0]],'note'=>'test return handover'],1);
  70. $drivers=array_column($service->drivers($date),null,'id');
  71. check($drivers[$driver]['state']==='available','driver available after route settlement');
  72. rejects(fn()=>$service->commit(['date'=>$date,'request_key'=>$tag.'duplicate','routes'=>[['driver_id'=>$driver,'task_ids'=>[$tasks[2],$tasks[2]]]]],1),'duplicate tasks rejected');
  73. $service->shift(['driver_id'=>$driver2,'date'=>$date,'state'=>'on'],1);
  74. $extra=$business->createDelivery(['enterprise_id'=>$enterpriseIds[0],'sku_id'=>$sku,'quantity'=>2],1)['id'];
  75. $routesBefore=\plugin\saiadmin\app\model\water\WaterRoute::query()->count();
  76. 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');
  77. 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');
  78. $business->transition(['id'=>$extra,'action'=>'cancel'],1);
  79. check($service->commit($request,1)['replayed']===true,'completed request replay no duplicate assignment');
  80. $future=date('Y-m-d',strtotime('+1 day'));
  81. $service->schedule(['id'=>$tasks[2],'date'=>$future],1);
  82. check($service->board(['date'=>$date,'keyword'=>$tag])['summary']['tasks']===0,'future task excluded from today');
  83. $service->shift(['driver_id'=>$driver,'date'=>$future,'state'=>'on'],1);
  84. $service->commit(['date'=>$future,'request_key'=>$tag.'future','routes'=>[['driver_id'=>$driver,'task_ids'=>[$tasks[2]]]]],1);
  85. rejects(fn()=>$business->transition(['id'=>$tasks[2],'action'=>'depart'],1),'future route cannot depart early');
  86. foreach ([['date'=>'2026-02-30'],['date'=>'bad']] as $invalid) rejects(fn()=>$service->board($invalid),'invalid date rejected');
  87. rejects(fn()=>WaterDispatch::coordinates(['longitude'=>181,'latitude'=>20]),'invalid coordinates rejected');
  88. rejects(fn()=>WaterDispatch::coordinates(['longitude'=>121]),'unpaired coordinates rejected');
  89. $reflection=new ReflectionClass(\plugin\saiadmin\app\controller\water\WaterController::class);
  90. $controller=$reflection->newInstanceWithoutConstructor(); $reflection->getProperty('adminId')->setValue($controller,0);
  91. $http=new \support\Request("GET /core/water/dispatch/board HTTP/1.1\r\nHost: localhost\r\n\r\n");
  92. foreach (['dispatchBoard','dispatchPlan','dispatchRoute'] as $method) rejects(fn()=>$controller->$method($http),'dispatch endpoint requires authorization');
  93. } finally { Db::rollback(); }
  94. check(\plugin\saiadmin\app\model\water\WaterDelivery::query()->count()===$before,'fixtures rolled back');
  95. echo "PASS: $checks dispatch checks; all fixtures rolled back.\n";