| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137 |
- <?php
- require dirname(__DIR__).'/vendor/autoload.php';
- \Dotenv\Dotenv::createUnsafeMutable(dirname(__DIR__))->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";
|