water_enterprise_dashboard_test.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130
  1. <?php
  2. /** Enterprise dashboard integration regression; all fixtures and batch writes 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\{WaterEnterprise,WaterAgent,WaterSku,WaterAccount,WaterDevice,WaterDeviceUsage,WaterOrder,WaterDelivery,WaterLedger};
  8. use plugin\saiadmin\app\service\{WaterBusiness,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 workbook(string $content): array {
  15. $path=tempnam(sys_get_temp_dir(),'enterprise-test-'); $reader=new \OpenSpout\Reader\XLSX\Reader();
  16. try { file_put_contents($path,$content); $reader->open($path); $rows=[]; foreach ($reader->getSheetIterator() as $sheet) foreach ($sheet->getRowIterator() as $row) $rows[]=$row->toArray(); return $rows; }
  17. finally { $reader->close(); unlink($path); }
  18. }
  19. $now=new DateTimeImmutable('2026-09-16 12:00:00',new DateTimeZone('Asia/Shanghai'));
  20. $service=new WaterEnterpriseDashboard($now); $business=new WaterBusiness();
  21. $tag='ENT-'.bin2hex(random_bytes(5)); $db=(new WaterEnterprise())->getConnection();
  22. $db->beginTransaction();
  23. try {
  24. \plugin\saiadmin\app\model\water\WaterAlertSetting::query()->where('id',1)->update(['alert_qty'=>5,'days_per_barrel'=>3,'urgent_qty'=>2]);
  25. $before=$service->dashboard([]);
  26. $agent=$business->save('agent',['name'=>$tag.'-agent','contact'=>'test','phone'=>'TEST'],1)['id'];
  27. $disabledAgent=$business->save('agent',['name'=>$tag.'-disabled','contact'=>'test','phone'=>'TEST','status'=>2],1)['id'];
  28. $sku=$business->save('sku',['name'=>$tag.'-sku','liters'=>10],1)['id'];
  29. $sku2=$business->save('sku',['name'=>$tag.'-sku2','liters'=>20],1)['id'];
  30. $ids=[];
  31. foreach (['healthy','urgent','pending','ordered','unconfigured','disabled','mixed'] as $i=>$state) {
  32. $ids[$state]=$business->save('enterprise',['name'=>($i===0?'=':'').$tag.'-'.$state,'contact'=>'contact-'.$tag,'phone'=>'phone-'.$tag,'address'=>'TEST ONLY','agent_id'=>$i===0?$agent:0,'status'=>$state==='disabled'?2:1],1)['id'];
  33. WaterEnterprise::query()->where('id',$ids[$state])->update(['created_at'=>'2026-09-01 00:00:00']);
  34. if ($state==='unconfigured') continue;
  35. WaterAccount::query()->insert(['enterprise_id'=>$ids[$state],'sku_id'=>$sku,'entitlement'=>$i===0?20:5,'frozen'=>$i===0?10:0,
  36. 'full_qty'=>$state==='healthy'?20:($state==='pending'?4:1),'empty_qty'=>2,'threshold_qty'=>5,'updated_at'=>'2026-09-16 12:00:00']);
  37. }
  38. // Two SKUs, multiple devices and orders must not multiply stock or rights.
  39. WaterAccount::query()->insert(['enterprise_id'=>$ids['healthy'],'sku_id'=>$sku2,'entitlement'=>30,'frozen'=>10,'full_qty'=>9,'empty_qty'=>3,'updated_at'=>'2026-09-16 12:00:00']);
  40. WaterAccount::query()->insert(['enterprise_id'=>$ids['mixed'],'sku_id'=>$sku2,'entitlement'=>2,'full_qty'=>0,'updated_at'=>'2026-09-16 12:00:00']);
  41. foreach (['ordered','mixed'] as $state) {
  42. WaterDelivery::query()->insert(['enterprise_id'=>$ids[$state],'sku_id'=>$sku,'quantity'=>1,'address'=>'TEST','created_by'=>1,
  43. 'active_key'=>$ids[$state].':'.$sku,'created_at'=>'2026-09-16 12:00:00','updated_at'=>'2026-09-16 12:00:00']);
  44. }
  45. $devices=[];
  46. foreach ([null,'2026-09-16 11:45:00','2026-09-16 11:44:59'] as $i=>$seen) {
  47. $devices[]=$business->save('device',['sn'=>$tag.'-'.$i,'name'=>'device','enterprise_id'=>$ids['healthy'],'sku_id'=>$sku],1)['id'];
  48. WaterDevice::query()->where('id',$devices[$i])->update(['last_seen'=>$seen]);
  49. }
  50. WaterDeviceUsage::query()->insert(['device_id'=>$devices[0],'day'=>'2026-08-18','liters'=>3]);
  51. WaterLedger::query()->insert(['enterprise_id'=>$ids['urgent'],'sku_id'=>$sku,'kind'=>'exchange','quantity'=>-1,'reference'=>$tag,'created_by'=>1,'created_at'=>'2026-09-16 11:00:00']);
  52. // A previous-month first purchase plus a current-month purchase is a repeat buyer.
  53. $orderRows=[['healthy','2026-09-02 10:00:00',100],['healthy','2026-09-16 10:00:00',200],['urgent','2026-09-03 10:00:00',300],['pending','2026-08-31 23:59:59',400],['pending','2026-09-01 00:00:00',500]];
  54. foreach ($orderRows as $i=>[$state,$time,$amount]) WaterOrder::query()->insert(['enterprise_id'=>$ids[$state],'sku_id'=>$sku,'package_id'=>0,'package_name'=>'TEST','reference'=>$tag.'-order-'.$i,'buckets'=>1,'amount_cents'=>$amount,'created_by'=>1,'created_at'=>$time]);
  55. // Pending was already a customer before this month, so exclude it from the new-customer funnel.
  56. WaterEnterprise::query()->where('id',$ids['pending'])->update(['created_at'=>'2026-08-17 23:59:59']);
  57. $first=$service->dashboard(['keyword'=>' '.$tag.' ','limit'=>3]);
  58. $second=$service->dashboard(['keyword'=>$tag,'page'=>2,'limit'=>3]);
  59. $third=$service->dashboard(['keyword'=>$tag,'page'=>3,'limit'=>3]);
  60. $rows=array_merge($first['list']['data'],$second['list']['data'],$third['list']['data']);
  61. check($first['list']['total']===7 && count($rows)===7,'paginated search');
  62. check(array_column($rows,'id')===array_reverse(array_values($ids)),'stable pagination');
  63. $byId=array_column($rows,null,'id');
  64. foreach ($ids as $state=>$id) check($byId[$id]['replenishment_status']===($state==='mixed'?'urgent':$state),'replenishment '.$state);
  65. $healthy=$byId[$ids['healthy']];
  66. foreach (['entitlement'=>50,'frozen'=>20,'available_entitlement'=>30,'full_qty'=>29,'empty_qty'=>5,'device_count'=>3,'online_count'=>1,'unconnected_count'=>1,'latest_order_amount'=>200] as $key=>$expected) check($healthy[$key]===$expected,'aggregate '.$key);
  67. check($healthy['latest_order_at']==='2026-09-16 10:00:00','latest order deterministic');
  68. check($first['summary']===$second['summary'] && $first['summary']===$service->dashboard(['keyword'=>'missing-'.$tag])['summary'],'global summary independent of filters and pages');
  69. check($first['summary']['total']-$before['summary']['total']===7,'total delta');
  70. check($first['summary']['new_month']-$before['summary']['new_month']===6,'new customer month boundary');
  71. check($first['summary']['active']-$before['summary']['active']===2,'actual usage and exchange count distinct enterprises');
  72. check($first['summary']['entitlement']-$before['summary']['entitlement']===77,'rights include frozen once');
  73. check($first['summary']['repurchase_buyers']-$before['summary']['repurchase_buyers']===3,'monthly buyer denominator');
  74. check($first['summary']['repeat_buyers']-$before['summary']['repeat_buyers']===2,'repeat buyer includes previous first purchase');
  75. foreach (['registered'=>6,'purchased'=>2,'repeated'=>1] as $key=>$delta) check($first['funnel'][$key]-$before['funnel'][$key]===$delta,'cohort funnel '.$key);
  76. check(count($first['trend'])===30 && $first['trend'][0]['day']==='2026-08-18' && $first['trend'][29]['day']==='2026-09-16','30-day boundaries');
  77. check($first['trend'][0]['total']-$before['trend'][0]['total']===1 && $first['trend'][29]['total']-$before['trend'][29]['total']===7,'cumulative growth');
  78. check($service->dashboard(['keyword'=>$tag,'status'=>'2'])['list']['total']===1,'disabled filter');
  79. check($service->dashboard(['keyword'=>$tag,'agent_id'=>$agent])['list']['total']===1,'agent filter');
  80. check($service->dashboard(['keyword'=>$tag,'agent_id'=>0])['list']['total']===6,'direct filter');
  81. foreach (['empty'=>1,'low'=>5,'sufficient'=>1] as $balance=>$total) check($service->dashboard(['keyword'=>$tag,'balance'=>$balance])['list']['total']===$total,'balance '.$balance);
  82. check($service->dashboard(['keyword'=>'phone-'.$tag])['list']['total']===7,'phone search');
  83. $detail=$service->detail($ids['healthy']);
  84. check(count($detail['accounts'])===2 && count($detail['orders'])===2 && $detail['accounts'][0]['sku']['name']===$tag.'-sku','detail model relations');
  85. $edit=WaterEnterprise::query()->find($ids['healthy'])->toArray();
  86. $business->save('enterprise',$edit+['longitude'=>113.1,'latitude'=>22.2],1);
  87. check((int)WaterEnterprise::query()->find($ids['healthy'])->agent_id===$agent,'editor agent preserved');
  88. rejects(fn()=>$business->save('enterprise',array_replace($edit,['agent_id'=>$disabledAgent]),1),'disabled agent assignment rejected');
  89. rejects(fn()=>$business->save('enterprise',array_replace($edit,['agent_id'=>99999999]),1),'missing agent rejected');
  90. $batch=$service->batchStatus(['ids'=>[$ids['healthy'],$ids['urgent']],'status'=>2],1);
  91. check($batch['changed']===2,'batch changed count');
  92. check($service->batchStatus(['ids'=>[$ids['healthy'],$ids['urgent']],'status'=>2],1)['changed']===0,'batch idempotent');
  93. check(WaterLedger::query()->whereIn('enterprise_id',[$ids['healthy'],$ids['urgent']])->where('note','批量停用企业')->count()===2,'batch audit');
  94. check($service->detail($ids['healthy'])['enterprise']['entitlement']===50,'batch preserves rights');
  95. rejects(fn()=>$service->batchStatus(['ids'=>[$ids['healthy'],99999999],'status'=>1],1),'all-or-nothing missing id');
  96. check((int)WaterEnterprise::query()->find($ids['healthy'])->status===2,'failed batch rolled back');
  97. $service->batchStatus(['ids'=>[$ids['healthy'],$ids['urgent']],'status'=>1],1);
  98. $book=workbook($service->export(['keyword'=>$tag,'page'=>2,'limit'=>1]));
  99. check(count($book)===8 && count($book[0])===17,'export all filtered rows');
  100. check($book[7][0]==="'=".$tag.'-healthy','export formula escaping');
  101. check(count(workbook($service->export(['keyword'=>$tag,'agent_id'=>$agent])))===2,'export filter matches list');
  102. foreach ([['keyword'=>['bad']],['keyword'=>str_repeat('字',121)],['status'=>'bad'],['balance'=>'bad'],['agent_id'=>'bad']] as $input) rejects(fn()=>$service->dashboard($input),'invalid filter');
  103. rejects(fn()=>$service->batchStatus(['ids'=>[],'status'=>1],1),'empty batch');
  104. rejects(fn()=>$service->detail(99999999),'missing detail');
  105. $reflection=new ReflectionClass(WaterController::class); $controller=$reflection->newInstanceWithoutConstructor();
  106. $reflection->getProperty('adminId')->setValue($controller,99999999);
  107. $request=new \support\Request("GET /core/water/enterprises/dashboard HTTP/1.1\r\nHost: localhost\r\n\r\n");
  108. foreach (['enterpriseDashboard','enterpriseDetail','enterpriseExport','enterpriseBatchStatus'] as $method) rejects(fn()=>$controller->$method($request),'authorization '.$method);
  109. $reflection->getProperty('adminId')->setValue($controller,1);
  110. $body=json_encode(['keyword'=>$tag]);
  111. $request=new \support\Request("POST /core/water/enterprises/export HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: ".strlen($body)."\r\n\r\n".$body);
  112. check(count(workbook($controller->enterpriseExport($request)->rawBody()))===8,'JSON export controller');
  113. } finally { $db->rollBack(); }
  114. check(WaterEnterprise::query()->where('name','like','%'.$tag.'%')->count()===0,'fixtures rolled back');
  115. if (in_array('--http',$argv,true)) {
  116. $token=\Tinywan\Jwt\JwtToken::generateToken(['id'=>1,'username'=>'admin','plat'=>'saiadmin','type'=>'web','client'=>$tag,'access_exp'=>120,'refresh_exp'=>120])['access_token'];
  117. foreach (['dashboard?limit=1','export'] as $path) {
  118. $ch=curl_init('http://127.0.0.1:8787/core/water/enterprises/'.$path);
  119. $options=[CURLOPT_RETURNTRANSFER=>true,CURLOPT_TIMEOUT=>20,CURLOPT_HTTPHEADER=>['Content-Type: application/json','Authorization: Bearer '.$token]];
  120. if ($path==='export') $options += [CURLOPT_POST=>true,CURLOPT_POSTFIELDS=>'{}'];
  121. curl_setopt_array($ch,$options); $raw=curl_exec($ch); $status=curl_getinfo($ch,CURLINFO_HTTP_CODE); curl_close($ch);
  122. check($status===200,'HTTP status '.$path);
  123. if ($path==='export') check(substr($raw,0,2)==='PK','HTTP workbook');
  124. else { $json=json_decode($raw,true); check(($json['code']??0)===200 && count($json['data']['trend']??[])===30,'HTTP dashboard contract'); }
  125. }
  126. unset($token);
  127. }
  128. echo "PASS: $checks enterprise checks; fixtures rolled back.\n";