device_operation_test.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. <?php
  2. // php tests/device_operation_test.php;依赖已执行的设备运营迁移,所有测试数据事务回滚。
  3. require dirname(__DIR__) . '/vendor/autoload.php';
  4. \Dotenv\Dotenv::createUnsafeMutable(dirname(__DIR__))->load();
  5. \support\App::loadAllConfig(['route']);
  6. \Webman\ThinkOrm\ThinkOrm::start(null);
  7. date_default_timezone_set(config('app.default_timezone', 'Asia/Shanghai'));
  8. use plugin\saiadmin\app\logic\operation\DeviceOperationLogic;
  9. use plugin\saiadmin\app\service\DeviceOperationMetrics;
  10. use plugin\saiadmin\app\service\DeviceOperationShares;
  11. use plugin\saiadmin\exception\ApiException;
  12. use support\think\Db;
  13. $checks = 0;
  14. function check(bool $condition, string $message): void
  15. {
  16. global $checks;
  17. ++$checks;
  18. if (!$condition) {
  19. throw new RuntimeException($message);
  20. }
  21. }
  22. function rejects(callable $call, string $message): void
  23. {
  24. try {
  25. $call();
  26. } catch (ApiException $e) {
  27. check(true, $message);
  28. return;
  29. }
  30. check(false, $message);
  31. }
  32. function listing(DeviceOperationLogic $logic, array $filters = []): array
  33. {
  34. return $logic->indexList(array_merge(['scope' => 'mine', 'category' => 'vehicle', 'limit' => 100], $filters));
  35. }
  36. $admin = new DeviceOperationLogic();
  37. $admin->init(['id' => 1]);
  38. $subId = (int) Db::table('sa_system_user')->where('id', '<>', 1)->whereNull('delete_time')->value('id');
  39. if (!$subId) {
  40. throw new RuntimeException('测试需要至少一个普通账号');
  41. }
  42. $sub = new DeviceOperationLogic();
  43. $sub->init(['id' => $subId]);
  44. $tag = 'TEST-OP-' . bin2hex(random_bytes(5));
  45. $now = new DateTimeImmutable();
  46. $today = $now->format('Y-m-d 00:00:00');
  47. $yesterday = $now->modify('-1 day')->format('Y-m-d 12:00:00');
  48. $started = $now->modify('-2 days')->format('Y-m-d 00:00:00');
  49. $countBefore = (int) Db::table('sa_device')->count();
  50. Db::startTrans();
  51. try {
  52. $createDevice = function (int $manager, string $suffix, int $type = 107) use ($tag, $started) {
  53. return (int) Db::table('sa_device')->insertGetId([
  54. 'goods_type' => $type, 'goods_sn' => $tag . '-' . $suffix,
  55. 'device_code' => $tag . '-' . $suffix, 'manage_agent_id' => $manager,
  56. 'belong_agent_id' => 1, 'create_time' => $started,
  57. ]);
  58. };
  59. $id = $createDevice(1, 'OWN');
  60. $other = $createDevice($subId, 'SUB');
  61. $deleted = $createDevice(1, 'DELETED');
  62. Db::table('sa_device')->where('id', $deleted)->update(['delete_time' => $today]);
  63. $merchantId = (int) Db::table('sa_merchant')->whereNull('delete_time')->value('id');
  64. Db::table('sa_merchant_device_binding')->insert([
  65. 'device_id' => $id, 'device_sn' => $tag . '-OWN', 'depend_type' => 107,
  66. 'merchant_id' => $merchantId, 'operator_id' => 1, 'bind_time' => $started,
  67. 'create_time' => $started, 'update_time' => $started,
  68. ]);
  69. $sequence = 0;
  70. $order = function (float $paid, float $refund, int $status, string $end, array $extra = []) use (&$sequence, $tag, $id, $started) {
  71. Db::table('sa_rental_order')->insert(array_merge([
  72. 'order_sn' => $tag . '-' . ++$sequence, 'device_id' => $id, 'device_type' => 107,
  73. 'manage_agent_id' => 1, 'status' => $status, 'paid_amount' => $paid,
  74. 'refund_amount' => $refund, 'rent_at' => $started, 'return_at' => $end,
  75. 'create_time' => $started, 'update_time' => $end,
  76. ], $extra));
  77. };
  78. $order(40, 10, 2, $yesterday); // 昨日净交易额 30
  79. $order(10, 0, 2, $today);
  80. $order(20, 0, 3, $today); // 同一天多单必须相加,不能覆盖
  81. $order(99, 0, 5, $today); // 扣款失败不得贡献收入/付费订单数
  82. $order(0, 0, 0, $today);
  83. $order(100, 0, 2, $today, ['delete_time' => $today]);
  84. $order(100, 0, 2, $today, ['manage_agent_id' => $subId]); // 不得泄露其他管理人订单
  85. $order(100, 0, 2, $today, ['rent_at' => $now->modify('+1 day')->format('Y-m-d H:i:s')]);
  86. Db::table('sa_device')->where('id', $id)->update(['cost_price' => 10]);
  87. Db::table('sa_merchant')->where('id', $merchantId)->update([
  88. 'agent_id' => 1, 'profit_method' => '比例分成', 'profit_rate' => 25,
  89. ]);
  90. $profile = ['profit_rates' => json_encode(['vehicle' => 80])];
  91. if (Db::table('sa_agent_profile')->where('agent_id', 1)->find()) {
  92. Db::table('sa_agent_profile')->where('agent_id', 1)->update($profile);
  93. } else {
  94. Db::table('sa_agent_profile')->insert($profile + [
  95. 'agent_id' => 1, 'product_codes' => '["vehicle"]', 'permissions' => '[]',
  96. 'create_time' => $today, 'update_time' => $today,
  97. ]);
  98. }
  99. $rows = array_column(listing($admin)['data'], null, 'id');
  100. $row = $rows[$id];
  101. check($row['total_amount'] === 60.0, '净交易额/退款/失败/删除/未来/权限过滤');
  102. check($row['today_amount'] === 30.0 && $row['yesterday_amount'] === 30.0, '每日金额应累加');
  103. check($row['total_orders'] === 5 && $row['today_orders'] === 4, '全部订单与日订单口径');
  104. check($row['paid_orders'] === 3 && $row['unit_price'] === 20.0, '客单价只除以有效付费订单');
  105. check($row['total_profit'] === 12.0 && $row['today_profit'] === 6.0, '商户有效分成盈利');
  106. check($row['merchant_rate'] === 20.0 && $row['manager_rate'] === 60.0, '从代理80%与商户相对25%自动计算20%/60%');
  107. check($row['profit_rate_value'] === 600.0, '盈利率沿用交易额/成本');
  108. check($row['daily_growth'] === 0.0, '日增长率');
  109. check($row['is_cost_recovery'] === true && $row['cost_recovery_date'] === substr($yesterday, 0, 10), '首次回本日期不得被后续订单覆盖');
  110. check($row['operated_days'] === 3 && $row['cost_recovery_days'] === 2, '投放与回本天数');
  111. check($row['today_performance'] === '优秀', '昨日/日均评级阈值');
  112. check(!isset($rows[$other]) && !isset($rows[$deleted]), '本人不包含下级及删除设备');
  113. check(!isset(array_column(listing($admin, ['scope' => 'subordinate'])['data'], null, 'id')[$id]), '下级不包含本人');
  114. check(isset(array_column(listing($admin, ['scope' => 'subordinate'])['data'], null, 'id')[$other]), '管理员可见下级');
  115. check(isset(array_column(listing($sub)['data'], null, 'id')[$other]), '普通账号本人范围');
  116. check(!isset(array_column(listing($sub)['data'], null, 'id')[$id]), '普通账号不可见其他管理人');
  117. foreach ([['scope' => 'all'], ['category' => 'unknown'], ['sort' => 'id DESC;DROP TABLE x'], ['order' => 'x']] as $bad) {
  118. rejects(fn () => listing($admin, $bad), '拒绝无效筛选/排序');
  119. }
  120. foreach (['ktv' => 100, 'bluetooth-cable' => 104, 'power-bank' => 101, 'password-cable' => 103, 'password-bluetooth-cable' => 105] as $category => $type) {
  121. $categoryId = $createDevice(1, $category, $type);
  122. check(isset(array_column(listing($admin, ['category' => $category])['data'], null, 'id')[$categoryId]), '设备分类查询');
  123. check(!isset(array_column(listing($admin)['data'], null, 'id')[$categoryId]), '分类隔离');
  124. }
  125. foreach (['total_amount', 'total_orders', 'unit_price', 'profit_rate_value'] as $sort) {
  126. $sorted = listing($admin, ['sort' => $sort, 'order' => 'desc']);
  127. $first = listing($admin, ['sort' => $sort, 'order' => 'desc', 'limit' => 1, 'page' => 1]);
  128. $second = listing($admin, ['sort' => $sort, 'order' => 'desc', 'limit' => 1, 'page' => 2]);
  129. check($first['data'][0]['id'] === $sorted['data'][0]['id'] && $second['data'][0]['id'] === $sorted['data'][1]['id'], '排序必须先于分页');
  130. check($first['total'] === $sorted['total'], '分页总数稳定');
  131. }
  132. Db::table('sa_merchant')->where('id', $merchantId)->update(['profit_rate' => 50]);
  133. $changed = array_column(listing($admin)['data'], null, 'id')[$id];
  134. check($changed['merchant_rate'] === 40.0 && $changed['total_profit'] === 24.0, '修改商户档案后运营统计自动变化');
  135. Db::table('sa_merchant')->where('id', $merchantId)->update(['profit_rate' => 25]);
  136. Db::table('sa_agent_profile')->where('agent_id', 1)->update(['profit_rates' => '{"vehicle":60}']);
  137. $changed = array_column(listing($admin)['data'], null, 'id')[$id];
  138. check($changed['merchant_rate'] === 15.0 && $changed['total_profit'] === 9.0, '修改代理档案后自动重新计算');
  139. Db::table('sa_agent_profile')->where('agent_id', 1)->update(['profit_rates' => '{}']);
  140. $unknown = array_column(listing($admin)['data'], null, 'id')[$id];
  141. check($unknown['total_profit'] === null && $unknown['is_cost_recovery'] === null, '未知参数不得假装为零/已回本');
  142. Db::table('sa_agent_profile')->where('agent_id', 1)->update($profile);
  143. Db::table('sa_device')->where('id', $id)->update(['cost_price' => 0]);
  144. $zero = array_column(listing($admin)['data'], null, 'id')[$id];
  145. check($zero['profit_rate_value'] === null && $zero['is_cost_recovery'] === true, '已确认零成本不能除零');
  146. Db::table('sa_device')->where('id', $id)->update(['manage_agent_id' => $subId]);
  147. $moved = array_column(listing($admin, ['scope' => 'subordinate'])['data'], null, 'id')[$id];
  148. check($moved['merchant_rate'] === 20.0 && $moved['cost_price'] === 0.0, '转移管理权不丢失台账成本和商户分成');
  149. check($moved['total_amount'] === 100.0, '转移后仍按可见订单管理人范围统计');
  150. $withoutYesterday = DeviceOperationMetrics::calculate(array_merge($row, ['yesterday_amount' => 0]), [], $now);
  151. check($withoutYesterday['daily_growth'] === null, '昨日无收入时增长率不得除零');
  152. // 新增产品和类型只改数据库字典,列表选项、分类与分成无需修改 PHP/Vue。
  153. $productType = Db::table('sa_system_dict_type')->where('code', 'device_product')->whereNull('delete_time')->value('id');
  154. $mappingType = Db::table('sa_system_dict_type')->where('code', 'device_type_product')->whereNull('delete_time')->value('id');
  155. Db::table('sa_system_dict_data')->insert([
  156. 'type_id' => $productType, 'code' => 'device_product', 'label' => '测试新产品', 'value' => 'test-product', 'status' => 1,
  157. ]);
  158. $mappingId = Db::table('sa_system_dict_data')->insertGetId([
  159. 'type_id' => $mappingType, 'code' => 'device_type_product', 'label' => 'test-product', 'value' => '65000', 'status' => 1,
  160. ]);
  161. $customId = $createDevice(1, 'CUSTOM', 65000);
  162. Db::table('sa_merchant_device_binding')->insert([
  163. 'device_id' => $customId, 'device_sn' => $tag . '-CUSTOM', 'depend_type' => 65000,
  164. 'merchant_id' => $merchantId, 'operator_id' => 1, 'bind_time' => $started,
  165. 'create_time' => $started, 'update_time' => $started,
  166. ]);
  167. Db::table('sa_agent_profile')->where('agent_id', 1)->update(['profit_rates' => '{"vehicle":80,"test-product":40}']);
  168. $order(20, 0, 2, $today, ['device_id' => $customId, 'device_type' => 65000]);
  169. $customList = listing($admin, ['category' => 'test-product']);
  170. check($customList['total'] === 1 && $customList['data'][0]['total_profit'] === 2.0, '数据库新增映射直接驱动分类与分成');
  171. check(in_array('test-product', array_column($customList['categories'], 'value'), true), '前端筛选选项来自数据库');
  172. Db::table('sa_system_dict_data')->where('id', $mappingId)->update(['status' => 2]);
  173. rejects(fn () => listing($admin, ['category' => 'test-product']), '禁用映射立即生效');
  174. Db::table('sa_system_dict_data')->where('id', $mappingId)->update(['status' => 1]);
  175. Db::table('sa_system_dict_data')->insert([
  176. 'type_id' => $mappingType, 'code' => 'device_type_product', 'label' => 'vehicle', 'value' => '65000', 'status' => 1,
  177. ]);
  178. rejects(fn () => listing($admin), '重复设备类型映射不得静默覆盖');
  179. } finally {
  180. Db::rollback();
  181. }
  182. check((int) Db::table('sa_device')->count() === $countBefore, '测试设备已回滚');
  183. check(Db::table('sa_rental_order')->whereLike('order_sn', $tag . '%')->count() === 0, '测试订单已回滚');
  184. // 多级代理扣除当前商户分支的直属下级比例,不能扣除整个代理树。
  185. $agents = [
  186. ['id' => 10, 'dept_id' => 10, 'dept_level' => '0,', 'profit_rates' => '{"vehicle":80}'],
  187. ['id' => 20, 'dept_id' => 20, 'dept_level' => '0,10,', 'profit_rates' => '{"vehicle":60}'],
  188. ['id' => 30, 'dept_id' => 30, 'dept_level' => '0,10,20,', 'profit_rates' => '{"vehicle":40}'],
  189. ];
  190. $shares = new DeviceOperationShares($agents);
  191. $device = ['merchant_id' => 1, 'merchant_agent_id' => 30, 'manage_agent_id' => 10,
  192. 'merchant_relative_rate' => 25, 'profit_method' => '比例分成'];
  193. check($shares->calculate($device, 'vehicle') === ['merchant_rate' => 10.0, 'manager_rate' => 20.0], '多层分成80-60与40*25%');
  194. check($shares->calculate(array_merge($device, ['manage_agent_id' => 30]), 'vehicle')['manager_rate'] === 30.0, '直属代理扣除商户有效比例');
  195. check($shares->calculate(array_merge($device, ['profit_method' => '不分成']), 'vehicle')['merchant_rate'] === 0.0, '不分成商户');
  196. check($shares->calculate($device, 'missing-product')['merchant_rate'] === null, '缺失产品分成不猜测');
  197. $agents[] = ['id' => 21, 'dept_id' => 20, 'dept_level' => '0,10,', 'profit_rates' => '{"vehicle":50}'];
  198. check((new DeviceOperationShares($agents))->calculate($device, 'vehicle')['manager_rate'] === null, '部门多人时不能猜分成代理');
  199. echo "PASS: $checks checks; test data rolled back.\n";