water_finance_dashboard_test.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167
  1. <?php
  2. /** Finance reconciliation regression; all fixtures and receipt effects 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\{WaterModel,WaterOrder,WaterEnterprise,WaterAccount,WaterSku,WaterLedger};
  8. use plugin\saiadmin\app\service\{WaterBusiness,WaterFinanceDashboard};
  9. use plugin\saiadmin\app\service\finance\FinanceFilters;
  10. use plugin\saiadmin\app\controller\water\WaterController;
  11. use plugin\saiadmin\exception\ApiException;
  12. $checks=0;
  13. function check(bool $ok,string $label): void { global $checks; $checks++; if (!$ok) throw new RuntimeException($label); }
  14. function rejects(callable $fn,string $label): void { try {$fn();} catch (ApiException $e) {check(true,$label);return;} throw new RuntimeException($label); }
  15. function workbook(string $bytes): array {
  16. $path=tempnam(sys_get_temp_dir(),'finance-test-');
  17. $reader=new \OpenSpout\Reader\XLSX\Reader();
  18. try {
  19. file_put_contents($path,$bytes); $reader->open($path); $rows=[];
  20. foreach ($reader->getSheetIterator() as $sheet) foreach ($sheet->getRowIterator() as $row) $rows[]=$row->toArray();
  21. return $rows;
  22. } finally { $reader->close(); unlink($path); }
  23. }
  24. function request(string $method,string $path,array $data=[]): \support\Request {
  25. $body=$method==='POST'?json_encode($data):'';
  26. 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);
  27. }
  28. $now=new DateTimeImmutable('2026-09-17 12:00:00',new DateTimeZone('Asia/Shanghai'));
  29. $finance=new WaterFinanceDashboard($now); $business=new WaterBusiness();
  30. $tag='FIN-'.bin2hex(random_bytes(5));
  31. $beforeOrders=WaterOrder::query()->count(); $beforeLedger=WaterLedger::query()->count();
  32. $db=(new WaterOrder())->getConnection(); $db->beginTransaction();
  33. try {
  34. $baseline=$finance->dashboard([]);
  35. $enterprise=$business->save('enterprise',['name'=>'=客户-'.$tag,'contact'=>'TEST','phone'=>'TEST','address'=>'测试,不实际配送'],1)['id'];
  36. $fixtures=[
  37. ['2026-08-31 23:59:59',100,'prev','standard'],
  38. ['2026-09-01 00:00:00',200,'first','standard'],
  39. ['2026-09-16 23:59:59',300,'yesterday','standard'],
  40. ['2026-09-17 00:00:00',400,'today','standard'],
  41. ['2026-09-17 12:00:00',500,'now','standard'],
  42. ['2026-09-17 12:00:01',600,'future','standard'],
  43. ['2026-09-18 00:00:00',700,'tomorrow','standard'],
  44. ['2026-09-10 00:00:00',800,'demo-ref','standard'],
  45. ['2026-09-10 00:00:00',900,'demo-package','测试套餐'],
  46. ['2004-02-29 23:59:59',12345,'leap','@SUM(1,1)'],
  47. ['2004-03-01 00:00:00',10000,'march','standard']
  48. ];
  49. $ids=[];
  50. foreach ($fixtures as [$at,$amount,$key,$package]) {
  51. $reference=($key==='demo-ref'?'TEST-':($key==='leap'?'+':'' )).$tag.'-'.$key;
  52. $ids[$key]=WaterOrder::query()->insertGetId(['enterprise_id'=>$enterprise,'package_id'=>0,'sku_id'=>0,
  53. 'package_name'=>$package,'buckets'=>2,'amount_cents'=>$amount,'reference'=>$reference,'created_by'=>1,'created_at'=>$at]);
  54. }
  55. $query=['keyword'=>' '.$tag.' ','limit'=>2];
  56. $first=$finance->dashboard($query);
  57. $second=$finance->dashboard(array_replace($query,['page'=>2]));
  58. $third=$finance->dashboard(array_replace($query,['page'=>3]));
  59. check($first['list']['total']===6 && count($first['list']['data'])===2 && count($third['list']['data'])===2,'month, current time and pagination');
  60. $expected=[$ids['now'],$ids['today'],$ids['yesterday'],$ids['demo-package'],$ids['demo-ref'],$ids['first']];
  61. check(array_column(array_merge($first['list']['data'],$second['list']['data'],$third['list']['data']),'id')===$expected,'stable time and ID ordering');
  62. check($first['summary']===$second['summary'] && $first['trend']===$third['trend'],'pagination does not alter cards or trend');
  63. check($first['summary']['month_cents']-$baseline['summary']['month_cents']===3100,'month sum excludes past month and future');
  64. check($first['summary']['today_cents']-$baseline['summary']['today_cents']===900,'today includes midnight and exact current second');
  65. check($first['summary']['month_count']-$baseline['summary']['month_count']===6,'monthly count');
  66. check($first['summary']['today_count']-$baseline['summary']['today_count']===2,'today count');
  67. check($first['summary']['demo_cents']-$baseline['summary']['demo_cents']===1700,'both test markers counted once');
  68. check($first['summary']['demo_count']-$baseline['summary']['demo_count']===2,'demo count');
  69. check(count($first['trend'])===17 && $first['trend'][16]['day']==='2026-09-17','daily trend ends today');
  70. check(array_sum(array_column($first['trend'],'receipt_cents'))===$first['summary']['month_cents'],'trend reconciles with monthly cents');
  71. check(array_sum(array_column($first['trend'],'count'))===$first['summary']['month_count'],'trend reconciles with monthly count');
  72. $empty=$finance->dashboard(['keyword'=>$tag.'-missing']);
  73. check($empty['list']['total']===0 && $empty['summary']===$first['summary'],'keyword only affects details');
  74. check($finance->dashboard(['keyword'=>$tag.'-now'])['list']['total']===1,'reference search');
  75. check($finance->dashboard(['keyword'=>'=客户-'.$tag])['list']['total']===6,'enterprise search');
  76. check($finance->dashboard(['keyword'=>$tag,'page'=>10])['list']['data']===[],'out of range page is empty');
  77. $regular=$finance->dashboard(['keyword'=>$tag,'source'=>'regular']);
  78. $demo=$finance->dashboard(['keyword'=>$tag,'source'=>'demo']);
  79. check($regular['list']['total']===4 && $demo['list']['total']===2,'source partition');
  80. check($regular['summary']['demo_cents']===0 && $regular['summary']['demo_count']===0,'regular summary excludes demo');
  81. check($regular['summary']['month_cents']+$demo['summary']['month_cents']===$first['summary']['month_cents'],'source totals reconcile');
  82. check(count(array_filter($demo['list']['data'],fn($r)=>$r['is_demo']))===2,'test rows marked');
  83. foreach (['today_refund_cents','month_refund_cents','pending_withdrawal_cents','net_rate'] as $field) check($first['summary'][$field]===null,'unavailable '.$field.' is null');
  84. check($first['summary']['pending_commission_cents']===$baseline['summary']['pending_commission_cents'],'only recorded commissions are shown');
  85. check($first['capabilities']['commissions']===true && $first['capabilities']['withdrawals']===false && $first['withdrawals']===[],'commission accrual does not enable withdrawals');
  86. $leap=$finance->dashboard(['month'=>'2004-02','keyword'=>$tag]);
  87. check(count($leap['trend'])===29 && $leap['period']['end']==='2004-02-29','leap month full range');
  88. check($leap['list']['total']===1 && $leap['list']['data'][0]['amount_cents']===12345,'month final second included, next month excluded');
  89. check($leap['trend'][0]['receipt_cents']===0,'missing dates filled with zero');
  90. check($leap['summary']['today_cents']===$first['summary']['today_cents'],'today independent of selected month');
  91. check(count($finance->dashboard(['month'=>'2003-02'])['trend'])===28,'non-leap February');
  92. $timezone=new WaterFinanceDashboard(new DateTimeImmutable('2026-08-31 16:00:00',new DateTimeZone('UTC')));
  93. check($timezone->dashboard(['keyword'=>$tag])['period']['month']==='2026-09','Shanghai date conversion');
  94. $export=workbook($finance->export(['keyword'=>$tag,'page'=>3,'limit'=>1]));
  95. check(count($export)===7,'export ignores pagination');
  96. check(array_column(array_slice($export,1),0)===array_map(fn($id)=>'WS'.str_pad((string)$id,10,'0',STR_PAD_LEFT),$expected),'export matches full ordered list');
  97. check($export[1][2]==="'=客户-".$tag,'enterprise formula injection prevented');
  98. check($export[1][6]===5,'amount exported in yuan');
  99. $special=workbook($finance->export(['month'=>'2004-02','keyword'=>$tag]));
  100. check($special[1][3]==="'+".$tag.'-leap' && $special[1][4]==="'@SUM(1,1)",'reference and package formula injection prevented');
  101. check($special[1][6]===123.45,'fractional currency preserved');
  102. check(count(workbook($finance->export(['keyword'=>$tag.'-missing'])))===1,'empty export contains headers');
  103. check(count(workbook($finance->export(['keyword'=>$tag,'source'=>'demo'])))===3,'export source matches dashboard');
  104. $invalids=[['month'=>'2026-13'],['month'=>'2026-9'],['month'=>'1999-12'],['month'=>'2026-10'],['month'=>"20\0-01"],['month'=>[]],['source'=>'bad'],['keyword'=>[]],['keyword'=>str_repeat('字',121)],['page'=>0],['page'=>'1.5'],['limit'=>101],['limit'=>[]]];
  105. foreach ($invalids as $invalid) {
  106. rejects(fn()=>$finance->dashboard($invalid),'invalid dashboard filters rejected');
  107. rejects(fn()=>$finance->export($invalid),'invalid export filters rejected');
  108. }
  109. $reflection=new ReflectionClass(WaterController::class); $controller=$reflection->newInstanceWithoutConstructor();
  110. $reflection->getProperty('adminId')->setValue($controller,0);
  111. rejects(fn()=>$controller->financeDashboard(request('GET','/core/water/finance/dashboard')),'unauthorized dashboard');
  112. rejects(fn()=>$controller->financeExport(request('POST','/core/water/finance/export')),'unauthorized export');
  113. rejects(fn()=>$controller->save(request('POST','/core/water/finance',['action'=>'purchase']),'finance'),'unauthorized receipt');
  114. $reflection->getProperty('adminId')->setValue($controller,1);
  115. $response=$controller->financeExport(request('POST','/core/water/finance/export',['month'=>'2004-02','keyword'=>$tag]));
  116. check($response->getStatusCode()===200 && count(workbook($response->rawBody()))===2,'authorized JSON export controller');
  117. $sku=$business->save('sku',['name'=>$tag,'liters'=>18.9],1)['id'];
  118. $package=$business->save('package',['name'=>$tag,'sku_id'=>$sku,'buckets'=>5,'price_cents'=>12345],1)['id'];
  119. $receipt=['enterprise_id'=>$enterprise,'package_id'=>$package,'reference'=>$tag.'-receipt','action'=>'purchase'];
  120. $saved=json_decode($controller->save(request('POST','/core/water/finance',$receipt),'finance')->rawBody(),true);
  121. check(($saved['code']??0)===200,'receipt controller succeeds');
  122. check((int)WaterAccount::query()->where('enterprise_id',$enterprise)->where('sku_id',$sku)->value('entitlement')===5,'receipt credits entitlement');
  123. $replay=json_decode($controller->save(request('POST','/core/water/finance',$receipt),'finance')->rawBody(),true);
  124. check(($replay['data']['replayed']??false)===true,'receipt replay detected');
  125. check((int)WaterAccount::query()->where('enterprise_id',$enterprise)->where('sku_id',$sku)->value('entitlement')===5,'replay does not duplicate entitlement');
  126. check(WaterOrder::query()->where('reference',$receipt['reference'])->count()===1,'one receipt for one reference');
  127. check((int)WaterSku::query()->find($sku)->available===0,'receipt does not create inventory');
  128. rejects(fn()=>$business->purchase(array_replace($receipt,['enterprise_id'=>99999999]),1),'mismatched receipt rejected');
  129. $model=new ReflectionMethod(WaterFinanceDashboard::class,'excelText');
  130. foreach (["=1+1",'+1','-1','@SUM(A1)',"\t=1","\r=1","\n=1"] as $unsafe) check($model->invoke(null,$unsafe)==="'".$unsafe,'unsafe export prefix escaped');
  131. // Enforce the all-matching export cap with isolated, batched fixture rows.
  132. $template=['enterprise_id'=>$enterprise,'package_id'=>0,'sku_id'=>0,'package_name'=>$tag.'-cap','buckets'=>1,'amount_cents'=>1,'created_by'=>1,'created_at'=>'2005-01-01 00:00:00'];
  133. for ($offset=0;$offset<50001;$offset+=1000) {
  134. $batch=[];
  135. for ($i=$offset;$i<min($offset+1000,50001);$i++) $batch[]=array_replace($template,['reference'=>$tag.'-cap-'.$i]);
  136. WaterOrder::query()->insert($batch);
  137. }
  138. rejects(fn()=>$finance->export(['month'=>'2005-01','keyword'=>$tag.'-cap']),'over 50000 matching rows rejected');
  139. } finally { $db->rollBack(); }
  140. check(WaterOrder::query()->count()===$beforeOrders && WaterLedger::query()->count()===$beforeLedger,'all receipt and ledger fixtures rolled back');
  141. check(!WaterEnterprise::query()->where('name','like','%'.$tag.'%')->exists(),'enterprise fixtures rolled back');
  142. if (in_array('--http',$argv,true)) {
  143. $token=\Tinywan\Jwt\JwtToken::generateToken(['id'=>1,'username'=>'admin','plat'=>'saiadmin','type'=>'web','client'=>$tag,'access_exp'=>120,'refresh_exp'=>120])['access_token'];
  144. $live=function(string $method,string $path,array $data=[],bool $authenticated=true) use ($token) {
  145. $ch=curl_init('http://127.0.0.1:8787/core/water/finance/'.$path);
  146. $headers=['Content-Type: application/json']; if ($authenticated) $headers[]='Authorization: Bearer '.$token;
  147. curl_setopt_array($ch,[CURLOPT_RETURNTRANSFER=>true,CURLOPT_TIMEOUT=>20,CURLOPT_HTTPHEADER=>$headers]);
  148. if ($method==='POST') curl_setopt_array($ch,[CURLOPT_POST=>true,CURLOPT_POSTFIELDS=>json_encode($data)]);
  149. $raw=curl_exec($ch); $status=curl_getinfo($ch,CURLINFO_HTTP_CODE); $error=curl_error($ch); curl_close($ch);
  150. check($raw!==false,'HTTP transport '.$error); return [$status,(string)$raw];
  151. };
  152. [$status,$raw]=$live('GET','dashboard?limit=1'); $body=json_decode($raw,true);
  153. check($status===200 && ($body['code']??0)===200,'HTTP finance dashboard');
  154. check(count($body['data']['list']['data'])<=1 && $body['data']['summary']['month_refund_cents']===null,'HTTP contract');
  155. [$status,$raw]=$live('POST','export',['month'=>'2004-02']);
  156. check($status===200 && str_starts_with($raw,'PK') && count(workbook($raw))>=1,'HTTP workbook');
  157. [$status,$raw]=$live('GET','dashboard',[],false);
  158. check((json_decode($raw,true)['code']??0)!==200,'HTTP dashboard requires login');
  159. [$status,$raw]=$live('POST','export',[],false);
  160. check(!str_starts_with($raw,'PK') && (json_decode($raw,true)['code']??0)!==200,'HTTP export requires login');
  161. [$status,$raw]=$live('GET','dashboard?month=2026-13');
  162. check((json_decode($raw,true)['code']??0)!==200,'HTTP invalid month rejected');
  163. }
  164. echo "PASS: $checks finance checks; all fixtures rolled back.\n";