load(); \support\App::loadAllConfig(['route']); date_default_timezone_set('Asia/Shanghai'); use plugin\saiadmin\app\model\water\{WaterEnterprise,WaterAgent,WaterSku,WaterAccount,WaterDevice,WaterDeviceUsage,WaterOrder,WaterDelivery,WaterLedger}; use plugin\saiadmin\app\service\{WaterBusiness,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 workbook(string $content): array { $path=tempnam(sys_get_temp_dir(),'enterprise-test-'); $reader=new \OpenSpout\Reader\XLSX\Reader(); 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; } finally { $reader->close(); unlink($path); } } $now=new DateTimeImmutable('2026-09-16 12:00:00',new DateTimeZone('Asia/Shanghai')); $service=new WaterEnterpriseDashboard($now); $business=new WaterBusiness(); $tag='ENT-'.bin2hex(random_bytes(5)); $db=(new WaterEnterprise())->getConnection(); $db->beginTransaction(); try { \plugin\saiadmin\app\model\water\WaterAlertSetting::query()->where('id',1)->update(['alert_qty'=>5,'days_per_barrel'=>3,'urgent_qty'=>2]); $before=$service->dashboard([]); $agent=$business->save('agent',['name'=>$tag.'-agent','contact'=>'test','phone'=>'TEST'],1)['id']; $disabledAgent=$business->save('agent',['name'=>$tag.'-disabled','contact'=>'test','phone'=>'TEST','status'=>2],1)['id']; $sku=$business->save('sku',['name'=>$tag.'-sku','liters'=>10],1)['id']; $sku2=$business->save('sku',['name'=>$tag.'-sku2','liters'=>20],1)['id']; $ids=[]; foreach (['healthy','urgent','pending','ordered','unconfigured','disabled','mixed'] as $i=>$state) { $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']; WaterEnterprise::query()->where('id',$ids[$state])->update(['created_at'=>'2026-09-01 00:00:00']); if ($state==='unconfigured') continue; WaterAccount::query()->insert(['enterprise_id'=>$ids[$state],'sku_id'=>$sku,'entitlement'=>$i===0?20:5,'frozen'=>$i===0?10:0, 'full_qty'=>$state==='healthy'?20:($state==='pending'?4:1),'empty_qty'=>2,'threshold_qty'=>5,'updated_at'=>'2026-09-16 12:00:00']); } // Two SKUs, multiple devices and orders must not multiply stock or rights. 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']); WaterAccount::query()->insert(['enterprise_id'=>$ids['mixed'],'sku_id'=>$sku2,'entitlement'=>2,'full_qty'=>0,'updated_at'=>'2026-09-16 12:00:00']); foreach (['ordered','mixed'] as $state) { WaterDelivery::query()->insert(['enterprise_id'=>$ids[$state],'sku_id'=>$sku,'quantity'=>1,'address'=>'TEST','created_by'=>1, 'active_key'=>$ids[$state].':'.$sku,'created_at'=>'2026-09-16 12:00:00','updated_at'=>'2026-09-16 12:00:00']); } $devices=[]; foreach ([null,'2026-09-16 11:45:00','2026-09-16 11:44:59'] as $i=>$seen) { $devices[]=$business->save('device',['sn'=>$tag.'-'.$i,'name'=>'device','enterprise_id'=>$ids['healthy'],'sku_id'=>$sku],1)['id']; WaterDevice::query()->where('id',$devices[$i])->update(['last_seen'=>$seen]); } WaterDeviceUsage::query()->insert(['device_id'=>$devices[0],'day'=>'2026-08-18','liters'=>3]); 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']); // A previous-month first purchase plus a current-month purchase is a repeat buyer. $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]]; 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]); // Pending was already a customer before this month, so exclude it from the new-customer funnel. WaterEnterprise::query()->where('id',$ids['pending'])->update(['created_at'=>'2026-08-17 23:59:59']); $first=$service->dashboard(['keyword'=>' '.$tag.' ','limit'=>3]); $second=$service->dashboard(['keyword'=>$tag,'page'=>2,'limit'=>3]); $third=$service->dashboard(['keyword'=>$tag,'page'=>3,'limit'=>3]); $rows=array_merge($first['list']['data'],$second['list']['data'],$third['list']['data']); check($first['list']['total']===7 && count($rows)===7,'paginated search'); check(array_column($rows,'id')===array_reverse(array_values($ids)),'stable pagination'); $byId=array_column($rows,null,'id'); foreach ($ids as $state=>$id) check($byId[$id]['replenishment_status']===($state==='mixed'?'urgent':$state),'replenishment '.$state); $healthy=$byId[$ids['healthy']]; 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); check($healthy['latest_order_at']==='2026-09-16 10:00:00','latest order deterministic'); check($first['summary']===$second['summary'] && $first['summary']===$service->dashboard(['keyword'=>'missing-'.$tag])['summary'],'global summary independent of filters and pages'); check($first['summary']['total']-$before['summary']['total']===7,'total delta'); check($first['summary']['new_month']-$before['summary']['new_month']===6,'new customer month boundary'); check($first['summary']['active']-$before['summary']['active']===2,'actual usage and exchange count distinct enterprises'); check($first['summary']['entitlement']-$before['summary']['entitlement']===77,'rights include frozen once'); check($first['summary']['repurchase_buyers']-$before['summary']['repurchase_buyers']===3,'monthly buyer denominator'); check($first['summary']['repeat_buyers']-$before['summary']['repeat_buyers']===2,'repeat buyer includes previous first purchase'); foreach (['registered'=>6,'purchased'=>2,'repeated'=>1] as $key=>$delta) check($first['funnel'][$key]-$before['funnel'][$key]===$delta,'cohort funnel '.$key); check(count($first['trend'])===30 && $first['trend'][0]['day']==='2026-08-18' && $first['trend'][29]['day']==='2026-09-16','30-day boundaries'); check($first['trend'][0]['total']-$before['trend'][0]['total']===1 && $first['trend'][29]['total']-$before['trend'][29]['total']===7,'cumulative growth'); check($service->dashboard(['keyword'=>$tag,'status'=>'2'])['list']['total']===1,'disabled filter'); check($service->dashboard(['keyword'=>$tag,'agent_id'=>$agent])['list']['total']===1,'agent filter'); check($service->dashboard(['keyword'=>$tag,'agent_id'=>0])['list']['total']===6,'direct filter'); foreach (['empty'=>1,'low'=>5,'sufficient'=>1] as $balance=>$total) check($service->dashboard(['keyword'=>$tag,'balance'=>$balance])['list']['total']===$total,'balance '.$balance); check($service->dashboard(['keyword'=>'phone-'.$tag])['list']['total']===7,'phone search'); $detail=$service->detail($ids['healthy']); check(count($detail['accounts'])===2 && count($detail['orders'])===2 && $detail['accounts'][0]['sku']['name']===$tag.'-sku','detail model relations'); $edit=WaterEnterprise::query()->find($ids['healthy'])->toArray(); $business->save('enterprise',$edit+['longitude'=>113.1,'latitude'=>22.2],1); check((int)WaterEnterprise::query()->find($ids['healthy'])->agent_id===$agent,'editor agent preserved'); rejects(fn()=>$business->save('enterprise',array_replace($edit,['agent_id'=>$disabledAgent]),1),'disabled agent assignment rejected'); rejects(fn()=>$business->save('enterprise',array_replace($edit,['agent_id'=>99999999]),1),'missing agent rejected'); $batch=$service->batchStatus(['ids'=>[$ids['healthy'],$ids['urgent']],'status'=>2],1); check($batch['changed']===2,'batch changed count'); check($service->batchStatus(['ids'=>[$ids['healthy'],$ids['urgent']],'status'=>2],1)['changed']===0,'batch idempotent'); check(WaterLedger::query()->whereIn('enterprise_id',[$ids['healthy'],$ids['urgent']])->where('note','批量停用企业')->count()===2,'batch audit'); check($service->detail($ids['healthy'])['enterprise']['entitlement']===50,'batch preserves rights'); rejects(fn()=>$service->batchStatus(['ids'=>[$ids['healthy'],99999999],'status'=>1],1),'all-or-nothing missing id'); check((int)WaterEnterprise::query()->find($ids['healthy'])->status===2,'failed batch rolled back'); $service->batchStatus(['ids'=>[$ids['healthy'],$ids['urgent']],'status'=>1],1); $book=workbook($service->export(['keyword'=>$tag,'page'=>2,'limit'=>1])); check(count($book)===8 && count($book[0])===17,'export all filtered rows'); check($book[7][0]==="'=".$tag.'-healthy','export formula escaping'); check(count(workbook($service->export(['keyword'=>$tag,'agent_id'=>$agent])))===2,'export filter matches list'); foreach ([['keyword'=>['bad']],['keyword'=>str_repeat('字',121)],['status'=>'bad'],['balance'=>'bad'],['agent_id'=>'bad']] as $input) rejects(fn()=>$service->dashboard($input),'invalid filter'); rejects(fn()=>$service->batchStatus(['ids'=>[],'status'=>1],1),'empty batch'); rejects(fn()=>$service->detail(99999999),'missing detail'); $reflection=new ReflectionClass(WaterController::class); $controller=$reflection->newInstanceWithoutConstructor(); $reflection->getProperty('adminId')->setValue($controller,99999999); $request=new \support\Request("GET /core/water/enterprises/dashboard HTTP/1.1\r\nHost: localhost\r\n\r\n"); foreach (['enterpriseDashboard','enterpriseDetail','enterpriseExport','enterpriseBatchStatus'] as $method) rejects(fn()=>$controller->$method($request),'authorization '.$method); $reflection->getProperty('adminId')->setValue($controller,1); $body=json_encode(['keyword'=>$tag]); $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); check(count(workbook($controller->enterpriseExport($request)->rawBody()))===8,'JSON export controller'); } finally { $db->rollBack(); } check(WaterEnterprise::query()->where('name','like','%'.$tag.'%')->count()===0,'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']; foreach (['dashboard?limit=1','export'] as $path) { $ch=curl_init('http://127.0.0.1:8787/core/water/enterprises/'.$path); $options=[CURLOPT_RETURNTRANSFER=>true,CURLOPT_TIMEOUT=>20,CURLOPT_HTTPHEADER=>['Content-Type: application/json','Authorization: Bearer '.$token]]; if ($path==='export') $options += [CURLOPT_POST=>true,CURLOPT_POSTFIELDS=>'{}']; curl_setopt_array($ch,$options); $raw=curl_exec($ch); $status=curl_getinfo($ch,CURLINFO_HTTP_CODE); curl_close($ch); check($status===200,'HTTP status '.$path); if ($path==='export') check(substr($raw,0,2)==='PK','HTTP workbook'); else { $json=json_decode($raw,true); check(($json['code']??0)===200 && count($json['data']['trend']??[])===30,'HTTP dashboard contract'); } } unset($token); } echo "PASS: $checks enterprise checks; fixtures rolled back.\n";