load(); \support\App::loadAllConfig(['route']); \Webman\ThinkOrm\ThinkOrm::start(null); date_default_timezone_set(config('app.default_timezone', 'Asia/Shanghai')); use plugin\saiadmin\app\logic\operation\DeviceOperationLogic; use plugin\saiadmin\app\service\DeviceOperationMetrics; use plugin\saiadmin\app\service\DeviceOperationShares; use plugin\saiadmin\exception\ApiException; use support\think\Db; $checks = 0; function check(bool $condition, string $message): void { global $checks; ++$checks; if (!$condition) { throw new RuntimeException($message); } } function rejects(callable $call, string $message): void { try { $call(); } catch (ApiException $e) { check(true, $message); return; } check(false, $message); } function listing(DeviceOperationLogic $logic, array $filters = []): array { return $logic->indexList(array_merge(['scope' => 'mine', 'category' => 'vehicle', 'limit' => 100], $filters)); } $admin = new DeviceOperationLogic(); $admin->init(['id' => 1]); $subId = (int) Db::table('sa_system_user')->where('id', '<>', 1)->whereNull('delete_time')->value('id'); if (!$subId) { throw new RuntimeException('测试需要至少一个普通账号'); } $sub = new DeviceOperationLogic(); $sub->init(['id' => $subId]); $tag = 'TEST-OP-' . bin2hex(random_bytes(5)); $now = new DateTimeImmutable(); $today = $now->format('Y-m-d 00:00:00'); $yesterday = $now->modify('-1 day')->format('Y-m-d 12:00:00'); $started = $now->modify('-2 days')->format('Y-m-d 00:00:00'); $countBefore = (int) Db::table('sa_device')->count(); Db::startTrans(); try { $createDevice = function (int $manager, string $suffix, int $type = 107) use ($tag, $started) { return (int) Db::table('sa_device')->insertGetId([ 'goods_type' => $type, 'goods_sn' => $tag . '-' . $suffix, 'device_code' => $tag . '-' . $suffix, 'manage_agent_id' => $manager, 'belong_agent_id' => 1, 'create_time' => $started, ]); }; $id = $createDevice(1, 'OWN'); $other = $createDevice($subId, 'SUB'); $deleted = $createDevice(1, 'DELETED'); Db::table('sa_device')->where('id', $deleted)->update(['delete_time' => $today]); $merchantId = (int) Db::table('sa_merchant')->whereNull('delete_time')->value('id'); Db::table('sa_merchant_device_binding')->insert([ 'device_id' => $id, 'device_sn' => $tag . '-OWN', 'depend_type' => 107, 'merchant_id' => $merchantId, 'operator_id' => 1, 'bind_time' => $started, 'create_time' => $started, 'update_time' => $started, ]); $sequence = 0; $order = function (float $paid, float $refund, int $status, string $end, array $extra = []) use (&$sequence, $tag, $id, $started) { Db::table('sa_rental_order')->insert(array_merge([ 'order_sn' => $tag . '-' . ++$sequence, 'device_id' => $id, 'device_type' => 107, 'manage_agent_id' => 1, 'status' => $status, 'paid_amount' => $paid, 'refund_amount' => $refund, 'rent_at' => $started, 'return_at' => $end, 'create_time' => $started, 'update_time' => $end, ], $extra)); }; $order(40, 10, 2, $yesterday); // 昨日净交易额 30 $order(10, 0, 2, $today); $order(20, 0, 3, $today); // 同一天多单必须相加,不能覆盖 $order(99, 0, 5, $today); // 扣款失败不得贡献收入/付费订单数 $order(0, 0, 0, $today); $order(100, 0, 2, $today, ['delete_time' => $today]); $order(100, 0, 2, $today, ['manage_agent_id' => $subId]); // 不得泄露其他管理人订单 $order(100, 0, 2, $today, ['rent_at' => $now->modify('+1 day')->format('Y-m-d H:i:s')]); Db::table('sa_device')->where('id', $id)->update(['cost_price' => 10]); Db::table('sa_merchant')->where('id', $merchantId)->update([ 'agent_id' => 1, 'profit_method' => '比例分成', 'profit_rate' => 25, ]); $profile = ['profit_rates' => json_encode(['vehicle' => 80])]; if (Db::table('sa_agent_profile')->where('agent_id', 1)->find()) { Db::table('sa_agent_profile')->where('agent_id', 1)->update($profile); } else { Db::table('sa_agent_profile')->insert($profile + [ 'agent_id' => 1, 'product_codes' => '["vehicle"]', 'permissions' => '[]', 'create_time' => $today, 'update_time' => $today, ]); } $rows = array_column(listing($admin)['data'], null, 'id'); $row = $rows[$id]; check($row['total_amount'] === 60.0, '净交易额/退款/失败/删除/未来/权限过滤'); check($row['today_amount'] === 30.0 && $row['yesterday_amount'] === 30.0, '每日金额应累加'); check($row['total_orders'] === 5 && $row['today_orders'] === 4, '全部订单与日订单口径'); check($row['paid_orders'] === 3 && $row['unit_price'] === 20.0, '客单价只除以有效付费订单'); check($row['total_profit'] === 12.0 && $row['today_profit'] === 6.0, '商户有效分成盈利'); check($row['merchant_rate'] === 20.0 && $row['manager_rate'] === 60.0, '从代理80%与商户相对25%自动计算20%/60%'); check($row['profit_rate_value'] === 600.0, '盈利率沿用交易额/成本'); check($row['daily_growth'] === 0.0, '日增长率'); check($row['is_cost_recovery'] === true && $row['cost_recovery_date'] === substr($yesterday, 0, 10), '首次回本日期不得被后续订单覆盖'); check($row['operated_days'] === 3 && $row['cost_recovery_days'] === 2, '投放与回本天数'); check($row['today_performance'] === '优秀', '昨日/日均评级阈值'); check(!isset($rows[$other]) && !isset($rows[$deleted]), '本人不包含下级及删除设备'); check(!isset(array_column(listing($admin, ['scope' => 'subordinate'])['data'], null, 'id')[$id]), '下级不包含本人'); check(isset(array_column(listing($admin, ['scope' => 'subordinate'])['data'], null, 'id')[$other]), '管理员可见下级'); check(isset(array_column(listing($sub)['data'], null, 'id')[$other]), '普通账号本人范围'); check(!isset(array_column(listing($sub)['data'], null, 'id')[$id]), '普通账号不可见其他管理人'); foreach ([['scope' => 'all'], ['category' => 'unknown'], ['sort' => 'id DESC;DROP TABLE x'], ['order' => 'x']] as $bad) { rejects(fn () => listing($admin, $bad), '拒绝无效筛选/排序'); } foreach (['ktv' => 100, 'bluetooth-cable' => 104, 'power-bank' => 101, 'password-cable' => 103, 'password-bluetooth-cable' => 105] as $category => $type) { $categoryId = $createDevice(1, $category, $type); check(isset(array_column(listing($admin, ['category' => $category])['data'], null, 'id')[$categoryId]), '设备分类查询'); check(!isset(array_column(listing($admin)['data'], null, 'id')[$categoryId]), '分类隔离'); } foreach (['total_amount', 'total_orders', 'unit_price', 'profit_rate_value'] as $sort) { $sorted = listing($admin, ['sort' => $sort, 'order' => 'desc']); $first = listing($admin, ['sort' => $sort, 'order' => 'desc', 'limit' => 1, 'page' => 1]); $second = listing($admin, ['sort' => $sort, 'order' => 'desc', 'limit' => 1, 'page' => 2]); check($first['data'][0]['id'] === $sorted['data'][0]['id'] && $second['data'][0]['id'] === $sorted['data'][1]['id'], '排序必须先于分页'); check($first['total'] === $sorted['total'], '分页总数稳定'); } Db::table('sa_merchant')->where('id', $merchantId)->update(['profit_rate' => 50]); $changed = array_column(listing($admin)['data'], null, 'id')[$id]; check($changed['merchant_rate'] === 40.0 && $changed['total_profit'] === 24.0, '修改商户档案后运营统计自动变化'); Db::table('sa_merchant')->where('id', $merchantId)->update(['profit_rate' => 25]); Db::table('sa_agent_profile')->where('agent_id', 1)->update(['profit_rates' => '{"vehicle":60}']); $changed = array_column(listing($admin)['data'], null, 'id')[$id]; check($changed['merchant_rate'] === 15.0 && $changed['total_profit'] === 9.0, '修改代理档案后自动重新计算'); Db::table('sa_agent_profile')->where('agent_id', 1)->update(['profit_rates' => '{}']); $unknown = array_column(listing($admin)['data'], null, 'id')[$id]; check($unknown['total_profit'] === null && $unknown['is_cost_recovery'] === null, '未知参数不得假装为零/已回本'); Db::table('sa_agent_profile')->where('agent_id', 1)->update($profile); Db::table('sa_device')->where('id', $id)->update(['cost_price' => 0]); $zero = array_column(listing($admin)['data'], null, 'id')[$id]; check($zero['profit_rate_value'] === null && $zero['is_cost_recovery'] === true, '已确认零成本不能除零'); Db::table('sa_device')->where('id', $id)->update(['manage_agent_id' => $subId]); $moved = array_column(listing($admin, ['scope' => 'subordinate'])['data'], null, 'id')[$id]; check($moved['merchant_rate'] === 20.0 && $moved['cost_price'] === 0.0, '转移管理权不丢失台账成本和商户分成'); check($moved['total_amount'] === 100.0, '转移后仍按可见订单管理人范围统计'); $withoutYesterday = DeviceOperationMetrics::calculate(array_merge($row, ['yesterday_amount' => 0]), [], $now); check($withoutYesterday['daily_growth'] === null, '昨日无收入时增长率不得除零'); // 新增产品和类型只改数据库字典,列表选项、分类与分成无需修改 PHP/Vue。 $productType = Db::table('sa_system_dict_type')->where('code', 'device_product')->whereNull('delete_time')->value('id'); $mappingType = Db::table('sa_system_dict_type')->where('code', 'device_type_product')->whereNull('delete_time')->value('id'); Db::table('sa_system_dict_data')->insert([ 'type_id' => $productType, 'code' => 'device_product', 'label' => '测试新产品', 'value' => 'test-product', 'status' => 1, ]); $mappingId = Db::table('sa_system_dict_data')->insertGetId([ 'type_id' => $mappingType, 'code' => 'device_type_product', 'label' => 'test-product', 'value' => '65000', 'status' => 1, ]); $customId = $createDevice(1, 'CUSTOM', 65000); Db::table('sa_merchant_device_binding')->insert([ 'device_id' => $customId, 'device_sn' => $tag . '-CUSTOM', 'depend_type' => 65000, 'merchant_id' => $merchantId, 'operator_id' => 1, 'bind_time' => $started, 'create_time' => $started, 'update_time' => $started, ]); Db::table('sa_agent_profile')->where('agent_id', 1)->update(['profit_rates' => '{"vehicle":80,"test-product":40}']); $order(20, 0, 2, $today, ['device_id' => $customId, 'device_type' => 65000]); $customList = listing($admin, ['category' => 'test-product']); check($customList['total'] === 1 && $customList['data'][0]['total_profit'] === 2.0, '数据库新增映射直接驱动分类与分成'); check(in_array('test-product', array_column($customList['categories'], 'value'), true), '前端筛选选项来自数据库'); Db::table('sa_system_dict_data')->where('id', $mappingId)->update(['status' => 2]); rejects(fn () => listing($admin, ['category' => 'test-product']), '禁用映射立即生效'); Db::table('sa_system_dict_data')->where('id', $mappingId)->update(['status' => 1]); Db::table('sa_system_dict_data')->insert([ 'type_id' => $mappingType, 'code' => 'device_type_product', 'label' => 'vehicle', 'value' => '65000', 'status' => 1, ]); rejects(fn () => listing($admin), '重复设备类型映射不得静默覆盖'); } finally { Db::rollback(); } check((int) Db::table('sa_device')->count() === $countBefore, '测试设备已回滚'); check(Db::table('sa_rental_order')->whereLike('order_sn', $tag . '%')->count() === 0, '测试订单已回滚'); // 多级代理扣除当前商户分支的直属下级比例,不能扣除整个代理树。 $agents = [ ['id' => 10, 'dept_id' => 10, 'dept_level' => '0,', 'profit_rates' => '{"vehicle":80}'], ['id' => 20, 'dept_id' => 20, 'dept_level' => '0,10,', 'profit_rates' => '{"vehicle":60}'], ['id' => 30, 'dept_id' => 30, 'dept_level' => '0,10,20,', 'profit_rates' => '{"vehicle":40}'], ]; $shares = new DeviceOperationShares($agents); $device = ['merchant_id' => 1, 'merchant_agent_id' => 30, 'manage_agent_id' => 10, 'merchant_relative_rate' => 25, 'profit_method' => '比例分成']; check($shares->calculate($device, 'vehicle') === ['merchant_rate' => 10.0, 'manager_rate' => 20.0], '多层分成80-60与40*25%'); check($shares->calculate(array_merge($device, ['manage_agent_id' => 30]), 'vehicle')['manager_rate'] === 30.0, '直属代理扣除商户有效比例'); check($shares->calculate(array_merge($device, ['profit_method' => '不分成']), 'vehicle')['merchant_rate'] === 0.0, '不分成商户'); check($shares->calculate($device, 'missing-product')['merchant_rate'] === null, '缺失产品分成不猜测'); $agents[] = ['id' => 21, 'dept_id' => 20, 'dept_level' => '0,10,', 'profit_rates' => '{"vehicle":50}']; check((new DeviceOperationShares($agents))->calculate($device, 'vehicle')['manager_rate'] === null, '部门多人时不能猜分成代理'); echo "PASS: $checks checks; test data rolled back.\n";