load(); \support\App::loadAllConfig(['route']); date_default_timezone_set('Asia/Shanghai'); use plugin\saiadmin\app\model\water\{WaterAccount,WaterEnterprise,WaterSku,WaterDevice,WaterDelivery,WaterLedger}; use plugin\saiadmin\app\service\{WaterBusiness,WaterAlertDashboard,WaterEnterpriseDashboard}; use plugin\saiadmin\app\controller\water\WaterController; use plugin\saiadmin\exception\ApiException; $checks=0; function check(bool $ok,string $label): void { global $checks; $checks++; if (!$ok) throw new RuntimeException($label); } function rejects(callable $fn,string $label): void { try {$fn();} catch (ApiException $e) {check(true,$label);return;} throw new RuntimeException($label); } function request(string $method,string $path,array $input=[]): \support\Request { $body=$method==='POST'?json_encode($input):''; if ($method==='GET' && $input) $path.='?'.http_build_query($input); return new \support\Request($method.' '.$path." HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: ".strlen($body)."\r\n\r\n".$body); } $now=new DateTimeImmutable('2026-09-17 12:00:00',new DateTimeZone('Asia/Shanghai')); $business=new WaterBusiness(); $tag='ALERT-'.bin2hex(random_bytes(4)); $db=(new WaterAccount())->getConnection(); $before=[]; foreach ([WaterAccount::class,WaterSku::class,WaterDelivery::class,WaterLedger::class] as $model) $before[$model]=hash('sha256',serialize($model::query()->orderBy('id')->get()->toArray())); $db->beginTransaction(); try { \plugin\saiadmin\app\model\water\WaterAlertSetting::query()->where('id',1)->update(['alert_qty'=>5,'days_per_barrel'=>3,'urgent_qty'=>2]); $service=new WaterAlertDashboard($now); $baseline=$service->dashboard([]); $sku=$business->save('sku',['name'=>$tag.'-water','liters'=>18.9],1)['id']; $emptySku=$business->save('sku',['name'=>$tag.'-empty','liters'=>20],1)['id']; $disabledSku=$business->save('sku',['name'=>$tag.'-disabled','liters'=>20,'status'=>2],1)['id']; WaterSku::query()->where('id',$sku)->update(['available'=>100]); $specs=[ 'urgent_qty'=>[2,2,null,null], 'normal_qty'=>[4,0,null,null], 'urgent_days'=>[2,0,999,2], 'normal_days'=>[4,0,999,3], 'healthy'=>[5,100,999,30], 'healthy_round'=>[10,100,39.9,6], 'normal_round'=>[3,0,39.9,3], 'zero_usage'=>[4,0,0,null], 'no_rights'=>[1,2,null,null], 'ordered'=>[1,2,null,null], 'no_stock'=>[1,2,null,null], 'disabled_enterprise'=>[0,2,null,null], 'disabled_sku'=>[0,2,null,null], ]; $accounts=[]; $enterprises=[]; foreach ($specs as $name=>[$full,$threshold,$daily,$people]) { $e=$business->save('enterprise',['name'=>$tag.'-'.$name,'contact'=>'TEST','phone'=>'TEST', 'address'=>$tag.'-模拟地址-'.$name,'region'=>$tag.($name==='normal_days'?'-west':'-east'), 'status'=>$name==='disabled_enterprise'?2:1,'employee_count'=>$people],1)['id']; $s=$name==='no_stock'?$emptySku:($name==='disabled_sku'?$disabledSku:$sku); $accounts[$name]=WaterAccount::query()->insertGetId(['enterprise_id'=>$e,'sku_id'=>$s,'full_qty'=>$full, 'threshold_qty'=>$threshold,'daily_liters'=>$daily,'entitlement'=>$name==='no_rights'?0:30,'frozen'=>0,'updated_at'=>'2026-09-17 09:00:00']); $enterprises[$name]=$e; } $extra=WaterAccount::query()->insertGetId(['enterprise_id'=>$enterprises['urgent_qty'],'sku_id'=>$emptySku, 'full_qty'=>20,'threshold_qty'=>2,'entitlement'=>30,'updated_at'=>'2026-09-17 09:00:00']); foreach ([[$sku,'SN-A'],[$sku,'SN-B'],[$emptySku,'SN-C']] as [$s,$sn]) $business->save('device',[ 'name'=>$tag.'-'.$sn,'sn'=>$tag.'-'.$sn,'enterprise_id'=>$enterprises['urgent_qty'],'sku_id'=>$s],1); $task=function(string $name,string $at,int $qty,string $status='completed',?string $active=null) use ($enterprises,$sku): int { return WaterDelivery::query()->insertGetId(['enterprise_id'=>$enterprises[$name],'sku_id'=>$sku,'quantity'=>$qty, 'address'=>'TEST','status'=>$status,'active_key'=>$active,'created_by'=>1,'created_at'=>$at,'updated_at'=>$at]); }; $ordered=$task('ordered','2026-09-16 10:00:00',3,'assigned',$enterprises['ordered'].':'.$sku); $task('healthy','2026-09-17 00:00:00',4); $task('healthy','2026-09-17 12:00:00',5); $task('healthy','2026-09-17 12:00:01',6); $task('healthy','2026-09-17 10:00:00',7,'cancelled'); $task('healthy','2026-09-11 00:00:00',8); $task('healthy','2026-09-10 23:59:59',9); $data=$service->dashboard(['keyword'=>$tag,'limit'=>100]); $all=$service->dashboard(['keyword'=>$tag,'scope'=>'all','limit'=>100]); $byId=array_column($all['list']['data'],null,'id'); check($data['list']['total']===9 && $all['list']['total']===12,'only risks vs all enabled accounts'); check($data['summary']['accounts']-$baseline['summary']['accounts']===12,'global enabled account count'); check($data['summary']['alerts']-$baseline['summary']['alerts']===9,'global alert count'); check($data['summary']['urgent']-$baseline['summary']['urgent']===5 && $data['summary']['normal']-$baseline['summary']['normal']===4,'global urgent and normal counts including null usage'); check($data['summary']['ordered']-$baseline['summary']['ordered']===1,'already ordered risk retained'); check($data['summary']['created_today']-$baseline['summary']['created_today']===2 && $data['summary']['quantity_today']-$baseline['summary']['quantity_today']===9,'today exact boundaries exclude future and cancelled'); check(count($data['trend'])===7 && $data['trend'][0]['day']==='2026-09-11' && $data['trend'][6]['day']==='2026-09-17','seven days inclusive'); check($data['trend'][0]['count']-$baseline['trend'][0]['count']===1 && $data['trend'][0]['quantity']-$baseline['trend'][0]['quantity']===8,'first-day boundary'); check($data['trend'][6]['count']===$data['summary']['created_today'] && $data['trend'][6]['quantity']===$data['summary']['quantity_today'],'trend reconciles with today cards'); check($data['summary']['accuracy']===null && $data['policy']['accuracy_available']===false && $data['trend_kind']==='delivery_created','no fabricated accuracy or historical alerts'); check($byId[$accounts['urgent_qty']]['device_count']===2 && $byId[$extra]['device_count']===1,'devices aggregated by enterprise and SKU, no multiplied rows'); check($byId[$accounts['urgent_qty']]['days_left']===null && $byId[$accounts['urgent_qty']]['exhausts_at']===null,'no usage is not zero-day prediction'); check($byId[$accounts['zero_usage']]['days_left']===null && $byId[$accounts['zero_usage']]['severity']==='normal','zero usage still triggers quantity normal alert'); check($byId[$accounts['healthy_round']]['days_left']===5.0 && !$byId[$accounts['healthy_round']]['is_alert'],'five-day estimate cannot trigger alert above five barrels'); check($byId[$accounts['normal_round']]['days_left']===3.0 && $byId[$accounts['normal_round']]['severity']==='normal','three barrels remain normal even with a three-day estimate'); check($byId[$accounts['urgent_days']]['severity']==='urgent' && $byId[$accounts['urgent_days']]['exhausts_at']==='2026-09-20 12:00:00','exact three-day threshold and timestamp'); check($byId[$accounts['normal_days']]['severity']==='normal' && $byId[$accounts['normal_days']]['reasons']===['现场满桶少于5桶'],'platform quantity reason despite arbitrary old usage'); check($byId[$accounts['normal_qty']]['reasons']===['现场满桶少于5桶'],'quantity-only reason ignores zero legacy threshold'); check($byId[$accounts['no_rights']]['blocked_reason']==='可用套餐权益不足' && !$byId[$accounts['no_rights']]['can_create'],'no entitlement blocked'); check($byId[$accounts['no_stock']]['blocked_reason']==='仓库可用库存不足' && !$byId[$accounts['no_stock']]['can_create'],'empty warehouse blocked'); check($byId[$accounts['ordered']]['delivery_id']===$ordered && !$byId[$accounts['ordered']]['can_create'],'active task blocks repeat'); check($byId[$accounts['urgent_days']]['can_create'] && !$byId[$accounts['healthy']]['can_create'],'only eligible risks selectable'); check($service->listing(['keyword'=>$tag.'-SN-A'])['total']===1,'device SN search'); check($service->listing(['keyword'=>$tag.'-SN-C','scope'=>'all'])['data'][0]['id']===$extra,'device search respects SKU'); check($service->listing(['keyword'=>$tag.'-模拟地址-normal_qty'])['total']===1,'address search'); check($service->listing(['keyword'=>$tag.'-water','scope'=>'all'])['total']===10,'SKU search excludes disabled enterprises'); check($service->listing(['keyword'=>$tag,'severity'=>'urgent'])['total']===5,'urgent filter'); check($service->listing(['keyword'=>$tag,'severity'=>'normal'])['total']===4,'normal filter includes missing usage'); check($service->listing(['keyword'=>$tag,'scope'=>'all','severity'=>'healthy'])['total']===3,'healthy filter includes null and above-boundary estimates'); check($service->listing(['keyword'=>$tag,'region'=>$tag.'-west'])['total']===1,'region filter'); check($service->listing(['keyword'=>$tag,'region'=>$tag.'-west','severity'=>'urgent'])['total']===0,'combined filters'); check($service->listing(['keyword'=>$tag,'exhaust_date'=>'2026-09-21'])['total']===1,'exhaustion date filter'); check($service->listing(['enterprise_id'=>$enterprises['urgent_qty'],'scope'=>'all'])['total']===2,'enterprise filter retained'); $pages=[]; for ($page=1;$page<=3;$page++) { $result=$service->dashboard(['keyword'=>$tag,'page'=>$page,'limit'=>4]); $pages=array_merge($pages,$result['list']['data']); check($result['summary']===$data['summary'] && $result['trend']===$data['trend'],'global cards and trend independent of pages'); } check(array_column($pages,'id')===array_column($data['list']['data'],'id'),'stable severity, exhaustion and ID ordering'); check($service->dashboard(['keyword'=>$tag.'-missing'])['summary']===$data['summary'],'summary independent of no-match keyword'); check($service->listing(['keyword'=>$tag,'page'=>99])['data']===[],'out of range page'); check(in_array($tag.'-east',$data['regions'],true) && in_array($tag.'-west',$data['regions'],true),'global region options'); $policyBefore=WaterAccount::query()->find($accounts['healthy'])->toArray(); rejects(fn()=>$business->saveAlertPolicy(['id'=>$accounts['healthy'],'threshold_qty'=>8,'full_qty'=>999,'daily_liters'=>999],1),'retired per-company policy command rejected'); check(WaterAccount::query()->find($accounts['healthy'])->toArray()===$policyBefore,'rejected policy leaves full account unchanged'); check(WaterLedger::query()->where('kind','alert_policy')->where('reference','account:'.$accounts['healthy'])->count()===0,'no audit or mutation on rejected policy'); check(!$byId[$accounts['healthy']]['is_alert'] && $byId[$accounts['healthy']]['full_qty']===5 && $byId[$accounts['healthy']]['days_left']===0.5,'exactly five barrels never alert even for thirty people and legacy threshold 100'); check($byId[$accounts['normal_days']]['estimated_daily_liters']===18.9 && $byId[$accounts['normal_days']]['estimated_daily_barrels']===1.0,'18.9 liters times three people divided by three'); check($byId[$accounts['healthy']]['estimated_daily_liters']===189.0,'thirty people consume ten barrels or 189 liters per day'); check($data['policy']['alert_qty']===5 && $data['policy']['comparison']==='lt' && $data['policy']['days_per_barrel']===3,'platform policy contract'); $enterpriseBefore=WaterEnterprise::query()->find($enterprises['normal_days'])->toArray(); foreach ([-1,0,1.5,10000001,[],true] as $invalid) rejects(fn()=>$business->save('enterprise',array_replace($enterpriseBefore,['employee_count'=>$invalid]),1),'invalid headcount rejected'); check(WaterEnterprise::query()->find($enterprises['normal_days'])->toArray()===$enterpriseBefore,'invalid headcount preserves enterprise'); $business->save('enterprise',array_replace($enterpriseBefore,['employee_count'=>6]),1); $changed=$service->listing(['enterprise_id'=>$enterprises['normal_days']])['data'][0]; check($changed['days_left']===2.0 && $changed['estimated_daily_liters']===37.8,'headcount edit immediately changes forecast'); check($changed['severity']==='normal' && $changed['full_qty']===4,'headcount change does not mutate stock or quantity severity'); $legacy=$business->listing('accounts',['enterprise_id'=>$enterprises['normal_days']])['data'][0]; check($legacy['estimated_daily_liters']===$changed['estimated_daily_liters'] && $legacy['days_left']===$changed['days_left'],'account and dashboard predictions consistent'); check((new WaterEnterpriseDashboard($now))->detail($enterprises['normal_days'])['enterprise']['employee_count']===6,'enterprise detail exposes persisted headcount'); $omitted=$enterpriseBefore; unset($omitted['employee_count']); $business->save('enterprise',$omitted,1); check((int)WaterEnterprise::query()->find($enterprises['normal_days'])->employee_count===6,'legacy save without headcount preserves value'); $business->save('enterprise',array_replace($enterpriseBefore,['employee_count'=>null]),1); $unknown=$service->listing(['enterprise_id'=>$enterprises['normal_days']])['data'][0]; check($unknown['days_left']===null && $unknown['estimated_daily_liters']===null && $unknown['exhausts_at']===null && $unknown['is_alert'],'clearing headcount removes estimates but preserves alert'); $stockBefore=WaterSku::query()->find($sku)->toArray(); rejects(fn()=>$business->batchCreateDelivery(['items'=>[['account_id'=>$accounts['urgent_qty'],'quantity'=>2],['account_id'=>$accounts['no_rights'],'quantity'=>1]]],1),'invalid later row rolls back whole batch'); check(WaterSku::query()->find($sku)->toArray()===$stockBefore && (int)WaterAccount::query()->find($accounts['urgent_qty'])->frozen===0,'failed batch restores stock and entitlement'); $input=['items'=>[['account_id'=>$accounts['urgent_qty'],'quantity'=>3],['account_id'=>$accounts['normal_qty'],'quantity'=>4]]]; $batch=$business->batchCreateDelivery($input,1); check($batch['count']===2 && (int)WaterSku::query()->find($sku)->available===93,'batch reserves warehouse'); check((int)WaterAccount::query()->find($accounts['urgent_qty'])->frozen===3,'batch freezes rights'); $orderedRow=$service->listing(['keyword'=>$tag.'-urgent_qty'])['data'][0]; check($orderedRow['delivery_id']!==null && !$orderedRow['can_create'] && $orderedRow['available_entitlement']===27,'created task reflected in alert immediately'); rejects(fn()=>$business->batchCreateDelivery($input,1),'repeated batch cannot duplicate active tasks'); foreach ($batch['created'] as $task) $business->transition(['id'=>$task['id'],'action'=>'cancel'],1); check($service->listing(['keyword'=>$tag.'-urgent_qty'])['data'][0]['can_create'],'cancel releases task lock and restores eligibility'); foreach ([['keyword'=>[]],['keyword'=>str_repeat('字',121)],['severity'=>'bad'],['scope'=>'bad'],['region'=>[]],['exhaust_date'=>'2026-02-30'],['exhaust_date'=>"2026-09\0"],['page'=>0],['limit'=>101],['enterprise_id'=>-1]] as $invalid) { rejects(fn()=>$service->dashboard($invalid),'invalid dashboard filters'); rejects(fn()=>$service->listing($invalid),'invalid legacy listing filters'); } $reflection=new ReflectionClass(WaterController::class); $controller=$reflection->newInstanceWithoutConstructor(); $reflection->getProperty('adminId')->setValue($controller,0); rejects(fn()=>$controller->alertDashboard(request('GET','/core/water/alerts/dashboard')),'unauthorized read'); rejects(fn()=>$controller->save(request('POST','/core/water/alerts',['action'=>'policy','id'=>$accounts['urgent_qty'],'threshold_qty'=>4]),'alerts'),'unauthorized policy write'); rejects(fn()=>$controller->save(request('POST','/core/water/dispatch',['action'=>'batch_create']+$input),'dispatch'),'unauthorized batch'); $reflection->getProperty('adminId')->setValue($controller,1); check((json_decode($controller->alertDashboard(request('GET','/core/water/alerts/dashboard'))->rawBody(),true)['code']??0)===200,'dashboard controller'); rejects(fn()=>$controller->save(request('POST','/core/water/alerts',['action'=>'policy','id'=>$accounts['urgent_qty'],'threshold_qty'=>3]),'alerts'),'admin cannot override platform threshold'); } finally { $db->rollBack(); } foreach ($before as $model=>$hash) check(hash('sha256',serialize($model::query()->orderBy('id')->get()->toArray()))===$hash,'original business model restored'); check(!WaterEnterprise::query()->where('name','like',$tag.'%')->exists() && !WaterDevice::query()->where('sn','like',$tag.'%')->exists(),'enterprise/device fixtures rolled back'); if (in_array('--http',$argv,true)) { $token=\Tinywan\Jwt\JwtToken::generateToken(['id'=>1,'username'=>'admin','plat'=>'saiadmin','type'=>'web','client'=>$tag,'access_exp'=>120,'refresh_exp'=>120])['access_token']; $livePolicy=(new \plugin\saiadmin\app\service\alerts\AlertSettings())->current(); foreach (['alerts/dashboard?limit=1','alerts/dashboard?severity=urgent','alerts?scope=all&limit=5'] as $path) { $ch=curl_init('http://127.0.0.1:8787/core/water/'.$path); curl_setopt_array($ch,[CURLOPT_RETURNTRANSFER=>true,CURLOPT_TIMEOUT=>15,CURLOPT_HTTPHEADER=>['Authorization: Bearer '.$token]]); $raw=curl_exec($ch); $status=curl_getinfo($ch,CURLINFO_HTTP_CODE); curl_close($ch); $body=json_decode((string)$raw,true); check($status===200 && ($body['code']??0)===200,'HTTP '.$path); $payload=$body['data']; if (str_starts_with($path,'alerts/dashboard')) { check(($payload['policy']['alert_qty']??null)===$livePolicy['alert_qty'] && ($payload['policy']['comparison']??null)==='lt','HTTP platform policy active'); $payload=$payload['list']; } foreach ($payload['data'] as $row) { check(array_key_exists('employee_count',$row) && array_key_exists('estimated_daily_liters',$row) && $row['is_alert']===($row['full_qty']<$livePolicy['alert_qty']),'HTTP uses headcount contract and strict quantity rule'); } } $current=(new WaterAlertDashboard())->dashboard([]); echo 'Current alerts: '.json_encode($current['summary'],JSON_UNESCAPED_UNICODE)."\n"; } echo "PASS: $checks alert checks; all fixtures rolled back.\n";