water_inventory_test.php 8.1 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788
  1. <?php
  2. /** Inventory read-model regression: every fixture is rolled back. Optional --http only reads live data. */
  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\{WaterSku,WaterAccount,WaterDelivery,WaterLedger};
  8. use plugin\saiadmin\app\service\{WaterBusiness,WaterInventory};
  9. use plugin\saiadmin\app\controller\water\WaterController;
  10. use plugin\saiadmin\exception\ApiException;
  11. $checks=0;
  12. function check(bool $ok,string $message): void { global $checks; $checks++; if (!$ok) throw new RuntimeException($message); }
  13. function rejects(callable $fn,string $message): void { try { $fn(); } catch (ApiException $e) { check(true,$message); return; } throw new RuntimeException($message); }
  14. function workbook(string $content): array {
  15. $path=tempnam(sys_get_temp_dir(),'inventory-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. $db=(new WaterSku())->getConnection(); $service=new WaterInventory(); $business=new WaterBusiness();
  20. $tag='INV-'.bin2hex(random_bytes(5)); $db->beginTransaction();
  21. try {
  22. $before=$service->dashboard([]); $ids=[];
  23. foreach (['healthy','alert','disabled'] as $i=>$state) {
  24. $id=$business->save('sku',['name'=>($i===0?'=':'').$tag.'-'.$state,'liters'=>18.9,'safety_stock'=>5,'status'=>$i===2?2:1],1)['id'];
  25. WaterSku::query()->where('id',$id)->update(['available'=>$i===0?20:5,'locked'=>2,'transit'=>3,'empty_qty'=>4,'damaged_qty'=>1,'empty_lost_qty'=>2]); $ids[]=$id;
  26. }
  27. $enterprise=$business->save('enterprise',['name'=>$tag,'contact'=>'test','phone'=>'TEST','address'=>'TEST'],1)['id'];
  28. WaterAccount::query()->insert(['enterprise_id'=>$enterprise,'sku_id'=>$ids[0],'empty_qty'=>7,'entitlement'=>20,'updated_at'=>date('Y-m-d H:i:s')]);
  29. WaterDelivery::query()->insert(['enterprise_id'=>$enterprise,'sku_id'=>$ids[0],'quantity'=>1,'address'=>'TEST','created_by'=>1,'empty_in_vehicle'=>6,'status'=>'completed','scheduled_date'=>date('Y-m-d'),'created_at'=>date('Y-m-d H:i:s'),'updated_at'=>date('Y-m-d H:i:s')]);
  30. $data=$service->dashboard(['keyword'=>' '.$tag.' ','page'=>1,'limit'=>2]); $next=$service->dashboard(['keyword'=>$tag,'page'=>2,'limit'=>2]);
  31. check($data['list']['total']===3 && count($data['list']['data'])===2 && count($next['list']['data'])===1,'search and pagination');
  32. check(array_column(array_merge($data['list']['data'],$next['list']['data']),'id')===array_reverse($ids),'stable page order');
  33. check(array_column(array_merge($data['list']['data'],$next['list']['data']),'stock_status')===['disabled','alert','healthy'],'disabled and safety equality status');
  34. check($data['summary']===$next['summary'],'global summary independent of page');
  35. check($data['summary']===$service->dashboard(['keyword'=>$tag.'-missing'])['summary'],'global summary independent of search');
  36. foreach (['specs'=>3,'available'=>30,'locked'=>6,'transit'=>9,'warehouse_empty'=>12,'damaged'=>3,'empty_lost'=>6,'alerts'=>1,'site_empty'=>7,'vehicle_empty'=>6,'empty_assets'=>25,'total'=>45] as $key=>$delta) check($data['summary'][$key]-$before['summary'][$key]===$delta,'aggregate delta '.$key);
  37. check(count($data['trend'])===7 && $data['trend'][0]['day']===date('Y-m-d',strtotime('-6 days')) && $data['trend'][6]['day']===date('Y-m-d'),'seven calendar days');
  38. $start=$data['trend'][0]['day']; $today=date('Y-m-d');
  39. foreach ([['inbound',11,$start.' 00:00:00'],['delivery_return',3,$today.' 23:59:59'],['depart',8,$today.' 12:00:00'],['warehouse_count',100,$today.' 12:00:00'],['empty_handover',200,$today.' 12:00:00'],['inbound',99,date('Y-m-d',strtotime('-7 days')).' 23:59:59'],['depart',99,date('Y-m-d',strtotime('+1 day')).' 00:00:00']] as [$kind,$qty,$time]) {
  40. WaterLedger::query()->insert(['sku_id'=>$ids[0],'enterprise_id'=>$enterprise,'kind'=>$kind,'quantity'=>$qty,'reference'=>$tag.'-'.$kind.'-'.$time,'created_by'=>1,'created_at'=>$time]);
  41. }
  42. $trend=$service->dashboard([])['trend'];
  43. for ($i=0;$i<7;$i++) {
  44. check($trend[$i]['inbound']-$data['trend'][$i]['inbound']===($i===0?11:($i===6?3:0)),'inbound calendar boundary '.$i);
  45. check($trend[$i]['outbound']-$data['trend'][$i]['outbound']===($i===6?8:0),'outbound excludes counts and outside dates '.$i);
  46. }
  47. $stock=['sku_id'=>$ids[0],'mode'=>'inbound','quantity'=>4,'reference'=>$tag.'-operation','note'=>'transactional test'];
  48. $business->stock($stock,1);
  49. check($service->dashboard([])['summary']['available']===$data['summary']['available']+4,'inbound reflected in dashboard');
  50. rejects(fn()=>$business->stock($stock,1),'duplicate inbound rejected');
  51. $business->stock(array_replace($stock,['mode'=>'warehouse_count','quantity'=>0]),1);
  52. check((int)WaterSku::query()->find($ids[0])->available===0,'zero warehouse count supported');
  53. $business->stock(array_replace($stock,['mode'=>'site_count','enterprise_id'=>$enterprise,'quantity'=>2]),1);
  54. $business->stock(array_replace($stock,['mode'=>'exchange','enterprise_id'=>$enterprise,'quantity'=>1]),1);
  55. $account=WaterAccount::query()->where('enterprise_id',$enterprise)->first();
  56. check((int)$account->full_qty===1 && (int)$account->empty_qty===8 && (int)$account->entitlement===20,'site count and exchange preserve rights');
  57. $xlsx=$service->export(['keyword'=>$tag,'page'=>2,'limit'=>1]); $book=workbook($xlsx);
  58. check(substr($xlsx,0,2)==='PK' && count($book)===4,'XLSX exports entire filtered result');
  59. check($book[3][0]==="'=".$tag.'-healthy','spreadsheet formula prefix escaped');
  60. check(count($book[0])===10 && in_array('破损隔离满桶',$book[0],true),'export includes custody fields');
  61. check(count(workbook($service->export(['keyword'=>$tag.'-missing'])))===1,'empty export has headers');
  62. rejects(fn()=>$service->dashboard(['keyword'=>str_repeat('字',121)]),'long dashboard keyword rejected');
  63. rejects(fn()=>$service->export(['keyword'=>str_repeat('字',121)]),'long export keyword rejected');
  64. $reflection=new ReflectionClass(WaterController::class); $controller=$reflection->newInstanceWithoutConstructor();
  65. $reflection->getProperty('adminId')->setValue($controller,99999999);
  66. $request=new \support\Request("GET /core/water/inventory/dashboard HTTP/1.1\r\nHost: localhost\r\n\r\n");
  67. rejects(fn()=>$controller->inventoryDashboard($request),'dashboard authorization');
  68. rejects(fn()=>$controller->inventoryExport($request),'export authorization');
  69. $reflection->getProperty('adminId')->setValue($controller,1);
  70. $body=json_encode(['keyword'=>$tag]);
  71. $request=new \support\Request("POST /core/water/inventory/export HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: ".strlen($body)."\r\n\r\n".$body);
  72. check(count(workbook($controller->inventoryExport($request)->rawBody()))===4,'JSON export controller');
  73. } finally { $db->rollBack(); }
  74. check(WaterSku::query()->where('name','like','%'.$tag.'%')->count()===0,'fixtures rolled back');
  75. if (in_array('--http',$argv,true)) {
  76. $token=\Tinywan\Jwt\JwtToken::generateToken(['id'=>1,'username'=>'admin','plat'=>'saiadmin','type'=>'web','client'=>$tag,'access_exp'=>120,'refresh_exp'=>120])['access_token'];
  77. foreach (['inventory/dashboard?limit=1','inventory/export'] as $path) {
  78. $ch=curl_init('http://127.0.0.1:8787/core/water/'.$path);
  79. $options=[CURLOPT_RETURNTRANSFER=>true,CURLOPT_TIMEOUT=>15,CURLOPT_HTTPHEADER=>['Content-Type: application/json','Authorization: Bearer '.$token]];
  80. if ($path==='inventory/export') $options += [CURLOPT_POST=>true,CURLOPT_POSTFIELDS=>'{}'];
  81. curl_setopt_array($ch,$options); $raw=curl_exec($ch); $status=curl_getinfo($ch,CURLINFO_HTTP_CODE); curl_close($ch);
  82. check($status===200,'HTTP status '.$path);
  83. if ($path==='inventory/export') check(substr($raw,0,2)==='PK' && count(workbook($raw))>1,'HTTP XLSX body');
  84. else { $json=json_decode($raw,true); check(($json['code']??0)===200 && count($json['data']['trend']??[])===7 && count($json['data']['list']['data']??[])<=1,'HTTP dashboard contract'); }
  85. }
  86. unset($token);
  87. }
  88. echo "PASS: $checks inventory checks; fixtures rolled back.\n";