water_fulfillment_test.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137
  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\model\water\{WaterSku,WaterAccount,WaterEnterprise,WaterDelivery,WaterRoute,WaterDeliveryEvent,WaterOrder};
  7. use plugin\saiadmin\app\service\{WaterBusiness,WaterDispatch,WaterFulfillment};
  8. use plugin\saiadmin\exception\ApiException;
  9. $checks=0;
  10. function check(bool $ok,string $message): void { global $checks; $checks++; if (!$ok) throw new RuntimeException($message); }
  11. function rejects(callable $fn,string $message): void { try { $fn(); } catch (ApiException $e) { check(true,$message); return; } throw new RuntimeException($message); }
  12. $db=(new WaterSku())->getConnection();
  13. $before=[WaterDelivery::query()->count(),WaterDeliveryEvent::query()->count(),WaterOrder::query()->count()];
  14. $business=new WaterBusiness(); $dispatch=new WaterDispatch(); $flow=new WaterFulfillment(); $date=date('Y-m-d');
  15. $tag='FULFILL-'.bin2hex(random_bytes(5));
  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=[]; $accounts=[];
  21. foreach ([10,5] as $i=>$qty) {
  22. $e=$business->save('enterprise',['name'=>$tag.$i,'contact'=>'test','phone'=>'test','address'=>'test','region'=>$tag],1)['id'];
  23. $accounts[]=WaterAccount::query()->insertGetId(['enterprise_id'=>$e,'sku_id'=>$sku,'entitlement'=>20,'empty_qty'=>5,'updated_at'=>date('Y-m-d H:i:s')]);
  24. $tasks[]=$business->createDelivery(['enterprise_id'=>$e,'sku_id'=>$sku,'quantity'=>$qty,'date'=>$date],1)['id'];
  25. }
  26. $driver=$dispatch->saveDriver(['name'=>$tag,'phone'=>$tag,'region'=>$tag,'capacity'=>30],1)['id'];
  27. $dispatch->shift(['driver_id'=>$driver,'date'=>$date,'state'=>'on'],1);
  28. $route=$dispatch->commit(['date'=>$date,'request_key'=>$tag,'routes'=>[['driver_id'=>$driver,'task_ids'=>$tasks]]],1)['route_ids'][0];
  29. $receive=['id'=>$tasks[0],'accepted_qty'=>6,'empty_returned'=>3,'receiver'=>'test receiver','reason'=>'4桶客户拒收'];
  30. rejects(fn()=>$flow->receive($receive,1),'cannot receive before departure');
  31. $business->transition(['id'=>$tasks[0],'action'=>'depart'],1);
  32. $business->transition(['id'=>$tasks[1],'action'=>'depart'],1);
  33. rejects(fn()=>$flow->receive(array_replace($receive,['accepted_qty'=>11]),1),'overdelivery rejected');
  34. rejects(fn()=>$flow->receive(array_replace($receive,['reason'=>'']),1),'partial receipt needs reason');
  35. rejects(fn()=>$flow->receive(array_replace($receive,['empty_returned'=>6]),1),'excess empty collection rejected');
  36. rejects(fn()=>$flow->receive(array_replace($receive,['accepted_qty'=>1.5]),1),'fractional buckets rejected');
  37. check(WaterDelivery::query()->find($tasks[0])->status==='transit','invalid receipts leave task unchanged');
  38. check($flow->receive($receive,1)['status']==='returning','partial receipt enters return custody');
  39. check($dispatch->board(['date'=>$date,'keyword'=>$tag])['summary']['buckets']===9,'pool counts only remaining full buckets after partial receipt');
  40. check($business->overview()['states']['returning']>=1,'overview includes unresolved returns');
  41. $a=WaterAccount::query()->find($accounts[0]); $s=WaterSku::query()->find($sku);
  42. check((int)$a->entitlement===14 && (int)$a->frozen===4 && (int)$a->full_qty===6,'partial receipt only consumes actual quantity');
  43. check((int)$s->transit===9 && (int)$s->available===85,'remaining full buckets stay in transit');
  44. check((int)$a->empty_qty===2 && (int)$s->empty_qty===0 && (int)WaterDelivery::query()->find($tasks[0])->empty_in_vehicle===3,'collected empties remain on vehicle until handover');
  45. check($flow->receive($receive,1)['replayed']===true,'receipt replay idempotent');
  46. rejects(fn()=>$flow->receive(array_replace($receive,['accepted_qty'=>5]),1),'conflicting receipt replay rejected');
  47. check(count($flow->history($tasks[0]))===1,'receipt event recorded once');
  48. rejects(fn()=>$business->transition(['id'=>$tasks[0],'action'=>'cancel'],1),'returning task cannot cancel around settlement');
  49. $parent=WaterDelivery::query()->find($tasks[0]);
  50. rejects(fn()=>$business->createDelivery(['enterprise_id'=>$parent->enterprise_id,'sku_id'=>$sku,'quantity'=>1],1),'active return blocks duplicate enterprise delivery');
  51. $redeliver=['id'=>$tasks[0],'date'=>$date,'note'=>'客户预约重送'];
  52. rejects(fn()=>$flow->redeliver($redeliver,1),'cannot redeliver before return confirmation');
  53. $old=date('Y-m-d',strtotime('-1 day'));
  54. WaterDelivery::query()->where('id',$tasks[0])->update(['scheduled_date'=>$old]);
  55. check($dispatch->board(['date'=>$date,'keyword'=>$tag])['summary']['returning']===1,'previous-day unresolved return remains visible');
  56. WaterDelivery::query()->where('id',$tasks[0])->update(['scheduled_date'=>$date]);
  57. $return=['id'=>$tasks[0],'good_qty'=>3,'damaged_qty'=>1,'note'=>'退回3桶完好,1桶破损隔离'];
  58. rejects(fn()=>$flow->returnReceive(array_replace($return,['good_qty'=>2]),1),'return quantity must reconcile exactly');
  59. rejects(fn()=>$flow->returnReceive(array_replace($return,['note'=>'']),1),'return requires warehouse note');
  60. check($flow->returnReceive($return,1)['status']==='returned','warehouse confirmation terminates exception task');
  61. WaterDelivery::query()->where('id',$tasks[0])->update(['scheduled_date'=>$old]);
  62. check($dispatch->board(['date'=>$date,'keyword'=>$tag])['summary']['returned']===1,'today return of overdue task remains visible for redelivery');
  63. WaterDelivery::query()->where('id',$tasks[0])->update(['scheduled_date'=>$date]);
  64. $a=WaterAccount::query()->find($accounts[0]); $s=WaterSku::query()->find($sku);
  65. check((int)$a->frozen===0 && (int)$a->entitlement===14,'undelivered rights released without deduction');
  66. check((int)$s->available===88 && (int)$s->damaged_qty===1 && (int)$s->transit===5,'good and damaged stock separated');
  67. check($flow->returnReceive($return,1)['replayed']===true,'return replay idempotent');
  68. rejects(fn()=>$flow->returnReceive(array_replace($return,['good_qty'=>4,'damaged_qty'=>0]),1),'conflicting return replay rejected');
  69. $close=['route_id'=>$route,'items'=>[['sku_id'=>$sku,'actual'=>2,'lost'=>1]],'note'=>'返仓实交2个空桶,运输遗失1个已核实'];
  70. rejects(fn()=>$flow->settleRoute($close,1),'cannot settle while another task active');
  71. $full=['id'=>$tasks[1],'accepted_qty'=>5,'empty_returned'=>0,'receiver'=>'test','reason'=>''];
  72. $flow->receive($full,1);
  73. check($dispatch->route(['id'=>$route])['fulfillment']['can_settle'],'all task outcomes ready for settlement');
  74. check(array_column($dispatch->drivers($date),null,'id')[$driver]['state']==='busy','terminal tasks do not prematurely release driver');
  75. rejects(fn()=>$dispatch->shift(['driver_id'=>$driver,'date'=>$date,'state'=>'rest'],1),'cannot rest before route handover');
  76. rejects(fn()=>$flow->settleRoute(array_replace($close,['items'=>[]]),1),'missing SKU handover rejected');
  77. rejects(fn()=>$flow->settleRoute(array_replace($close,['items'=>[['sku_id'=>$sku,'actual'=>1,'lost'=>1]]]),1),'unexplained shortage rejected');
  78. check((int)WaterSku::query()->find($sku)->empty_qty===0,'failed settlement leaves warehouse unchanged');
  79. $flow->settleRoute($close,1);
  80. $s=WaterSku::query()->find($sku);
  81. check((int)$s->empty_qty===2 && (int)$s->empty_lost_qty===1,'empty handover and shortage accounted separately');
  82. check((int)WaterDelivery::query()->find($tasks[0])->empty_in_vehicle===0,'vehicle empty custody cleared');
  83. check(array_column($dispatch->drivers($date),null,'id')[$driver]['state']==='available','settlement releases driver');
  84. check($flow->settleRoute($close,1)['replayed']===true,'settlement replay idempotent');
  85. rejects(fn()=>$flow->settleRoute(array_replace($close,['note'=>'different']),1),'settlement changes rejected');
  86. check($flow->receive($receive,1)['replayed']===true,'receipt replay remains valid after route settlement');
  87. check($flow->returnReceive($return,1)['replayed']===true,'return replay remains valid after route settlement');
  88. $child=$flow->redeliver($redeliver,1)['id'];
  89. $new=WaterDelivery::query()->find($child);
  90. check((int)$new->quantity===4 && (int)$new->redelivery_of===$tasks[0] && $new->status==='pending','redelivery creates linked residual task');
  91. check((int)WaterAccount::query()->find($accounts[0])->frozen===4,'redelivery rechecks and freezes residual rights');
  92. check($flow->redeliver($redeliver,1)['id']===$child,'redelivery replay returns same child');
  93. rejects(fn()=>$flow->redeliver(array_replace($redeliver,['note'=>'different']),1),'conflicting redelivery rejected');
  94. check((int)WaterDelivery::query()->find($tasks[0])->delivered_qty===6,'parent receipt preserved');
  95. $boardTasks=array_column($dispatch->board(['date'=>$date,'keyword'=>$tag])['tasks'],null,'id');
  96. check((int)$boardTasks[$tasks[0]]['redelivery_id']===$child && WaterDelivery::query()->where('redelivery_of',$tasks[0])->count()===1,'board exposes the unique linked redelivery');
  97. // Second route: full rejection, return, and a fresh subsequent attempt are all supported.
  98. $route2=$dispatch->commit(['date'=>$date,'request_key'=>$tag.'-retry','routes'=>[['driver_id'=>$driver,'task_ids'=>[$child]]]],1)['route_ids'][0];
  99. $business->transition(['id'=>$child,'action'=>'depart'],1);
  100. $flow->receive(['id'=>$child,'accepted_qty'=>0,'empty_returned'=>0,'receiver'=>'test','reason'=>'客户不在'],1);
  101. check((int)WaterAccount::query()->find($accounts[0])->entitlement===14,'full refusal consumes no rights');
  102. $flow->returnReceive(['id'=>$child,'good_qty'=>4,'damaged_qty'=>0,'note'=>'全部退回'],1);
  103. $flow->settleRoute(['route_id'=>$route2,'items'=>[['sku_id'=>$sku,'actual'=>0,'lost'=>0]],'note'=>'车辆返仓核对完成'],1);
  104. check((int)WaterSku::query()->find($sku)->transit===0 && (int)WaterSku::query()->find($sku)->locked===0,'all full bucket custody reconciles');
  105. check((int)WaterSku::query()->find($sku)->available+11+(int)WaterSku::query()->find($sku)->damaged_qty===100,'full bucket conservation: warehouse + delivered + damage');
  106. check((int)WaterAccount::query()->whereIn('id',$accounts)->sum('empty_qty')+(int)WaterSku::query()->find($sku)->empty_qty+(int)WaterSku::query()->find($sku)->empty_lost_qty===10,'empty bucket conservation including explicit losses');
  107. $second=$flow->redeliver(['id'=>$child,'date'=>$date,'note'=>'第三次预约'],1)['id'];
  108. check((int)WaterDelivery::query()->find($second)->redelivery_of===$child,'retry chain preserves every attempt');
  109. check(WaterOrder::query()->count()===$before[2],'fulfillment creates no payment orders');
  110. // Multi-SKU settlement must roll back earlier SKU writes when a later row fails.
  111. $sku2=$business->save('sku',['name'=>$tag.'-second','liters'=>12],1)['id'];
  112. $business->stock(['sku_id'=>$sku2,'mode'=>'inbound','quantity'=>5,'reference'=>$tag.'s2','note'=>'test'],1);
  113. $enterprise3=$business->save('enterprise',['name'=>$tag.'-multi','contact'=>'test','phone'=>'test','address'=>'test','region'=>$tag],1)['id'];
  114. $multi=[];
  115. foreach ([$sku,$sku2] as $product) {
  116. WaterAccount::query()->insert(['enterprise_id'=>$enterprise3,'sku_id'=>$product,'entitlement'=>5,'empty_qty'=>2,'updated_at'=>date('Y-m-d H:i:s')]);
  117. $multi[]=$business->createDelivery(['enterprise_id'=>$enterprise3,'sku_id'=>$product,'quantity'=>1,'date'=>$date],1)['id'];
  118. }
  119. $multiRoute=$dispatch->commit(['date'=>$date,'request_key'=>$tag.'-multi','routes'=>[['driver_id'=>$driver,'task_ids'=>$multi]]],1)['route_ids'][0];
  120. foreach ($multi as $taskId) {
  121. $business->transition(['id'=>$taskId,'action'=>'depart'],1);
  122. $flow->receive(['id'=>$taskId,'accepted_qty'=>1,'empty_returned'=>2,'receiver'=>'test','reason'=>''],1);
  123. }
  124. $emptyBefore=(int)WaterSku::query()->find($sku)->empty_qty;
  125. $settleMulti=['route_id'=>$multiRoute,'items'=>[['sku_id'=>$sku,'actual'=>2,'lost'=>0],['sku_id'=>$sku2,'actual'=>1,'lost'=>0]],'note'=>'test'];
  126. rejects(fn()=>$flow->settleRoute($settleMulti,1),'later SKU mismatch rejects entire settlement');
  127. check((int)WaterSku::query()->find($sku)->empty_qty===$emptyBefore && !WaterRoute::query()->find($multiRoute)->settled_at,'earlier SKU movement rolls back with failed settlement');
  128. check((int)WaterDelivery::query()->whereIn('id',$multi)->sum('empty_in_vehicle')===4,'failed multi-SKU settlement retains all vehicle custody');
  129. $settleMulti['items'][1]['actual']=2;
  130. $flow->settleRoute($settleMulti,1);
  131. check((int)WaterSku::query()->find($sku)->empty_qty===$emptyBefore+2 && (int)WaterSku::query()->find($sku2)->empty_qty===2,'multi-SKU handover settles each product exactly');
  132. $reflection=new ReflectionClass(\plugin\saiadmin\app\controller\water\WaterController::class);
  133. $controller=$reflection->newInstanceWithoutConstructor(); $reflection->getProperty('adminId')->setValue($controller,0);
  134. rejects(fn()=>$controller->dispatchHistory(new \support\Request("GET / HTTP/1.1\r\nHost: localhost\r\n\r\n")),'history requires read authorization');
  135. } finally { $db->rollBack(); }
  136. check([WaterDelivery::query()->count(),WaterDeliveryEvent::query()->count(),WaterOrder::query()->count()]===$before,'all fixtures rolled back');
  137. echo "PASS: $checks fulfillment checks; all fixtures rolled back.\n";