| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181 |
- <?php
- /** Read-model and existing policy/batch command regression. All fixtures roll back. */
- 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\{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";
|