load(); \support\App::loadAllConfig(['route']); date_default_timezone_set('Asia/Shanghai'); use plugin\saiadmin\app\model\water\{WaterSku,WaterAccount,WaterEnterprise,WaterDelivery,WaterRoute,WaterDeliveryEvent,WaterOrder}; use plugin\saiadmin\app\service\{WaterBusiness,WaterDispatch,WaterFulfillment}; 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); } $db=(new WaterSku())->getConnection(); $before=[WaterDelivery::query()->count(),WaterDeliveryEvent::query()->count(),WaterOrder::query()->count()]; $business=new WaterBusiness(); $dispatch=new WaterDispatch(); $flow=new WaterFulfillment(); $date=date('Y-m-d'); $tag='FULFILL-'.bin2hex(random_bytes(5)); $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=[]; $accounts=[]; foreach ([10,5] as $i=>$qty) { $e=$business->save('enterprise',['name'=>$tag.$i,'contact'=>'test','phone'=>'test','address'=>'test','region'=>$tag],1)['id']; $accounts[]=WaterAccount::query()->insertGetId(['enterprise_id'=>$e,'sku_id'=>$sku,'entitlement'=>20,'empty_qty'=>5,'updated_at'=>date('Y-m-d H:i:s')]); $tasks[]=$business->createDelivery(['enterprise_id'=>$e,'sku_id'=>$sku,'quantity'=>$qty,'date'=>$date],1)['id']; } $driver=$dispatch->saveDriver(['name'=>$tag,'phone'=>$tag,'region'=>$tag,'capacity'=>30],1)['id']; $dispatch->shift(['driver_id'=>$driver,'date'=>$date,'state'=>'on'],1); $route=$dispatch->commit(['date'=>$date,'request_key'=>$tag,'routes'=>[['driver_id'=>$driver,'task_ids'=>$tasks]]],1)['route_ids'][0]; $receive=['id'=>$tasks[0],'accepted_qty'=>6,'empty_returned'=>3,'receiver'=>'test receiver','reason'=>'4桶客户拒收']; rejects(fn()=>$flow->receive($receive,1),'cannot receive before departure'); $business->transition(['id'=>$tasks[0],'action'=>'depart'],1); $business->transition(['id'=>$tasks[1],'action'=>'depart'],1); rejects(fn()=>$flow->receive(array_replace($receive,['accepted_qty'=>11]),1),'overdelivery rejected'); rejects(fn()=>$flow->receive(array_replace($receive,['reason'=>'']),1),'partial receipt needs reason'); rejects(fn()=>$flow->receive(array_replace($receive,['empty_returned'=>6]),1),'excess empty collection rejected'); rejects(fn()=>$flow->receive(array_replace($receive,['accepted_qty'=>1.5]),1),'fractional buckets rejected'); check(WaterDelivery::query()->find($tasks[0])->status==='transit','invalid receipts leave task unchanged'); check($flow->receive($receive,1)['status']==='returning','partial receipt enters return custody'); check($dispatch->board(['date'=>$date,'keyword'=>$tag])['summary']['buckets']===9,'pool counts only remaining full buckets after partial receipt'); check($business->overview()['states']['returning']>=1,'overview includes unresolved returns'); $a=WaterAccount::query()->find($accounts[0]); $s=WaterSku::query()->find($sku); check((int)$a->entitlement===14 && (int)$a->frozen===4 && (int)$a->full_qty===6,'partial receipt only consumes actual quantity'); check((int)$s->transit===9 && (int)$s->available===85,'remaining full buckets stay in transit'); 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'); check($flow->receive($receive,1)['replayed']===true,'receipt replay idempotent'); rejects(fn()=>$flow->receive(array_replace($receive,['accepted_qty'=>5]),1),'conflicting receipt replay rejected'); check(count($flow->history($tasks[0]))===1,'receipt event recorded once'); rejects(fn()=>$business->transition(['id'=>$tasks[0],'action'=>'cancel'],1),'returning task cannot cancel around settlement'); $parent=WaterDelivery::query()->find($tasks[0]); rejects(fn()=>$business->createDelivery(['enterprise_id'=>$parent->enterprise_id,'sku_id'=>$sku,'quantity'=>1],1),'active return blocks duplicate enterprise delivery'); $redeliver=['id'=>$tasks[0],'date'=>$date,'note'=>'客户预约重送']; rejects(fn()=>$flow->redeliver($redeliver,1),'cannot redeliver before return confirmation'); $old=date('Y-m-d',strtotime('-1 day')); WaterDelivery::query()->where('id',$tasks[0])->update(['scheduled_date'=>$old]); check($dispatch->board(['date'=>$date,'keyword'=>$tag])['summary']['returning']===1,'previous-day unresolved return remains visible'); WaterDelivery::query()->where('id',$tasks[0])->update(['scheduled_date'=>$date]); $return=['id'=>$tasks[0],'good_qty'=>3,'damaged_qty'=>1,'note'=>'退回3桶完好,1桶破损隔离']; rejects(fn()=>$flow->returnReceive(array_replace($return,['good_qty'=>2]),1),'return quantity must reconcile exactly'); rejects(fn()=>$flow->returnReceive(array_replace($return,['note'=>'']),1),'return requires warehouse note'); check($flow->returnReceive($return,1)['status']==='returned','warehouse confirmation terminates exception task'); WaterDelivery::query()->where('id',$tasks[0])->update(['scheduled_date'=>$old]); check($dispatch->board(['date'=>$date,'keyword'=>$tag])['summary']['returned']===1,'today return of overdue task remains visible for redelivery'); WaterDelivery::query()->where('id',$tasks[0])->update(['scheduled_date'=>$date]); $a=WaterAccount::query()->find($accounts[0]); $s=WaterSku::query()->find($sku); check((int)$a->frozen===0 && (int)$a->entitlement===14,'undelivered rights released without deduction'); check((int)$s->available===88 && (int)$s->damaged_qty===1 && (int)$s->transit===5,'good and damaged stock separated'); check($flow->returnReceive($return,1)['replayed']===true,'return replay idempotent'); rejects(fn()=>$flow->returnReceive(array_replace($return,['good_qty'=>4,'damaged_qty'=>0]),1),'conflicting return replay rejected'); $close=['route_id'=>$route,'items'=>[['sku_id'=>$sku,'actual'=>2,'lost'=>1]],'note'=>'返仓实交2个空桶,运输遗失1个已核实']; rejects(fn()=>$flow->settleRoute($close,1),'cannot settle while another task active'); $full=['id'=>$tasks[1],'accepted_qty'=>5,'empty_returned'=>0,'receiver'=>'test','reason'=>'']; $flow->receive($full,1); check($dispatch->route(['id'=>$route])['fulfillment']['can_settle'],'all task outcomes ready for settlement'); check(array_column($dispatch->drivers($date),null,'id')[$driver]['state']==='busy','terminal tasks do not prematurely release driver'); rejects(fn()=>$dispatch->shift(['driver_id'=>$driver,'date'=>$date,'state'=>'rest'],1),'cannot rest before route handover'); rejects(fn()=>$flow->settleRoute(array_replace($close,['items'=>[]]),1),'missing SKU handover rejected'); rejects(fn()=>$flow->settleRoute(array_replace($close,['items'=>[['sku_id'=>$sku,'actual'=>1,'lost'=>1]]]),1),'unexplained shortage rejected'); check((int)WaterSku::query()->find($sku)->empty_qty===0,'failed settlement leaves warehouse unchanged'); $flow->settleRoute($close,1); $s=WaterSku::query()->find($sku); check((int)$s->empty_qty===2 && (int)$s->empty_lost_qty===1,'empty handover and shortage accounted separately'); check((int)WaterDelivery::query()->find($tasks[0])->empty_in_vehicle===0,'vehicle empty custody cleared'); check(array_column($dispatch->drivers($date),null,'id')[$driver]['state']==='available','settlement releases driver'); check($flow->settleRoute($close,1)['replayed']===true,'settlement replay idempotent'); rejects(fn()=>$flow->settleRoute(array_replace($close,['note'=>'different']),1),'settlement changes rejected'); check($flow->receive($receive,1)['replayed']===true,'receipt replay remains valid after route settlement'); check($flow->returnReceive($return,1)['replayed']===true,'return replay remains valid after route settlement'); $child=$flow->redeliver($redeliver,1)['id']; $new=WaterDelivery::query()->find($child); check((int)$new->quantity===4 && (int)$new->redelivery_of===$tasks[0] && $new->status==='pending','redelivery creates linked residual task'); check((int)WaterAccount::query()->find($accounts[0])->frozen===4,'redelivery rechecks and freezes residual rights'); check($flow->redeliver($redeliver,1)['id']===$child,'redelivery replay returns same child'); rejects(fn()=>$flow->redeliver(array_replace($redeliver,['note'=>'different']),1),'conflicting redelivery rejected'); check((int)WaterDelivery::query()->find($tasks[0])->delivered_qty===6,'parent receipt preserved'); $boardTasks=array_column($dispatch->board(['date'=>$date,'keyword'=>$tag])['tasks'],null,'id'); check((int)$boardTasks[$tasks[0]]['redelivery_id']===$child && WaterDelivery::query()->where('redelivery_of',$tasks[0])->count()===1,'board exposes the unique linked redelivery'); // Second route: full rejection, return, and a fresh subsequent attempt are all supported. $route2=$dispatch->commit(['date'=>$date,'request_key'=>$tag.'-retry','routes'=>[['driver_id'=>$driver,'task_ids'=>[$child]]]],1)['route_ids'][0]; $business->transition(['id'=>$child,'action'=>'depart'],1); $flow->receive(['id'=>$child,'accepted_qty'=>0,'empty_returned'=>0,'receiver'=>'test','reason'=>'客户不在'],1); check((int)WaterAccount::query()->find($accounts[0])->entitlement===14,'full refusal consumes no rights'); $flow->returnReceive(['id'=>$child,'good_qty'=>4,'damaged_qty'=>0,'note'=>'全部退回'],1); $flow->settleRoute(['route_id'=>$route2,'items'=>[['sku_id'=>$sku,'actual'=>0,'lost'=>0]],'note'=>'车辆返仓核对完成'],1); check((int)WaterSku::query()->find($sku)->transit===0 && (int)WaterSku::query()->find($sku)->locked===0,'all full bucket custody reconciles'); check((int)WaterSku::query()->find($sku)->available+11+(int)WaterSku::query()->find($sku)->damaged_qty===100,'full bucket conservation: warehouse + delivered + damage'); 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'); $second=$flow->redeliver(['id'=>$child,'date'=>$date,'note'=>'第三次预约'],1)['id']; check((int)WaterDelivery::query()->find($second)->redelivery_of===$child,'retry chain preserves every attempt'); check(WaterOrder::query()->count()===$before[2],'fulfillment creates no payment orders'); // Multi-SKU settlement must roll back earlier SKU writes when a later row fails. $sku2=$business->save('sku',['name'=>$tag.'-second','liters'=>12],1)['id']; $business->stock(['sku_id'=>$sku2,'mode'=>'inbound','quantity'=>5,'reference'=>$tag.'s2','note'=>'test'],1); $enterprise3=$business->save('enterprise',['name'=>$tag.'-multi','contact'=>'test','phone'=>'test','address'=>'test','region'=>$tag],1)['id']; $multi=[]; foreach ([$sku,$sku2] as $product) { WaterAccount::query()->insert(['enterprise_id'=>$enterprise3,'sku_id'=>$product,'entitlement'=>5,'empty_qty'=>2,'updated_at'=>date('Y-m-d H:i:s')]); $multi[]=$business->createDelivery(['enterprise_id'=>$enterprise3,'sku_id'=>$product,'quantity'=>1,'date'=>$date],1)['id']; } $multiRoute=$dispatch->commit(['date'=>$date,'request_key'=>$tag.'-multi','routes'=>[['driver_id'=>$driver,'task_ids'=>$multi]]],1)['route_ids'][0]; foreach ($multi as $taskId) { $business->transition(['id'=>$taskId,'action'=>'depart'],1); $flow->receive(['id'=>$taskId,'accepted_qty'=>1,'empty_returned'=>2,'receiver'=>'test','reason'=>''],1); } $emptyBefore=(int)WaterSku::query()->find($sku)->empty_qty; $settleMulti=['route_id'=>$multiRoute,'items'=>[['sku_id'=>$sku,'actual'=>2,'lost'=>0],['sku_id'=>$sku2,'actual'=>1,'lost'=>0]],'note'=>'test']; rejects(fn()=>$flow->settleRoute($settleMulti,1),'later SKU mismatch rejects entire settlement'); check((int)WaterSku::query()->find($sku)->empty_qty===$emptyBefore && !WaterRoute::query()->find($multiRoute)->settled_at,'earlier SKU movement rolls back with failed settlement'); check((int)WaterDelivery::query()->whereIn('id',$multi)->sum('empty_in_vehicle')===4,'failed multi-SKU settlement retains all vehicle custody'); $settleMulti['items'][1]['actual']=2; $flow->settleRoute($settleMulti,1); 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'); $reflection=new ReflectionClass(\plugin\saiadmin\app\controller\water\WaterController::class); $controller=$reflection->newInstanceWithoutConstructor(); $reflection->getProperty('adminId')->setValue($controller,0); rejects(fn()=>$controller->dispatchHistory(new \support\Request("GET / HTTP/1.1\r\nHost: localhost\r\n\r\n")),'history requires read authorization'); } finally { $db->rollBack(); } check([WaterDelivery::query()->count(),WaterDeliveryEvent::query()->count(),WaterOrder::query()->count()]===$before,'all fixtures rolled back'); echo "PASS: $checks fulfillment checks; all fixtures rolled back.\n";