water_alert_dashboard_test.php 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181
  1. <?php
  2. /** Read-model and existing policy/batch command regression. All fixtures roll back. */
  3. require dirname(__DIR__).'/vendor/autoload.php';
  4. \Dotenv\Dotenv::createUnsafeMutable(dirname(__DIR__))->load();
  5. \support\App::loadAllConfig(['route']);
  6. date_default_timezone_set('Asia/Shanghai');
  7. use plugin\saiadmin\app\model\water\{WaterAccount,WaterEnterprise,WaterSku,WaterDevice,WaterDelivery,WaterLedger};
  8. use plugin\saiadmin\app\service\{WaterBusiness,WaterAlertDashboard,WaterEnterpriseDashboard};
  9. use plugin\saiadmin\app\controller\water\WaterController;
  10. use plugin\saiadmin\exception\ApiException;
  11. $checks=0;
  12. function check(bool $ok,string $label): void { global $checks; $checks++; if (!$ok) throw new RuntimeException($label); }
  13. function rejects(callable $fn,string $label): void { try {$fn();} catch (ApiException $e) {check(true,$label);return;} throw new RuntimeException($label); }
  14. function request(string $method,string $path,array $input=[]): \support\Request {
  15. $body=$method==='POST'?json_encode($input):'';
  16. if ($method==='GET' && $input) $path.='?'.http_build_query($input);
  17. 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);
  18. }
  19. $now=new DateTimeImmutable('2026-09-17 12:00:00',new DateTimeZone('Asia/Shanghai'));
  20. $business=new WaterBusiness(); $tag='ALERT-'.bin2hex(random_bytes(4));
  21. $db=(new WaterAccount())->getConnection();
  22. $before=[];
  23. foreach ([WaterAccount::class,WaterSku::class,WaterDelivery::class,WaterLedger::class] as $model) $before[$model]=hash('sha256',serialize($model::query()->orderBy('id')->get()->toArray()));
  24. $db->beginTransaction();
  25. try {
  26. \plugin\saiadmin\app\model\water\WaterAlertSetting::query()->where('id',1)->update(['alert_qty'=>5,'days_per_barrel'=>3,'urgent_qty'=>2]);
  27. $service=new WaterAlertDashboard($now);
  28. $baseline=$service->dashboard([]);
  29. $sku=$business->save('sku',['name'=>$tag.'-water','liters'=>18.9],1)['id'];
  30. $emptySku=$business->save('sku',['name'=>$tag.'-empty','liters'=>20],1)['id'];
  31. $disabledSku=$business->save('sku',['name'=>$tag.'-disabled','liters'=>20,'status'=>2],1)['id'];
  32. WaterSku::query()->where('id',$sku)->update(['available'=>100]);
  33. $specs=[
  34. 'urgent_qty'=>[2,2,null,null], 'normal_qty'=>[4,0,null,null], 'urgent_days'=>[2,0,999,2],
  35. 'normal_days'=>[4,0,999,3], 'healthy'=>[5,100,999,30], 'healthy_round'=>[10,100,39.9,6],
  36. 'normal_round'=>[3,0,39.9,3], 'zero_usage'=>[4,0,0,null], 'no_rights'=>[1,2,null,null],
  37. 'ordered'=>[1,2,null,null], 'no_stock'=>[1,2,null,null], 'disabled_enterprise'=>[0,2,null,null], 'disabled_sku'=>[0,2,null,null],
  38. ];
  39. $accounts=[]; $enterprises=[];
  40. foreach ($specs as $name=>[$full,$threshold,$daily,$people]) {
  41. $e=$business->save('enterprise',['name'=>$tag.'-'.$name,'contact'=>'TEST','phone'=>'TEST',
  42. 'address'=>$tag.'-模拟地址-'.$name,'region'=>$tag.($name==='normal_days'?'-west':'-east'),
  43. 'status'=>$name==='disabled_enterprise'?2:1,'employee_count'=>$people],1)['id'];
  44. $s=$name==='no_stock'?$emptySku:($name==='disabled_sku'?$disabledSku:$sku);
  45. $accounts[$name]=WaterAccount::query()->insertGetId(['enterprise_id'=>$e,'sku_id'=>$s,'full_qty'=>$full,
  46. 'threshold_qty'=>$threshold,'daily_liters'=>$daily,'entitlement'=>$name==='no_rights'?0:30,'frozen'=>0,'updated_at'=>'2026-09-17 09:00:00']);
  47. $enterprises[$name]=$e;
  48. }
  49. $extra=WaterAccount::query()->insertGetId(['enterprise_id'=>$enterprises['urgent_qty'],'sku_id'=>$emptySku,
  50. 'full_qty'=>20,'threshold_qty'=>2,'entitlement'=>30,'updated_at'=>'2026-09-17 09:00:00']);
  51. foreach ([[$sku,'SN-A'],[$sku,'SN-B'],[$emptySku,'SN-C']] as [$s,$sn]) $business->save('device',[
  52. 'name'=>$tag.'-'.$sn,'sn'=>$tag.'-'.$sn,'enterprise_id'=>$enterprises['urgent_qty'],'sku_id'=>$s],1);
  53. $task=function(string $name,string $at,int $qty,string $status='completed',?string $active=null) use ($enterprises,$sku): int {
  54. return WaterDelivery::query()->insertGetId(['enterprise_id'=>$enterprises[$name],'sku_id'=>$sku,'quantity'=>$qty,
  55. 'address'=>'TEST','status'=>$status,'active_key'=>$active,'created_by'=>1,'created_at'=>$at,'updated_at'=>$at]);
  56. };
  57. $ordered=$task('ordered','2026-09-16 10:00:00',3,'assigned',$enterprises['ordered'].':'.$sku);
  58. $task('healthy','2026-09-17 00:00:00',4); $task('healthy','2026-09-17 12:00:00',5);
  59. $task('healthy','2026-09-17 12:00:01',6); $task('healthy','2026-09-17 10:00:00',7,'cancelled');
  60. $task('healthy','2026-09-11 00:00:00',8); $task('healthy','2026-09-10 23:59:59',9);
  61. $data=$service->dashboard(['keyword'=>$tag,'limit'=>100]); $all=$service->dashboard(['keyword'=>$tag,'scope'=>'all','limit'=>100]);
  62. $byId=array_column($all['list']['data'],null,'id');
  63. check($data['list']['total']===9 && $all['list']['total']===12,'only risks vs all enabled accounts');
  64. check($data['summary']['accounts']-$baseline['summary']['accounts']===12,'global enabled account count');
  65. check($data['summary']['alerts']-$baseline['summary']['alerts']===9,'global alert count');
  66. check($data['summary']['urgent']-$baseline['summary']['urgent']===5 && $data['summary']['normal']-$baseline['summary']['normal']===4,'global urgent and normal counts including null usage');
  67. check($data['summary']['ordered']-$baseline['summary']['ordered']===1,'already ordered risk retained');
  68. 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');
  69. check(count($data['trend'])===7 && $data['trend'][0]['day']==='2026-09-11' && $data['trend'][6]['day']==='2026-09-17','seven days inclusive');
  70. check($data['trend'][0]['count']-$baseline['trend'][0]['count']===1 && $data['trend'][0]['quantity']-$baseline['trend'][0]['quantity']===8,'first-day boundary');
  71. check($data['trend'][6]['count']===$data['summary']['created_today'] && $data['trend'][6]['quantity']===$data['summary']['quantity_today'],'trend reconciles with today cards');
  72. check($data['summary']['accuracy']===null && $data['policy']['accuracy_available']===false && $data['trend_kind']==='delivery_created','no fabricated accuracy or historical alerts');
  73. check($byId[$accounts['urgent_qty']]['device_count']===2 && $byId[$extra]['device_count']===1,'devices aggregated by enterprise and SKU, no multiplied rows');
  74. check($byId[$accounts['urgent_qty']]['days_left']===null && $byId[$accounts['urgent_qty']]['exhausts_at']===null,'no usage is not zero-day prediction');
  75. check($byId[$accounts['zero_usage']]['days_left']===null && $byId[$accounts['zero_usage']]['severity']==='normal','zero usage still triggers quantity normal alert');
  76. check($byId[$accounts['healthy_round']]['days_left']===5.0 && !$byId[$accounts['healthy_round']]['is_alert'],'five-day estimate cannot trigger alert above five barrels');
  77. 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');
  78. 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');
  79. check($byId[$accounts['normal_days']]['severity']==='normal' && $byId[$accounts['normal_days']]['reasons']===['现场满桶少于5桶'],'platform quantity reason despite arbitrary old usage');
  80. check($byId[$accounts['normal_qty']]['reasons']===['现场满桶少于5桶'],'quantity-only reason ignores zero legacy threshold');
  81. check($byId[$accounts['no_rights']]['blocked_reason']==='可用套餐权益不足' && !$byId[$accounts['no_rights']]['can_create'],'no entitlement blocked');
  82. check($byId[$accounts['no_stock']]['blocked_reason']==='仓库可用库存不足' && !$byId[$accounts['no_stock']]['can_create'],'empty warehouse blocked');
  83. check($byId[$accounts['ordered']]['delivery_id']===$ordered && !$byId[$accounts['ordered']]['can_create'],'active task blocks repeat');
  84. check($byId[$accounts['urgent_days']]['can_create'] && !$byId[$accounts['healthy']]['can_create'],'only eligible risks selectable');
  85. check($service->listing(['keyword'=>$tag.'-SN-A'])['total']===1,'device SN search');
  86. check($service->listing(['keyword'=>$tag.'-SN-C','scope'=>'all'])['data'][0]['id']===$extra,'device search respects SKU');
  87. check($service->listing(['keyword'=>$tag.'-模拟地址-normal_qty'])['total']===1,'address search');
  88. check($service->listing(['keyword'=>$tag.'-water','scope'=>'all'])['total']===10,'SKU search excludes disabled enterprises');
  89. check($service->listing(['keyword'=>$tag,'severity'=>'urgent'])['total']===5,'urgent filter');
  90. check($service->listing(['keyword'=>$tag,'severity'=>'normal'])['total']===4,'normal filter includes missing usage');
  91. check($service->listing(['keyword'=>$tag,'scope'=>'all','severity'=>'healthy'])['total']===3,'healthy filter includes null and above-boundary estimates');
  92. check($service->listing(['keyword'=>$tag,'region'=>$tag.'-west'])['total']===1,'region filter');
  93. check($service->listing(['keyword'=>$tag,'region'=>$tag.'-west','severity'=>'urgent'])['total']===0,'combined filters');
  94. check($service->listing(['keyword'=>$tag,'exhaust_date'=>'2026-09-21'])['total']===1,'exhaustion date filter');
  95. check($service->listing(['enterprise_id'=>$enterprises['urgent_qty'],'scope'=>'all'])['total']===2,'enterprise filter retained');
  96. $pages=[];
  97. for ($page=1;$page<=3;$page++) {
  98. $result=$service->dashboard(['keyword'=>$tag,'page'=>$page,'limit'=>4]);
  99. $pages=array_merge($pages,$result['list']['data']);
  100. check($result['summary']===$data['summary'] && $result['trend']===$data['trend'],'global cards and trend independent of pages');
  101. }
  102. check(array_column($pages,'id')===array_column($data['list']['data'],'id'),'stable severity, exhaustion and ID ordering');
  103. check($service->dashboard(['keyword'=>$tag.'-missing'])['summary']===$data['summary'],'summary independent of no-match keyword');
  104. check($service->listing(['keyword'=>$tag,'page'=>99])['data']===[],'out of range page');
  105. check(in_array($tag.'-east',$data['regions'],true) && in_array($tag.'-west',$data['regions'],true),'global region options');
  106. $policyBefore=WaterAccount::query()->find($accounts['healthy'])->toArray();
  107. rejects(fn()=>$business->saveAlertPolicy(['id'=>$accounts['healthy'],'threshold_qty'=>8,'full_qty'=>999,'daily_liters'=>999],1),'retired per-company policy command rejected');
  108. check(WaterAccount::query()->find($accounts['healthy'])->toArray()===$policyBefore,'rejected policy leaves full account unchanged');
  109. check(WaterLedger::query()->where('kind','alert_policy')->where('reference','account:'.$accounts['healthy'])->count()===0,'no audit or mutation on rejected policy');
  110. 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');
  111. 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');
  112. check($byId[$accounts['healthy']]['estimated_daily_liters']===189.0,'thirty people consume ten barrels or 189 liters per day');
  113. check($data['policy']['alert_qty']===5 && $data['policy']['comparison']==='lt' && $data['policy']['days_per_barrel']===3,'platform policy contract');
  114. $enterpriseBefore=WaterEnterprise::query()->find($enterprises['normal_days'])->toArray();
  115. foreach ([-1,0,1.5,10000001,[],true] as $invalid) rejects(fn()=>$business->save('enterprise',array_replace($enterpriseBefore,['employee_count'=>$invalid]),1),'invalid headcount rejected');
  116. check(WaterEnterprise::query()->find($enterprises['normal_days'])->toArray()===$enterpriseBefore,'invalid headcount preserves enterprise');
  117. $business->save('enterprise',array_replace($enterpriseBefore,['employee_count'=>6]),1);
  118. $changed=$service->listing(['enterprise_id'=>$enterprises['normal_days']])['data'][0];
  119. check($changed['days_left']===2.0 && $changed['estimated_daily_liters']===37.8,'headcount edit immediately changes forecast');
  120. check($changed['severity']==='normal' && $changed['full_qty']===4,'headcount change does not mutate stock or quantity severity');
  121. $legacy=$business->listing('accounts',['enterprise_id'=>$enterprises['normal_days']])['data'][0];
  122. check($legacy['estimated_daily_liters']===$changed['estimated_daily_liters'] && $legacy['days_left']===$changed['days_left'],'account and dashboard predictions consistent');
  123. check((new WaterEnterpriseDashboard($now))->detail($enterprises['normal_days'])['enterprise']['employee_count']===6,'enterprise detail exposes persisted headcount');
  124. $omitted=$enterpriseBefore; unset($omitted['employee_count']);
  125. $business->save('enterprise',$omitted,1);
  126. check((int)WaterEnterprise::query()->find($enterprises['normal_days'])->employee_count===6,'legacy save without headcount preserves value');
  127. $business->save('enterprise',array_replace($enterpriseBefore,['employee_count'=>null]),1);
  128. $unknown=$service->listing(['enterprise_id'=>$enterprises['normal_days']])['data'][0];
  129. check($unknown['days_left']===null && $unknown['estimated_daily_liters']===null && $unknown['exhausts_at']===null && $unknown['is_alert'],'clearing headcount removes estimates but preserves alert');
  130. $stockBefore=WaterSku::query()->find($sku)->toArray();
  131. 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');
  132. check(WaterSku::query()->find($sku)->toArray()===$stockBefore && (int)WaterAccount::query()->find($accounts['urgent_qty'])->frozen===0,'failed batch restores stock and entitlement');
  133. $input=['items'=>[['account_id'=>$accounts['urgent_qty'],'quantity'=>3],['account_id'=>$accounts['normal_qty'],'quantity'=>4]]];
  134. $batch=$business->batchCreateDelivery($input,1);
  135. check($batch['count']===2 && (int)WaterSku::query()->find($sku)->available===93,'batch reserves warehouse');
  136. check((int)WaterAccount::query()->find($accounts['urgent_qty'])->frozen===3,'batch freezes rights');
  137. $orderedRow=$service->listing(['keyword'=>$tag.'-urgent_qty'])['data'][0];
  138. check($orderedRow['delivery_id']!==null && !$orderedRow['can_create'] && $orderedRow['available_entitlement']===27,'created task reflected in alert immediately');
  139. rejects(fn()=>$business->batchCreateDelivery($input,1),'repeated batch cannot duplicate active tasks');
  140. foreach ($batch['created'] as $task) $business->transition(['id'=>$task['id'],'action'=>'cancel'],1);
  141. check($service->listing(['keyword'=>$tag.'-urgent_qty'])['data'][0]['can_create'],'cancel releases task lock and restores eligibility');
  142. 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) {
  143. rejects(fn()=>$service->dashboard($invalid),'invalid dashboard filters');
  144. rejects(fn()=>$service->listing($invalid),'invalid legacy listing filters');
  145. }
  146. $reflection=new ReflectionClass(WaterController::class); $controller=$reflection->newInstanceWithoutConstructor();
  147. $reflection->getProperty('adminId')->setValue($controller,0);
  148. rejects(fn()=>$controller->alertDashboard(request('GET','/core/water/alerts/dashboard')),'unauthorized read');
  149. rejects(fn()=>$controller->save(request('POST','/core/water/alerts',['action'=>'policy','id'=>$accounts['urgent_qty'],'threshold_qty'=>4]),'alerts'),'unauthorized policy write');
  150. rejects(fn()=>$controller->save(request('POST','/core/water/dispatch',['action'=>'batch_create']+$input),'dispatch'),'unauthorized batch');
  151. $reflection->getProperty('adminId')->setValue($controller,1);
  152. check((json_decode($controller->alertDashboard(request('GET','/core/water/alerts/dashboard'))->rawBody(),true)['code']??0)===200,'dashboard controller');
  153. rejects(fn()=>$controller->save(request('POST','/core/water/alerts',['action'=>'policy','id'=>$accounts['urgent_qty'],'threshold_qty'=>3]),'alerts'),'admin cannot override platform threshold');
  154. } finally { $db->rollBack(); }
  155. foreach ($before as $model=>$hash) check(hash('sha256',serialize($model::query()->orderBy('id')->get()->toArray()))===$hash,'original business model restored');
  156. check(!WaterEnterprise::query()->where('name','like',$tag.'%')->exists() && !WaterDevice::query()->where('sn','like',$tag.'%')->exists(),'enterprise/device fixtures rolled back');
  157. if (in_array('--http',$argv,true)) {
  158. $token=\Tinywan\Jwt\JwtToken::generateToken(['id'=>1,'username'=>'admin','plat'=>'saiadmin','type'=>'web','client'=>$tag,'access_exp'=>120,'refresh_exp'=>120])['access_token'];
  159. $livePolicy=(new \plugin\saiadmin\app\service\alerts\AlertSettings())->current();
  160. foreach (['alerts/dashboard?limit=1','alerts/dashboard?severity=urgent','alerts?scope=all&limit=5'] as $path) {
  161. $ch=curl_init('http://127.0.0.1:8787/core/water/'.$path);
  162. curl_setopt_array($ch,[CURLOPT_RETURNTRANSFER=>true,CURLOPT_TIMEOUT=>15,CURLOPT_HTTPHEADER=>['Authorization: Bearer '.$token]]);
  163. $raw=curl_exec($ch); $status=curl_getinfo($ch,CURLINFO_HTTP_CODE); curl_close($ch); $body=json_decode((string)$raw,true);
  164. check($status===200 && ($body['code']??0)===200,'HTTP '.$path);
  165. $payload=$body['data'];
  166. if (str_starts_with($path,'alerts/dashboard')) {
  167. check(($payload['policy']['alert_qty']??null)===$livePolicy['alert_qty'] && ($payload['policy']['comparison']??null)==='lt','HTTP platform policy active');
  168. $payload=$payload['list'];
  169. }
  170. foreach ($payload['data'] as $row) {
  171. check(array_key_exists('employee_count',$row) && array_key_exists('estimated_daily_liters',$row)
  172. && $row['is_alert']===($row['full_qty']<$livePolicy['alert_qty']),'HTTP uses headcount contract and strict quantity rule');
  173. }
  174. }
  175. $current=(new WaterAlertDashboard())->dashboard([]);
  176. echo 'Current alerts: '.json_encode($current['summary'],JSON_UNESCAPED_UNICODE)."\n";
  177. }
  178. echo "PASS: $checks alert checks; all fixtures rolled back.\n";