water_business_test.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139
  1. <?php
  2. /** php tests/water_business_test.php:真实数据库事务回滚,不保留测试业务数据。 */
  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 support\Db;
  8. use plugin\saiadmin\app\model\water\WaterModel;
  9. use plugin\saiadmin\app\service\WaterBusiness;
  10. use plugin\saiadmin\exception\ApiException;
  11. $checks = 0;
  12. function check(bool $condition,string $message): void { global $checks; ++$checks; if (!$condition) throw new RuntimeException($message); }
  13. function rejects(callable $callback,string $message): void { try { $callback(); } catch (ApiException $e) { check(true,$message); return; } throw new RuntimeException($message); }
  14. function record(string $table,int $id): array { return WaterModel::forKind($table)->where('id',$id)->first()?->toArray(); }
  15. $service = new WaterBusiness();
  16. $tag = 'WATER-TEST-'.bin2hex(random_bytes(6));
  17. $before = (int)\plugin\saiadmin\app\model\water\WaterEnterprise::query()->count();
  18. Db::beginTransaction();
  19. try {
  20. \plugin\saiadmin\app\model\water\WaterAlertSetting::query()->where('id',1)->update(['alert_qty'=>5,'days_per_barrel'=>3,'urgent_qty'=>2]);
  21. $agentInput = ['name'=>$tag,'contact'=>'测试代理联系人','phone'=>'TEST','region'=>'测试服务区','address'=>'测试地址'];
  22. $agentId = $service->save('agent',$agentInput + ['user_id'=>1],1)['id'];
  23. check(record('agent',$agentId)['user_id'] === null,'agent creation cannot bind an arbitrary login account');
  24. check((int)$service->listing('agents',['keyword'=>$tag])['total'] === 1,'agent directory search uses independent water data');
  25. $service->save('agent',$agentInput + ['id'=>$agentId,'status'=>2],1);
  26. check((int)record('agent',$agentId)['status'] === 2,'agent cooperation status can be disabled');
  27. rejects(fn()=>$service->save('agent',array_merge($agentInput,['phone'=>'']),1),'agent contact phone is required');
  28. $enterprise = $service->save('enterprise',['name'=>$tag,'contact'=>'测试联系人','phone'=>'TEST','region'=>'测试区','address'=>'事务回滚测试地址'],1)['id'];
  29. $sku = $service->save('sku',['name'=>$tag,'liters'=>18.9,'safety_stock'=>2],1)['id'];
  30. $package = $service->save('package',['name'=>$tag,'sku_id'=>$sku,'buckets'=>30,'price_cents'=>69900],1)['id'];
  31. rejects(fn()=>$service->save('package',['name'=>'invalid','sku_id'=>$sku,'buckets'=>1.5,'price_cents'=>1],1),'fractional buckets rejected');
  32. rejects(fn()=>$service->save('sku',['name'=>'invalid','liters'=>0],1),'zero capacity rejected');
  33. $purchase = ['enterprise_id'=>$enterprise,'package_id'=>$package,'reference'=>$tag];
  34. $order = $service->purchase($purchase,1);
  35. check($service->purchase($purchase,1)['id'] === $order['id'],'receipt retry idempotent');
  36. $account = \plugin\saiadmin\app\model\water\WaterAccount::query()->where(['enterprise_id'=>$enterprise,'sku_id'=>$sku])->first()?->toArray();
  37. check((int)$account['entitlement'] === 30 && (int)$account['full_qty'] === 0,'purchase changes entitlement only');
  38. $stock = ['sku_id'=>$sku,'mode'=>'inbound','quantity'=>50,'reference'=>$tag,'note'=>'测试入库'];
  39. $service->stock($stock,1);
  40. rejects(fn()=>$service->stock($stock,1),'duplicate stock receipt rejected');
  41. rejects(fn()=>$service->createDelivery(['enterprise_id'=>$enterprise,'sku_id'=>$sku,'quantity'=>31],1),'insufficient entitlement rejected');
  42. $task = $service->createDelivery(['enterprise_id'=>$enterprise,'sku_id'=>$sku,'quantity'=>10],1)['id'];
  43. $s = record('sku',$sku); $a = record('account',(int)$account['id']);
  44. check((int)$s['available'] === 40 && (int)$s['locked'] === 10,'task reserves inventory');
  45. check((int)$a['entitlement'] === 30 && (int)$a['frozen'] === 10,'task freezes without deducting entitlement');
  46. rejects(fn()=>$service->createDelivery(['enterprise_id'=>$enterprise,'sku_id'=>$sku,'quantity'=>1],1),'duplicate active task rejected');
  47. rejects(fn()=>$service->transition(['id'=>$task,'action'=>'complete'],1),'cannot sign before departure');
  48. $alerts = $service->listing('alerts',['keyword'=>$tag]);
  49. check($alerts['total'] === 1 && (int)$alerts['data'][0]['delivery_id'] === $task,'alert links active task');
  50. check($alerts['data'][0]['days_left'] === null,'no fabricated prediction');
  51. $service->transition(['id'=>$task,'action'=>'assign','driver'=>'测试配送员','driver_phone'=>'TEST'],1);
  52. $service->transition(['id'=>$task,'action'=>'depart'],1);
  53. $service->transition(['id'=>$task,'action'=>'depart'],1);
  54. $s = record('sku',$sku);
  55. check((int)$s['locked'] === 0 && (int)$s['transit'] === 10,'departure idempotent');
  56. rejects(fn()=>$service->transition(['id'=>$task,'action'=>'cancel'],1),'in transit cannot silently cancel');
  57. rejects(fn()=>$service->transition(['id'=>$task,'action'=>'complete','empty_returned'=>1],1),'cannot return nonexistent empties');
  58. $service->transition(['id'=>$task,'action'=>'complete','empty_returned'=>0],1);
  59. $service->transition(['id'=>$task,'action'=>'complete','empty_returned'=>0],1);
  60. $a = record('account',(int)$account['id']); $s = record('sku',$sku);
  61. check((int)$a['entitlement'] === 20 && (int)$a['frozen'] === 0 && (int)$a['full_qty'] === 10,'sign once settles entitlement and site stock');
  62. check((int)$s['transit'] === 0 && (int)$s['available'] === 40,'sign clears transit');
  63. $service->stock(['sku_id'=>$sku,'enterprise_id'=>$enterprise,'mode'=>'exchange','quantity'=>2,'reference'=>$tag.'-exchange','note'=>'测试换桶'],1);
  64. $a = record('account',(int)$account['id']);
  65. check((int)$a['full_qty'] === 8 && (int)$a['empty_qty'] === 2 && (int)$a['entitlement'] === 20,'exchange only affects physical stock');
  66. rejects(fn()=>$service->stock(['sku_id'=>$sku,'enterprise_id'=>$enterprise,'mode'=>'exchange','quantity'=>9,'reference'=>$tag.'-bad','note'=>'不足'],1),'cannot exchange beyond full stock');
  67. $task2 = $service->createDelivery(['enterprise_id'=>$enterprise,'sku_id'=>$sku,'quantity'=>5],1)['id'];
  68. $service->transition(['id'=>$task2,'action'=>'cancel'],1);
  69. $service->transition(['id'=>$task2,'action'=>'cancel'],1);
  70. check((int)record('sku',$sku)['available'] === 40 && (int)record('account',(int)$account['id'])['frozen'] === 0,'cancel releases both exactly once');
  71. $task3 = $service->createDelivery(['enterprise_id'=>$enterprise,'sku_id'=>$sku,'quantity'=>5],1)['id'];
  72. $service->transition(['id'=>$task3,'action'=>'assign','driver'=>'测试配送员','driver_phone'=>'TEST'],1);
  73. $service->transition(['id'=>$task3,'action'=>'depart'],1);
  74. $service->transition(['id'=>$task3,'action'=>'complete','empty_returned'=>2],1);
  75. check((int)record('sku',$sku)['empty_qty'] === 2 && (int)record('account',(int)$account['id'])['empty_qty'] === 0,'empty return moves stock');
  76. $s = record('sku',$sku); $a = record('account',(int)$account['id']);
  77. check((int)$s['available']+(int)$s['locked']+(int)$s['transit']+(int)$a['full_qty']+(int)$a['empty_qty']+(int)$s['empty_qty'] === 50,'physical barrels conserved');
  78. check((int)$a['entitlement'] === 15,'remaining entitlement after two deliveries');
  79. $accountId = (int)$account['id'];
  80. $policyBefore = record('account',$accountId);
  81. rejects(fn()=>$service->saveAlertPolicy(['id'=>$accountId,'threshold_qty'=>13,'entitlement'=>999,'daily_liters'=>999],1),'per-enterprise policy command retired');
  82. check(record('account',$accountId) === $policyBefore,'retired policy cannot modify account');
  83. check(\plugin\saiadmin\app\model\water\WaterLedger::query()->where(['kind'=>'alert_policy','reference'=>'account:'.$accountId,'created_by'=>1])->count() === 0,'rejected policy creates no audit');
  84. check((int)$service->listing('alerts',['keyword'=>$tag])['total'] === 0,'thirteen barrels does not alert');
  85. $allAccounts = $service->listing('alerts',['keyword'=>$tag,'scope'=>'all']);
  86. check($allAccounts['total'] === 1 && $allAccounts['data'][0]['severity'] === 'healthy','healthy accounts remain visible');
  87. $enterprise2 = $service->save('enterprise',['name'=>$tag.'-batch','contact'=>'测试','phone'=>'TEST','address'=>'第二处测试地址'],1)['id'];
  88. $service->purchase(['enterprise_id'=>$enterprise2,'package_id'=>$package,'reference'=>$tag.'-batch'],1);
  89. $account2 = (int)\plugin\saiadmin\app\model\water\WaterAccount::query()->where(['enterprise_id'=>$enterprise2,'sku_id'=>$sku])->value('id');
  90. foreach ([[],array_fill(0,51,['account_id'=>$accountId,'quantity'=>1]),['bad'],[['account_id'=>$accountId,'quantity'=>0]],[['account_id'=>$accountId,'quantity'=>1],['account_id'=>$accountId,'quantity'=>1]]] as $invalid) {
  91. rejects(fn()=>$service->batchCreateDelivery(['items'=>$invalid],1),'invalid batch rejected');
  92. }
  93. $stockBefore = record('sku',$sku);
  94. $taskCount = \plugin\saiadmin\app\model\water\WaterDelivery::query()->count();
  95. $ledgerCount = \plugin\saiadmin\app\model\water\WaterLedger::query()->count();
  96. rejects(fn()=>$service->batchCreateDelivery(['items'=>[['account_id'=>$accountId,'quantity'=>1],['account_id'=>$account2,'quantity'=>31]]],1),'batch rolls back when later entitlement insufficient');
  97. check(record('sku',$sku) === $stockBefore && (int)record('account',$accountId)['frozen'] === 0,'failed batch restores earlier stock and entitlement');
  98. check(\plugin\saiadmin\app\model\water\WaterDelivery::query()->count() === $taskCount && \plugin\saiadmin\app\model\water\WaterLedger::query()->count() === $ledgerCount,'failed batch leaves no tasks or ledger entries');
  99. rejects(fn()=>$service->batchCreateDelivery(['items'=>[['account_id'=>$accountId,'quantity'=>10],['account_id'=>$account2,'quantity'=>30]]],1),'batch checks combined warehouse capacity');
  100. check(record('sku',$sku) === $stockBefore,'combined stock failure rolls back');
  101. \plugin\saiadmin\app\model\water\WaterEnterprise::query()->where('id',$enterprise2)->update(['status'=>2]);
  102. rejects(fn()=>$service->batchCreateDelivery(['items'=>[['account_id'=>$accountId,'quantity'=>1],['account_id'=>$account2,'quantity'=>1]]],1),'disabled enterprise rejects entire batch');
  103. \plugin\saiadmin\app\model\water\WaterEnterprise::query()->where('id',$enterprise2)->update(['status'=>1]);
  104. $batchInput = ['items'=>[['account_id'=>$account2,'quantity'=>4,'enterprise_id'=>$enterprise],['account_id'=>$accountId,'quantity'=>3]]];
  105. $batch = $service->batchCreateDelivery($batchInput,1);
  106. check($batch['count'] === 2 && (int)record('sku',$sku)['available'] === (int)$stockBefore['available']-7,'batch success reserves aggregate stock');
  107. check((int)record('account',$accountId)['frozen'] === 3 && (int)record('account',$account2)['frozen'] === 4,'batch resolves enterprise from account rather than client fields');
  108. rejects(fn()=>$service->batchCreateDelivery($batchInput,1),'repeated batch cannot create duplicate active deliveries');
  109. check((int)record('sku',$sku)['locked'] === (int)$stockBefore['locked']+7,'retry does not reserve twice');
  110. foreach ($batch['created'] as $created) $service->transition(['id'=>$created['id'],'action'=>'cancel'],1);
  111. check((int)record('sku',$sku)['available'] === (int)$stockBefore['available'],'batch tasks use normal cancellation release');
  112. \plugin\saiadmin\app\model\water\WaterSku::query()->where('id',$sku)->update(['status'=>2]);
  113. check((int)$service->listing('alerts',['keyword'=>$tag,'scope'=>'all'])['total'] === 0,'disabled SKU excluded from replenishment');
  114. \plugin\saiadmin\app\model\water\WaterSku::query()->where('id',$sku)->update(['status'=>1]);
  115. $device = $service->save('device',['name'=>$tag,'sn'=>$tag,'enterprise_id'=>$enterprise,'sku_id'=>$sku],1);
  116. $devices = $service->listing('devices',['keyword'=>$tag]);
  117. check($devices['data'][0]['connection_status'] === 'unconnected' && $devices['data'][0]['today_liters'] === null,'new device has no invented telemetry');
  118. rejects(fn()=>$service->save('content',['title'=>'unsafe','audience'=>'all','target_url'=>'javascript:alert(1)'],1),'unsafe content URL rejected');
  119. $content = $service->save('content',['title'=>$tag,'audience'=>'new','starts_at'=>'2099-01-01 00:00:00'],1);
  120. check($service->listing('content',['keyword'=>$tag])['data'][0]['delivery_status'] === 'scheduled','content scheduling');
  121. $overview = $service->overview();
  122. check(isset($overview['stock']['available']) && isset($overview['fulfilment']['completed']),'dashboard aggregate query works: '.json_encode($overview['stock']));
  123. foreach (['enterprises','inventory','packages','dispatch','finance','accounts','ledger'] as $module) {
  124. check(isset($service->listing($module,['keyword'=>$tag])['data']),'listing works: '.$module);
  125. }
  126. $otherUser = Db::table('sa_system_user')->where('id','<>',1)->where('status',1)->whereNull('delete_time')->orderBy('id')->value('id');
  127. if ($otherUser) {
  128. $reflection = new ReflectionClass(\plugin\saiadmin\app\controller\water\WaterController::class);
  129. $controller = $reflection->newInstanceWithoutConstructor();
  130. $reflection->getProperty('adminId')->setValue($controller,(int)$otherUser);
  131. $authorize = $reflection->getMethod('authorize');
  132. // 新业务没有自动授予旧角色;使用现有普通账号验证默认拒绝。
  133. rejects(fn()=>$authorize->invoke($controller,'inventory','write'),'legacy account cannot access new water business without grant');
  134. rejects(fn()=>$authorize->invoke($controller,'alerts','write'),'policy requires explicit alerts write permission');
  135. rejects(fn()=>$authorize->invoke($controller,'dispatch','write'),'batch delivery requires explicit dispatch write permission');
  136. }
  137. } finally { Db::rollback(); }
  138. check((int)\plugin\saiadmin\app\model\water\WaterEnterprise::query()->count() === $before,'test data rolled back');
  139. echo "PASS: $checks water business checks; all test records rolled back.\n";