From d8673fb2c57e6ac5e1f31876b6ae4ad3a89879f7 Mon Sep 17 00:00:00 2001 From: zhenhui <1276357500@qq.com> Date: Fri, 29 May 2026 12:01:00 +0800 Subject: [PATCH] =?UTF-8?q?1.=E4=BC=98=E5=8C=96=E5=88=86=E7=BA=A2=E6=96=B9?= =?UTF-8?q?=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/controller/auth/Admin.php | 128 ++++++++++++++++- app/common/lang/en/service.php | 3 + app/common/lang/zh-cn/service.php | 3 + .../AdminCommissionDistributionService.php | 124 +++++++++++++--- docs/36字花-数据库与实施计划.md | 8 +- docs/36字花-移动端接口设计草案.md | 18 ++- docs/en/36zihua-mobile-api-design-draft.md | 20 ++- docs/en/commission-share-guide.md | 32 +++-- docs/分红说明文档.md | 32 +++-- docs/后端.md | 2 +- web/src/lang/backend/en/auth/admin.ts | 7 + web/src/lang/backend/zh-cn/auth/admin.ts | 5 + .../views/backend/auth/admin/popupForm.vue | 135 +++++++++++++++++- 13 files changed, 457 insertions(+), 60 deletions(-) diff --git a/app/admin/controller/auth/Admin.php b/app/admin/controller/auth/Admin.php index 3dfa61c..9850ccc 100644 --- a/app/admin/controller/auth/Admin.php +++ b/app/admin/controller/auth/Admin.php @@ -21,7 +21,7 @@ class Admin extends Backend /** * 分红比例余量查询(表单提示用) */ - protected array $noNeedPermission = ['commissionShareRemainder']; + protected array $noNeedPermission = ['commissionShareRemainder', 'groupMeta']; protected ?object $model = null; @@ -107,11 +107,38 @@ class Admin extends Backend $parentAdminId = intval($request->get('parent_admin_id', 0)); $excludeId = intval($request->get('exclude_id', 0)); + $isTopLevelGroup = ($request->get('is_top_level') ?? $request->post('is_top_level')) === '1' + || ($request->get('is_top_level') ?? $request->post('is_top_level')) === 1 + || ($request->get('is_top_level') ?? $request->post('is_top_level')) === true; + $channelId = intval($request->get('channel_id', 0)); + + if ($isTopLevelGroup) { + if ($channelId <= 0) { + return $this->success('', [ + 'used_rate' => '0.00', + 'remaining_rate' => '100.00', + 'parent_has_no_share' => false, + 'is_top_level' => true, + ]); + } + $stats = AdminCommissionDistributionService::getChannelRootShareRemainder( + $channelId, + $excludeId > 0 ? $excludeId : null + ); + return $this->success('', [ + 'used_rate' => $stats['used_rate'], + 'remaining_rate' => $stats['remaining_rate'], + 'parent_has_no_share' => bccomp($stats['remaining_rate'], '0', 2) <= 0, + 'is_top_level' => true, + ]); + } + if ($parentAdminId <= 0) { return $this->success('', [ 'used_rate' => '0.00', 'remaining_rate' => '100.00', 'parent_has_no_share' => false, + 'is_top_level' => false, ]); } @@ -128,6 +155,43 @@ class Admin extends Backend 'used_rate' => $stats['used_rate'], 'remaining_rate' => $stats['remaining_rate'], 'parent_has_no_share' => bccomp($stats['remaining_rate'], '0', 2) <= 0, + 'is_top_level' => false, + ]); + } + + /** + * 查询角色组是否为顶级(pid=0),供管理员表单联动 + */ + public function groupMeta(Request $request): Response + { + $response = $this->initializeBackend($request); + if ($response !== null) { + return $response; + } + + $groupId = intval($request->get('group_id', 0)); + if ($groupId <= 0) { + return $this->error(__('Invalid parameters')); + } + + $group = Db::name('admin_group')->where('id', $groupId)->field(['id', 'pid', 'channel_id'])->find(); + if (!is_array($group)) { + return $this->error(__('Record not found')); + } + + if (!$this->auth->isSuperAdmin()) { + $authGroups = $this->getManageableGroupIds(); + if (!in_array($groupId, $authGroups, true)) { + return $this->error(__('You have no permission')); + } + } + + $pid = intval($group['pid'] ?? 0); + + return $this->success('', [ + 'is_top_level' => $pid === 0, + 'pid' => $pid, + 'channel_id' => $group['channel_id'] ?? null, ]); } @@ -272,7 +336,7 @@ class Admin extends Backend } } - $parentErr = $this->normalizeParentAndShareFields($data, null); + $parentErr = $this->normalizeParentAndShareFields($data, null, $data['group_arr'] ?? []); if ($parentErr !== null) { return $this->error($parentErr); } @@ -429,7 +493,7 @@ class Admin extends Backend if (!$this->auth->isSuperAdmin()) { unset($data['parent_admin_id']); } - $parentErr = $this->normalizeParentAndShareFields($data, intval($id)); + $parentErr = $this->normalizeParentAndShareFields($data, intval($id), $editGroupArr ?? []); if ($parentErr !== null) { return $this->error($parentErr); } @@ -463,8 +527,18 @@ class Admin extends Backend $rowData = $row->toArray(); $enriched = $this->enrichAdminRows([$rowData]); $rowData = $enriched[0] ?? $rowData; + $groupArr = is_array($rowData['group_arr'] ?? null) ? $rowData['group_arr'] : []; + $rowData['primary_group_is_top_level'] = $this->isPrimaryGroupTopLevel($groupArr); $parentId = intval($rowData['parent_admin_id'] ?? 0); - if ($parentId > 0) { + if ($rowData['primary_group_is_top_level']) { + $channelId = intval($rowData['channel_id'] ?? 0); + if ($channelId > 0) { + $rowData['root_share_remainder'] = AdminCommissionDistributionService::getChannelRootShareRemainder( + $channelId, + intval($id) + ); + } + } elseif ($parentId > 0) { $remainder = AdminCommissionDistributionService::getShareRemainder($parentId, intval($id)); $rowData['share_remainder'] = $remainder; } @@ -594,10 +668,51 @@ class Admin extends Backend } /** - * @param array $data + * @param array $groupIds */ - private function normalizeParentAndShareFields(array &$data, ?int $editAdminId): ?string + private function isPrimaryGroupTopLevel(array $groupIds): bool { + if ($groupIds === []) { + return false; + } + $gid = intval($groupIds[0]); + if ($gid <= 0) { + return false; + } + $pid = Db::name('admin_group')->where('id', $gid)->value('pid'); + + return intval($pid ?? 0) === 0; + } + + /** + * @param array $data + * @param array $groupIds + */ + private function normalizeParentAndShareFields(array &$data, ?int $editAdminId, array $groupIds = []): ?string + { + if ($this->isPrimaryGroupTopLevel($groupIds)) { + $data['parent_admin_id'] = null; + $channelId = $data['channel_id'] ?? null; + if ($channelId === null || $channelId === '') { + $channelId = $this->resolveChannelIdFromPrimaryGroup($groupIds); + if ($channelId !== null && $channelId !== '') { + $data['channel_id'] = $channelId; + } + } + $channelIdInt = intval($channelId ?? 0); + $shareErr = AdminCommissionDistributionService::validateChannelRootCommissionShareRate( + $channelIdInt, + $data['commission_share_rate'] ?? null, + $editAdminId + ); + if ($shareErr !== null) { + return $shareErr; + } + $data['commission_share_rate'] = bcadd(strval($data['commission_share_rate'] ?? '0'), '0', 2); + + return null; + } + $parentId = isset($data['parent_admin_id']) && $data['parent_admin_id'] !== '' && $data['parent_admin_id'] !== null ? intval($data['parent_admin_id']) : 0; @@ -608,6 +723,7 @@ class Admin extends Backend if ($parentId <= 0) { $data['parent_admin_id'] = null; $data['commission_share_rate'] = null; + return null; } diff --git a/app/common/lang/en/service.php b/app/common/lang/en/service.php index 014e8b3..4254874 100644 --- a/app/common/lang/en/service.php +++ b/app/common/lang/en/service.php @@ -10,6 +10,9 @@ return [ 'Sub-agent commission share rate is required' => 'Commission share rate from parent is required for sub-agents', 'Commission share rate must be between 0 and 100' => 'Commission share rate must be between 0 and 100', 'Sum of sibling commission share rates cannot exceed 100%' => 'Sum of sibling commission share rates cannot exceed 100%', + 'Sum of channel top-level commission share rates cannot exceed 100%' => 'Sum of channel top-level commission share rates cannot exceed 100%', + 'Top-level agent commission share rate is required' => 'Commission share rate from channel is required for top-level role group administrators', + 'Channel is required for top-level agent commission share' => 'Channel must be set before configuring top-level commission share rate', 'Parent administrator is required for sub-agent' => 'Parent administrator is required for sub-agent', 'Invalid parent administrator' => 'Invalid parent administrator', 'Parent administrator must belong to the same channel' => 'Parent administrator must belong to the same channel', diff --git a/app/common/lang/zh-cn/service.php b/app/common/lang/zh-cn/service.php index 6359b29..f93be61 100644 --- a/app/common/lang/zh-cn/service.php +++ b/app/common/lang/zh-cn/service.php @@ -10,6 +10,9 @@ return [ 'Sub-agent commission share rate is required' => '子代理须填写从上级分红中抽取的比例', 'Commission share rate must be between 0 and 100' => '分红比例须在0到100之间', 'Sum of sibling commission share rates cannot exceed 100%' => '同上级下各子代理分红比例合计不能超过100%', + 'Sum of channel top-level commission share rates cannot exceed 100%' => '同渠道顶级代理分红比例合计不能超过100%', + 'Top-level agent commission share rate is required' => '顶级角色组管理员须填写从渠道分红中分得的比例', + 'Channel is required for top-level agent commission share' => '顶级角色组管理员须先绑定渠道后再设置分红比例', 'Parent administrator is required for sub-agent' => '子代理须绑定上级管理员', 'Invalid parent administrator' => '上级管理员无效', 'Parent administrator must belong to the same channel' => '上级管理员须与当前渠道一致', diff --git a/app/common/service/AdminCommissionDistributionService.php b/app/common/service/AdminCommissionDistributionService.php index 4c82312..5f3a41c 100644 --- a/app/common/service/AdminCommissionDistributionService.php +++ b/app/common/service/AdminCommissionDistributionService.php @@ -81,6 +81,57 @@ class AdminCommissionDistributionService return ['used_rate' => $used, 'remaining_rate' => $remaining]; } + /** + * 同渠道下顶级代理(无上级)已占用的渠道分红比例 + * + * @return array{used_rate:string,remaining_rate:string} + */ + public static function getChannelRootShareRemainder(int $channelId, ?int $excludeAdminId = null): array + { + $used = '0.00'; + if ($channelId <= 0) { + return ['used_rate' => $used, 'remaining_rate' => '100.00']; + } + $query = Db::name('admin') + ->where('channel_id', $channelId) + ->where('status', 'enable') + ->whereRaw('(parent_admin_id IS NULL OR parent_admin_id = 0)'); + if ($excludeAdminId !== null && $excludeAdminId > 0) { + $query->where('id', '<>', $excludeAdminId); + } + $rows = $query->column('commission_share_rate'); + foreach ($rows as $rate) { + if ($rate === null || $rate === '') { + continue; + } + $used = bcadd($used, bcadd(strval($rate), '0', 2), 2); + } + $remaining = bcsub('100.00', $used, 2); + if (bccomp($remaining, '0', 2) < 0) { + $remaining = '0.00'; + } + return ['used_rate' => $used, 'remaining_rate' => $remaining]; + } + + public static function validateChannelRootCommissionShareRate(int $channelId, mixed $rateRaw, ?int $excludeAdminId = null): ?string + { + if ($channelId <= 0) { + return (string) __('Channel is required for top-level agent commission share'); + } + if ($rateRaw === null || $rateRaw === '') { + return (string) __('Top-level agent commission share rate is required'); + } + $rate = bcadd(strval($rateRaw), '0', 2); + if (bccomp($rate, '0', 2) <= 0 || bccomp($rate, '100', 2) > 0) { + return (string) __('Commission share rate must be between 0 and 100'); + } + $remainder = self::getChannelRootShareRemainder($channelId, $excludeAdminId); + if (bccomp(bcadd($remainder['used_rate'], $rate, 2), '100.00', 2) > 0) { + return (string) __('Sum of channel top-level commission share rates cannot exceed 100%'); + } + return null; + } + public static function validateCommissionShareRate(?int $parentAdminId, mixed $rateRaw, ?int $excludeAdminId = null): ?string { if ($parentAdminId === null || $parentAdminId <= 0) { @@ -110,35 +161,66 @@ class AdminCommissionDistributionService if ($channelId <= 0 || bccomp($totalCommission, '0', 2) <= 0) { return []; } - $roots = Db::name('admin') + $rootRows = Db::name('admin') ->where('channel_id', $channelId) ->where('status', 'enable') ->whereRaw('(parent_admin_id IS NULL OR parent_admin_id = 0)') ->order('id', 'asc') - ->column('id'); - if ($roots === []) { + ->field(['id', 'commission_share_rate']) + ->select() + ->toArray(); + if ($rootRows === []) { return []; } - $rootCount = count($roots); - $perRoot = bcdiv($totalCommission, strval($rootCount), 2); - $assigned = '0.00'; + $useRateSplit = true; + foreach ($rootRows as $rootRow) { + $rate = bcadd(strval($rootRow['commission_share_rate'] ?? '0'), '0', 2); + if (bccomp($rate, '0', 2) <= 0) { + $useRateSplit = false; + break; + } + } $merged = []; - foreach ($roots as $index => $rootId) { - $rootId = intval($rootId); - if ($rootId <= 0) { - continue; - } - $isLast = $index === $rootCount - 1; - $rootAmount = $isLast ? bcsub($totalCommission, $assigned, 2) : $perRoot; - if (!$isLast) { - $assigned = bcadd($assigned, $rootAmount, 2); - } - $parts = self::distributeFromAdmin($rootId, $rootAmount, $calcBaseAmount); - foreach ($parts as $adminId => $amount) { - if (!isset($merged[$adminId])) { - $merged[$adminId] = '0.00'; + if ($useRateSplit) { + foreach ($rootRows as $rootRow) { + $rootId = intval($rootRow['id'] ?? 0); + if ($rootId <= 0) { + continue; + } + $rate = bcadd(strval($rootRow['commission_share_rate'] ?? '0'), '0', 2); + $rootAmount = bcmul($totalCommission, bcdiv($rate, '100', 4), 2); + if (bccomp($rootAmount, '0', 2) <= 0) { + continue; + } + $parts = self::distributeFromAdmin($rootId, $rootAmount, $calcBaseAmount); + foreach ($parts as $adminId => $amount) { + if (!isset($merged[$adminId])) { + $merged[$adminId] = '0.00'; + } + $merged[$adminId] = bcadd($merged[$adminId], $amount, 2); + } + } + } else { + $rootCount = count($rootRows); + $perRoot = bcdiv($totalCommission, strval($rootCount), 2); + $assigned = '0.00'; + foreach ($rootRows as $index => $rootRow) { + $rootId = intval($rootRow['id'] ?? 0); + if ($rootId <= 0) { + continue; + } + $isLast = $index === $rootCount - 1; + $rootAmount = $isLast ? bcsub($totalCommission, $assigned, 2) : $perRoot; + if (!$isLast) { + $assigned = bcadd($assigned, $rootAmount, 2); + } + $parts = self::distributeFromAdmin($rootId, $rootAmount, $calcBaseAmount); + foreach ($parts as $adminId => $amount) { + if (!isset($merged[$adminId])) { + $merged[$adminId] = '0.00'; + } + $merged[$adminId] = bcadd($merged[$adminId], $amount, 2); } - $merged[$adminId] = bcadd($merged[$adminId], $amount, 2); } } $out = []; diff --git a/docs/36字花-数据库与实施计划.md b/docs/36字花-数据库与实施计划.md index f48af92..5e9e8ee 100644 --- a/docs/36字花-数据库与实施计划.md +++ b/docs/36字花-数据库与实施计划.md @@ -169,13 +169,14 @@ | 渠道归属 | `channel_id` 表示该子代理属于哪个顶级渠道 | | 邀请管理 | `invite_code` 自动生成且全局唯一,用于发展玩家并做归属绑定 | | 角色标识 | `agent_role`(如 `agent_admin` / `sub_agent` / `staff`) | -| 分红设置 | 在 **管理员管理** 维护:`parent_admin_id`(上级代理)、`commission_share_rate`(从上级分红抽取比例 %);顶级代理留空上级与比例 | +| 分红设置 | 在 **管理员管理** 维护:`parent_admin_id`(非顶级角色组必填)、`commission_share_rate`(顶级角色组=从渠道总佣金分得 %;子代理=从上级实得抽取 %) | | 开奖权限 | 不在数据层开放给渠道/子代理;开奖权限仅由超管 RBAC 控制 | ### 5.1 分红计算口径(现行,2026-05-29) - **渠道分红**:先按 `channel.agent_mode` 与已结算注单(`bet_order.status=2`)计算 **渠道总佣金**(非充值口径) -- **管理员分红**:总佣金进入渠道 **顶级代理** 后,按 `admin.parent_admin_id` 树递归拆分: +- **管理员分红**:渠道总佣金先按顶级代理 `commission_share_rate` 划入,再按 `admin.parent_admin_id` 树递归拆分: + - 顶级实得 = 渠道本期总佣金 × 顶级 `commission_share_rate`(%) - 子代理实得 = 上级本期实得 × `commission_share_rate`(%) - 上级保留 = 上级实得 − 所有直属子代理实得之和 - 同一上级下子代理比例合计 **≤ 100%** @@ -405,6 +406,7 @@ | V1.15 | 2026-04-20 | 热点写路径收口:`GameHotDataCoordinator` + `GameHotDataLock` + `GameHotDataWriteQueue` / `GameHotDataQueueConsumer`;文档与实现对齐(替代仅 `*Forget` 描述);移动端 `betPlace` 与后台钱包共用用户互斥锁及 `coin` 乐观更新 | | V1.16 | 2026-04-23 | 渠道结算改为单阶段口径(仅超管结算,结算即按比例发放管理员钱包并记录操作人)与管理员钱包提现流程(`admin_wallet` / `admin_wallet_record` / `admin_withdraw_order`,渠道顶级组审核) | | V1.17 | 2026-05-29 | 代理分红改为树形拆分:`admin.commission_share_rate` + `parent_admin_id`;配置迁移至管理员管理;渠道页移除 `channel_admin_share` 入口;管理员列表树形展示与下级可见范围 | +| V1.18 | 2026-05-29 | 顶级角色组(`admin_group.pid=0`)可配置渠道分红比例;表单禁用上级代理并增加说明 | --- @@ -447,7 +449,7 @@ |---------|------| | `admin.parent_admin_id` | 子代理上下级 | | `admin.channel_id` | 所属渠道 | -| `admin.commission_share_rate` | 从上级本期分红抽取比例(%);顶级代理为空 | +| `admin.commission_share_rate` | 顶级角色组:从渠道本期总佣金分得比例(%);子代理:从上级实得抽取比例(%) | | `admin.invite_code` | 子代理邀请码,注册归属 | | `admin.agent_role` | 角色类型(均无开奖权) | | `admin_group.channel_id` | 角色组归属渠道;`NULL` 可为系统级(仅超管) | diff --git a/docs/36字花-移动端接口设计草案.md b/docs/36字花-移动端接口设计草案.md index dd1bd02..b565f4b 100644 --- a/docs/36字花-移动端接口设计草案.md +++ b/docs/36字花-移动端接口设计草案.md @@ -978,19 +978,29 @@ flowchart TD | 字段 | 说明 | |------|------| | `channel_id` | 所属渠道(超管可选;非超管随角色组/当前账号) | -| `parent_admin_id` | 上级代理;留空表示渠道 **顶级代理** | -| `commission_share_rate` | 从上级本期分红抽取的比例(0–100);有上级时必填 | -| `group_arr` | 角色组(单选,仅权限) | +| `parent_admin_id` | 上级代理;**顶级角色组**(`admin_group.pid=0`)时留空且不可选 | +| `commission_share_rate` | **顶级角色组**:从渠道本期总佣金分得的比例(0–100),必填;**子代理**:从上级本期实得抽取的比例,有上级时必填 | +| `group_arr` | 角色组(单选,仅权限;是否顶级由 `pid=0` 判定) | ### 10.3 校验与提示 +**顶级角色组**: + +- 上级代理字段禁用,并提示「无需绑定上级代理」 +- 须填写分红比例;实得 = **渠道本期总佣金 × 本项比例** +- 同一 `channel_id` 下顶级代理比例 **合计 ≤ 100%** +- 表单调用 **GET** `/admin/auth.Admin/commissionShareRemainder?is_top_level=1&channel_id=&exclude_id=` +- 角色组联动:**GET** `/admin/auth.Admin/groupMeta?group_id=` + +**子代理**: + - 同一 `parent_admin_id` 下,启用子代理的 `commission_share_rate` **合计 ≤ 100%** - 表单调用 **GET** `/admin/auth.Admin/commissionShareRemainder?parent_admin_id=&exclude_id=` 展示剩余可分配比例 - 合计 100% 时提示:上级在本层将无分红留存 ### 10.4 结算拆分(与渠道结算联动) -- 渠道结算得到总佣金后,由 `AdminCommissionDistributionService` 从顶级代理起递归拆分 +- 渠道结算得到总佣金后,由 `AdminCommissionDistributionService` 先按顶级代理 `commission_share_rate` 划入,再向下递归拆分 - 每个管理员实得写入 `agent_commission_record` 并 **即时入账** `admin_wallet` - 须存在至少一名渠道顶级代理,否则结算失败 diff --git a/docs/en/36zihua-mobile-api-design-draft.md b/docs/en/36zihua-mobile-api-design-draft.md index c639f27..a534ad5 100644 --- a/docs/en/36zihua-mobile-api-design-draft.md +++ b/docs/en/36zihua-mobile-api-design-draft.md @@ -871,19 +871,29 @@ flowchart TD | Field | Description | |-------|-------------| | `channel_id` | Channel (super-admin selectable; others follow role/account) | -| `parent_admin_id` | Parent agent; empty = channel **root agent** | -| `commission_share_rate` | Share taken from parent’s period commission (0–100); required when parent set | -| `group_arr` | Role group (single select, permissions only) | +| `parent_admin_id` | Parent agent; **top-level role group** (`admin_group.pid=0`): empty and disabled | +| `commission_share_rate` | **Top-level role group**: share of channel period total (0–100), required; **sub-agent**: share from parent’s net amount, required when parent set | +| `group_arr` | Role group (single select, permissions; top-level when `pid=0`) | ### 10.3 Validation & Hints +**Top-level role group:** + +- Parent agent field disabled with hint “no parent required” +- Share rate required; amount = **channel period total × this rate** +- Under same `channel_id`, top-level agents’ rates **≤ 100% total** +- **GET** `/admin/auth.Admin/commissionShareRemainder?is_top_level=1&channel_id=&exclude_id=` +- Role group linkage: **GET** `/admin/auth.Admin/groupMeta?group_id=` + +**Sub-agent:** + - Under same `parent_admin_id`, sum of enabled children’s `commission_share_rate` **≤ 100%** -- Form calls **GET** `/admin/auth.Admin/commissionShareRemainder?parent_admin_id=&exclude_id=` for remaining allocatable % +- **GET** `/admin/auth.Admin/commissionShareRemainder?parent_admin_id=&exclude_id=` for remaining allocatable % - At 100% total: hint that parent retains no share at this level ### 10.4 Settlement Split (Channel Settlement Integration) -- After channel settlement total commission, **`AdminCommissionDistributionService`** splits recursively from root agent +- After channel settlement total commission, **`AdminCommissionDistributionService`** allocates to top-level agents by `commission_share_rate`, then splits recursively downline - Each admin’s net amount → `agent_commission_record` and **immediate credit** to `admin_wallet` - At least one channel root agent required; else settlement fails diff --git a/docs/en/commission-share-guide.md b/docs/en/commission-share-guide.md index a02318c..b589273 100644 --- a/docs/en/commission-share-guide.md +++ b/docs/en/commission-share-guide.md @@ -22,10 +22,12 @@ Commission is calculated in two steps: - **Do not** configure flat channel-wide shares on the channel page (`channel_admin_share` is deprecated in UI; table may remain for history) - Maintain the agent tree in **Administrator Management** (`/admin/auth/admin`): - - `parent_admin_id`: parent agent (empty for top-level) - - `commission_share_rate`: percentage taken from **parent’s commission for this period** (sub-agents only) + - `parent_admin_id`: parent agent (**required for non–top-level role groups**; **disabled and empty when role group `admin_group.pid = 0`**) + - `commission_share_rate`: + - **Top-level role group**: share (%) of **channel period total commission**; amount = channel period total × this rate + - **Sub-agent**: share (%) taken from **parent’s commission for this period** - At settlement, `AdminCommissionDistributionService` splits recursively: - 1. Channel total commission goes to **top-level agent(s)** (`parent_admin_id` is null) + 1. Channel total commission is allocated to **top-level agents** by their `commission_share_rate` (sum per channel ≤ 100%; legacy equal split if rates missing) 2. Each agent allocates to direct children by `commission_share_rate` from **their own received amount** 3. **Parent keeps** = received amount − sum allocated to children @@ -51,12 +53,23 @@ If a sub-agent has further downline, the same rules apply on **their received am | Visibility | Admin list | Non–super admin sees **self + all downline** only | | Settlement | `/admin/channel` manual / cron | **Super admin only**; credits `admin_wallet` on settle | -### 3.1 Sub-agent share validation +### 3.1 Share rate validation -- Under the same `parent_admin_id`, enabled sub-agents’ `commission_share_rate` **must not exceed 100% in total** -- Form shows **remaining allocatable rate** when creating/editing sub-agents -- If total is 100%, parent keeps **no commission** at this level -- Top-level agents (no parent) **do not** set `commission_share_rate` +**Top-level role group** (`admin_group.pid = 0`): + +- No parent agent required or allowed +- `commission_share_rate` is required: share of **channel period total commission** +- Under the same `channel_id`, top-level agents’ rates **must not exceed 100% in total** +- Form shows remaining allocatable share on the channel + +**Sub-agent** (non–top-level role group): + +- `parent_admin_id` required +- `commission_share_rate` is share from **parent’s net amount this period** +- Under the same `parent_admin_id`, enabled sub-agents’ rates **must not exceed 100% in total** +- Form shows remaining allocatable share under the parent + +If a level totals 100%, the parent at that level keeps **no commission**. ### 3.2 Role groups @@ -96,7 +109,7 @@ If a sub-agent has further downline, the same rules apply on **their received am | `channel.agent_mode` / `turnover_share_rate` / `affiliate_*` | Channel commission calculation | | `admin.parent_admin_id` | Parent agent | | `admin.channel_id` | Channel | -| `admin.commission_share_rate` | Share from parent (%); null for top-level | +| `admin.commission_share_rate` | Top-level role group: share of channel period total (%); sub-agent: share from parent (%) | | `agent_settlement_period` | Settlement period snapshot | | `agent_commission_record` | Paid commission per admin | | `admin_wallet` / `admin_wallet_record` | Admin wallet & ledger | @@ -124,3 +137,4 @@ If a sub-agent has further downline, the same rules apply on **their received am | 2026-04-18 | `channel_admin_share` flat split; removed `admin`/`admin_group.commission_rate` | | 2026-04-23 | Settle-and-pay to admin wallet; `admin_wallet` system | | 2026-05-29 | **Agent tree commission** in Administrator Management; removed channel share UI; tree list & downline visibility | +| 2026-05-29 | Top-level role groups (`pid=0`) require channel share rate; parent agent disabled in form | diff --git a/docs/分红说明文档.md b/docs/分红说明文档.md index add87cd..a717542 100644 --- a/docs/分红说明文档.md +++ b/docs/分红说明文档.md @@ -22,10 +22,12 @@ - **不再**在渠道管理页维护「渠道内管理员分配比例」(`channel_admin_share` 已废弃于 UI,历史表可保留) - 在 **管理员管理**(`/admin/auth/admin`)维护代理树: - - `parent_admin_id`:上级代理(顶级留空) - - `commission_share_rate`:从 **上级本期分红** 中抽取的比例(%),仅子代理需要填写 + - `parent_admin_id`:上级代理(**非顶级角色组**必填;**顶级角色组** `admin_group.pid=0` 时留空且不可选) + - `commission_share_rate`: + - **顶级角色组**:从 **渠道本期总佣金** 中分得的比例(%);实得 = 渠道本期总佣金 × 本项比例 + - **子代理**:从 **上级本期分红** 中抽取的比例(%) - 结算时由 `AdminCommissionDistributionService` 递归拆分: - 1. 渠道总佣金先进入该渠道 **顶级代理**(`parent_admin_id` 为空) + 1. 渠道总佣金按各 **顶级代理** 的 `commission_share_rate` 划入(同渠道合计 ≤ 100%;未配置比例时历史数据均分兜底) 2. 每个代理按直属子代理的 `commission_share_rate`,从 **自己拿到的金额** 中划出子代理份额 3. **上级保留** = 自己拿到的金额 − 已分给所有子代理的金额 @@ -51,12 +53,23 @@ | 数据可见范围 | 管理员列表 | 非超管仅见 **本人 + 全部下级**,不见其他代理线 | | 结算执行 | `/admin/channel` 手动结算 / 定时任务 | **仅超管**可结算;结算即发放至 `admin_wallet` | -### 3.1 子代理分红比例校验 +### 3.1 分红比例校验 -- 同一 `parent_admin_id` 下,所有启用子代理的 `commission_share_rate` **合计不得超过 100%** -- 创建/编辑子代理时,表单会提示 **当前剩余可分配比例** -- 若合计为 100%,上级在本层 **不再保留分红** -- 顶级代理(无上级)**不需要**填写 `commission_share_rate` +**顶级角色组**(`admin_group.pid = 0`): + +- 无需、不可绑定上级代理 +- 须填写 `commission_share_rate`,表示从 **渠道本期总佣金** 中分得的比例 +- 同一 `channel_id` 下,所有无上级顶级代理的比例 **合计 ≤ 100%** +- 表单提示同渠道剩余可分配比例 + +**子代理**(非顶级角色组): + +- 须绑定 `parent_admin_id` +- `commission_share_rate` 表示从 **上级本期实得** 中抽取的比例 +- 同一 `parent_admin_id` 下,启用子代理比例 **合计 ≤ 100%** +- 表单提示同上级剩余可分配比例 + +若某层合计为 100%,该层上级 **不再保留分红**。 ### 3.2 角色组的作用 @@ -96,7 +109,7 @@ | `channel.agent_mode` / `turnover_share_rate` / `affiliate_*` | 渠道总佣金计算参数 | | `admin.parent_admin_id` | 上级代理 | | `admin.channel_id` | 所属渠道 | -| `admin.commission_share_rate` | 从上级分红抽取比例(%),顶级为空 | +| `admin.commission_share_rate` | 顶级角色组:从渠道本期总佣金分得比例(%);子代理:从上级分红抽取比例(%) | | `agent_settlement_period` | 结算周期与大盘快照 | | `agent_commission_record` | 各管理员本期实发佣金 | | `admin_wallet` / `admin_wallet_record` | 管理员钱包与入账流水 | @@ -125,3 +138,4 @@ | 2026-04-23 | 超管结算即发放至管理员钱包;新增 `admin_wallet` 体系 | | 2026-05-29 | **改为代理树形分红**:配置迁移至管理员管理 `commission_share_rate` + `parent_admin_id`;移除渠道页分配比例入口;管理员列表树形展示与下级可见范围 | | 2026-05-29 | 新增英文文档 `docs/en/commission-share-guide.md`;后台切换 `lang=en` 时文档页自动加载英文版 | +| 2026-05-29 | **顶级角色组可配置渠道分红比例**:`pid=0` 时禁用上级代理并必填 `commission_share_rate`;结算按顶级比例划入后再向下拆分 | diff --git a/docs/后端.md b/docs/后端.md index 11bf13d..1485914 100644 --- a/docs/后端.md +++ b/docs/后端.md @@ -76,7 +76,7 @@ * **提现手续费收益报表**:单独统计系统抽取的 0.5% 手续费总收入。 ### 3.5 🏢 代理中枢与佣金结算系统 (Agent System) -* **创建总代/子代账号**:在 **管理员管理**(`/admin/auth/admin`)维护代理树:`parent_admin_id`、`commission_share_rate`(从上级分红抽取 %)、`channel_id`、邀请码。 +* **创建总代/子代账号**:在 **管理员管理**(`/admin/auth/admin`)维护代理树:`parent_admin_id`、`commission_share_rate`(顶级角色组从渠道总佣金分得 %,子代理从上级实得抽取 %)、`channel_id`、邀请码。 * **代理树状图 (Tree View)**:管理员列表以树形展示;非超管仅见本人及全部下级。 * **渠道佣金结算**(仅超管): * 按渠道 `agent_mode` 与已结算注单计算渠道总佣金(非充值口径)。 diff --git a/web/src/lang/backend/en/auth/admin.ts b/web/src/lang/backend/en/auth/admin.ts index 9313ea9..402213d 100644 --- a/web/src/lang/backend/en/auth/admin.ts +++ b/web/src/lang/backend/en/auth/admin.ts @@ -18,6 +18,13 @@ export default { 'Manage subordinate agents here': 'Manage your subordinate agents here. You can only see yourself and your downline, not sub-agents under other agents.', 'Parent admin placeholder': 'Leave empty for top-level channel agent', + 'Top level group parent hint': + 'The selected role group is top-level; no parent agent is required. Settlement uses the channel commission share configured here.', + 'Top level share formula hint': + 'For a top-level role group, this rate is the share (%) of the channel period total commission. Amount = channel period total × this rate; sub-agents still take their share from their parent’s received amount.', + 'Channel root share remainder hint': + 'Other top-level agents on this channel already use {used}%; remaining allocatable {remaining}%. After saving this value, about {after}% remains on the channel.', + 'Channel root share remainder none': 'Top-level shares on this channel already total 100%; no remaining allocatable share.', 'Share remainder hint': 'Siblings already use {used}%; remaining allocatable {remaining}%. After saving this value, parent keeps about {after}%.', 'Share remainder none for parent': 'Sibling shares total 100%; the parent will keep no commission at this level.', diff --git a/web/src/lang/backend/zh-cn/auth/admin.ts b/web/src/lang/backend/zh-cn/auth/admin.ts index c82aa83..86020e0 100644 --- a/web/src/lang/backend/zh-cn/auth/admin.ts +++ b/web/src/lang/backend/zh-cn/auth/admin.ts @@ -17,6 +17,11 @@ export default { 'Administrator login': '管理员登录名', 'Manage subordinate agents here': '在此管理您下级代理管理员;仅显示您本人及所有下级,无法查看其他代理线下的子代理。', 'Parent admin placeholder': '留空表示渠道顶级代理', + 'Top level group parent hint': '当前角色组为顶级角色组,无需绑定上级代理;系统将按渠道分红比例直接结算至该管理员。', + 'Top level share formula hint': + '顶级角色组分红比例表示从渠道本期总佣金中分得的比例(%)。实得 = 渠道本期总佣金 × 本项分红比例;下级子代理仍从其上级实得中按各自比例抽取。', + 'Channel root share remainder hint': '同渠道顶级代理已分配 {used}%,当前剩余可设 {remaining}%;若本项设为当前值,保存后同渠道约剩 {after}%。', + 'Channel root share remainder none': '同渠道顶级代理比例已合计 100%,无法再分配。', 'Share remainder hint': '同上级下已分配 {used}%,当前剩余可设 {remaining}%;若本项设为当前值,保存后上级约剩 {after}%。', 'Share remainder none for parent': '同上级下子代理比例已合计 100%,上级在本层将无分红留存。', 'Share remainder none after current': '当前比例将超过剩余可分配额度,请调低后再保存。', diff --git a/web/src/views/backend/auth/admin/popupForm.vue b/web/src/views/backend/auth/admin/popupForm.vue index cc0007a..f777857 100644 --- a/web/src/views/backend/auth/admin/popupForm.vue +++ b/web/src/views/backend/auth/admin/popupForm.vue @@ -78,10 +78,14 @@ remoteUrl: '/admin/auth.Admin/index', field: 'username', pk: 'id', + disabled: isTopLevelGroup, params: parentSelectParams, placeholder: t('auth.admin.Parent admin placeholder'), }" /> + + + + + + @@ -185,6 +192,7 @@ const { t } = useI18n() const shareHint = ref('') const shareHintType = ref<'info' | 'warning'>('info') +const isTopLevelGroup = ref(false) const isSelfEdit = computed(() => baTable.form.operate === 'Edit' && adminInfo.id == baTable.form.items?.id) @@ -194,6 +202,9 @@ const showParentField = computed(() => adminInfo.super && !isSelfEdit.value) const showShareRateField = computed(() => { if (isSelfEdit.value) return false + if (isTopLevelGroup.value) { + return adminInfo.super || baTable.form.operate === 'Add' + } if (adminInfo.super) { const pid = baTable.form.items?.parent_admin_id return pid !== null && pid !== undefined && pid !== '' && Number(pid) > 0 @@ -238,11 +249,87 @@ const singleGroupValue = computed({ }, }) +const resolveChannelIdForShare = (): number => { + const cid = baTable.form.items?.channel_id + if (cid !== null && cid !== undefined && cid !== '') { + return Number(cid) + } + return 0 +} + +const loadGroupMeta = async (groupId: unknown) => { + if (groupId === null || groupId === undefined || groupId === '') { + isTopLevelGroup.value = false + return + } + try { + const res = await createAxios({ + url: '/admin/auth.Admin/groupMeta', + method: 'get', + params: { group_id: groupId }, + }) + isTopLevelGroup.value = !!res.data.is_top_level + if (isTopLevelGroup.value && baTable.form.items) { + baTable.form.items.parent_admin_id = null + const metaChannelId = res.data.channel_id + if ( + adminInfo.super && + (baTable.form.items.channel_id === null || + baTable.form.items.channel_id === undefined || + baTable.form.items.channel_id === '') && + metaChannelId !== null && + metaChannelId !== undefined && + metaChannelId !== '' + ) { + baTable.form.items.channel_id = metaChannelId + } + } + } catch { + isTopLevelGroup.value = false + } +} + const loadShareRemainder = async () => { if (!showShareRateField.value) { shareHint.value = '' return } + if (isTopLevelGroup.value) { + const channelId = resolveChannelIdForShare() + try { + const res = await createAxios({ + url: '/admin/auth.Admin/commissionShareRemainder', + method: 'get', + params: { + is_top_level: 1, + channel_id: channelId, + exclude_id: baTable.form.items?.id || 0, + }, + }) + const remaining = res.data.remaining_rate ?? '100.00' + const used = res.data.used_rate ?? '0.00' + const current = baTable.form.items?.commission_share_rate + let afterCurrent = remaining + if (current !== null && current !== undefined && current !== '') { + afterCurrent = (Number(remaining) - Number(current)).toFixed(2) + } + shareHint.value = t('auth.admin.Channel root share remainder hint', { + used, + remaining, + after: afterCurrent, + }) + shareHintType.value = Number(remaining) <= 0 || Number(afterCurrent) < 0 ? 'warning' : 'info' + if (Number(remaining) <= 0) { + shareHint.value = t('auth.admin.Channel root share remainder none') + } else if (Number(afterCurrent) <= 0 && current) { + shareHint.value = t('auth.admin.Share remainder none after current') + } + } catch { + shareHint.value = '' + } + return + } + let parentId = baTable.form.items?.parent_admin_id if (!adminInfo.super) { parentId = adminInfo.id @@ -284,17 +371,41 @@ const loadShareRemainder = async () => { } watch( - () => [baTable.form.items?.parent_admin_id, baTable.form.items?.commission_share_rate, baTable.form.operate], + () => singleGroupValue.value, + (groupId) => { + void loadGroupMeta(groupId) + }, + { immediate: true } +) + +watch( + () => [ + baTable.form.items?.parent_admin_id, + baTable.form.items?.commission_share_rate, + baTable.form.items?.channel_id, + baTable.form.operate, + isTopLevelGroup.value, + ], () => { void loadShareRemainder() }, { immediate: true } ) +watch( + () => baTable.form.items?.primary_group_is_top_level, + (val) => { + if (typeof val === 'boolean') { + isTopLevelGroup.value = val + } + }, + { immediate: true } +) + watch( () => baTable.form.items?.share_remainder, (val) => { - if (val && baTable.form.operate === 'Edit') { + if (val && baTable.form.operate === 'Edit' && !isTopLevelGroup.value) { shareHint.value = t('auth.admin.Share remainder hint', { used: val.used_rate ?? '0.00', remaining: val.remaining_rate ?? '100.00', @@ -305,6 +416,26 @@ watch( { immediate: true } ) +watch( + () => baTable.form.items?.root_share_remainder, + (val) => { + if (val && baTable.form.operate === 'Edit' && isTopLevelGroup.value) { + shareHint.value = t('auth.admin.Channel root share remainder hint', { + used: val.used_rate ?? '0.00', + remaining: val.remaining_rate ?? '100.00', + after: val.remaining_rate ?? '100.00', + }) + } + }, + { immediate: true } +) + +watch(isTopLevelGroup, (topLevel) => { + if (topLevel && baTable.form.items) { + baTable.form.items.parent_admin_id = null + } +}) + const rules: Partial> = reactive({ username: [buildValidatorData({ name: 'required', title: t('auth.admin.username') }), buildValidatorData({ name: 'account' })], nickname: [buildValidatorData({ name: 'required', title: t('auth.admin.nickname') })],