1.优化分红方式
This commit is contained in:
@@ -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<string, mixed> $data
|
||||
* @param array<int|string> $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<string, mixed> $data
|
||||
* @param array<int|string> $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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' => '上级管理员须与当前渠道一致',
|
||||
|
||||
@@ -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,21 +161,51 @@ 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);
|
||||
$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 = [];
|
||||
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';
|
||||
$merged = [];
|
||||
foreach ($roots as $index => $rootId) {
|
||||
$rootId = intval($rootId);
|
||||
foreach ($rootRows as $index => $rootRow) {
|
||||
$rootId = intval($rootRow['id'] ?? 0);
|
||||
if ($rootId <= 0) {
|
||||
continue;
|
||||
}
|
||||
@@ -141,6 +222,7 @@ class AdminCommissionDistributionService
|
||||
$merged[$adminId] = bcadd($merged[$adminId], $amount, 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
$out = [];
|
||||
foreach ($merged as $adminId => $amount) {
|
||||
if (bccomp($amount, '0', 2) <= 0) {
|
||||
|
||||
@@ -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` 可为系统级(仅超管) |
|
||||
|
||||
@@ -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`
|
||||
- 须存在至少一名渠道顶级代理,否则结算失败
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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`;结算按顶级比例划入后再向下拆分 |
|
||||
|
||||
@@ -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` 与已结算注单计算渠道总佣金(非充值口径)。
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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': '当前比例将超过剩余可分配额度,请调低后再保存。',
|
||||
|
||||
@@ -78,10 +78,14 @@
|
||||
remoteUrl: '/admin/auth.Admin/index',
|
||||
field: 'username',
|
||||
pk: 'id',
|
||||
disabled: isTopLevelGroup,
|
||||
params: parentSelectParams,
|
||||
placeholder: t('auth.admin.Parent admin placeholder'),
|
||||
}"
|
||||
/>
|
||||
<el-form-item v-if="showParentField && isTopLevelGroup" label=" ">
|
||||
<el-alert :title="t('auth.admin.Top level group parent hint')" type="info" :closable="false" show-icon />
|
||||
</el-form-item>
|
||||
<FormItem
|
||||
v-if="showShareRateField"
|
||||
:label="t('auth.admin.commission_share_rate')"
|
||||
@@ -96,6 +100,9 @@
|
||||
class: 'w100',
|
||||
}"
|
||||
/>
|
||||
<el-form-item v-if="showShareRateField && isTopLevelGroup" label=" ">
|
||||
<el-alert :title="t('auth.admin.Top level share formula hint')" type="info" :closable="false" show-icon />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="showShareRateField && shareHint" label=" ">
|
||||
<el-alert :title="shareHint" :type="shareHintType" :closable="false" show-icon />
|
||||
</el-form-item>
|
||||
@@ -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<Record<string, FormItemRule[]>> = reactive({
|
||||
username: [buildValidatorData({ name: 'required', title: t('auth.admin.username') }), buildValidatorData({ name: 'account' })],
|
||||
nickname: [buildValidatorData({ name: 'required', title: t('auth.admin.nickname') })],
|
||||
|
||||
Reference in New Issue
Block a user