diff --git a/app/admin/controller/auth/Admin.php b/app/admin/controller/auth/Admin.php index 48342ea..3dfa61c 100644 --- a/app/admin/controller/auth/Admin.php +++ b/app/admin/controller/auth/Admin.php @@ -5,24 +5,32 @@ declare(strict_types=1); namespace app\admin\controller\auth; use ba\Random; +use ba\Tree; use Throwable; use support\think\Db; use support\validation\Validator; use support\validation\ValidationException; use app\common\controller\Backend; +use app\common\service\AdminCommissionDistributionService; use app\admin\model\Admin as AdminModel; use support\Response; use Webman\Http\Request; class Admin extends Backend { + /** + * 分红比例余量查询(表单提示用) + */ + protected array $noNeedPermission = ['commissionShareRemainder']; + protected ?object $model = null; protected array|string $preExcludeFields = ['create_time', 'update_time', 'password', 'salt', 'login_failure', 'last_login_time', 'last_login_ip']; protected array|string $quickSearchField = ['username', 'nickname']; - protected string|int|bool $dataLimit = 'parent'; + /** 使用 parent_admin_id 树过滤,不用角色组 dataLimit */ + protected string|int|bool $dataLimit = false; protected string $dataLimitField = 'id'; @@ -35,41 +43,94 @@ class Admin extends Backend public function index(Request $request): Response { $response = $this->initializeBackend($request); - if ($response !== null) return $response; + if ($response !== null) { + return $response; + } if ($request->get('select') ?? $request->post('select')) { $selectRes = $this->select($request); - if ($selectRes !== null) return $selectRes; + if ($selectRes !== null) { + return $selectRes; + } } list($where, $alias, $limit, $order) = $this->queryBuilder(); + $adminAlias = $alias[strtolower($this->model->getTable())] ?? 'admin'; + $query = $this->model ->withoutField('login_failure,password,salt') - ->withJoin($this->withJoinTable, $this->withJoinType) ->alias($alias) ->where($where); - // 仅返回“顶级角色组(pid=0)”下的管理员(用于远程下拉等场景) + $visibleIds = AdminCommissionDistributionService::getVisibleAdminIdsForOperator( + intval($this->auth->id), + $this->auth->isSuperAdmin() + ); + if ($visibleIds !== []) { + $query->where($adminAlias . '.id', 'in', $visibleIds); + } + $topGroup = $request->get('top_group') ?? $request->post('top_group'); if ($topGroup === '1' || $topGroup === 1 || $topGroup === true) { $query = $query - ->join('admin_group_access aga', $alias['admin'] . '.id = aga.uid') + ->join('admin_group_access aga', $adminAlias . '.id = aga.uid') ->join('admin_group ag', 'aga.group_id = ag.id') ->where('ag.pid', 0) ->distinct(true); } - $res = $query - ->order($order) - ->paginate($limit); - $items = $res->items(); + $rows = $query->order($order)->select()->toArray(); + $rows = $this->enrichAdminRows($rows); + foreach ($rows as $k => $row) { + $parentId = intval($row['parent_admin_id'] ?? 0); + $rows[$k]['pid'] = $parentId > 0 ? $parentId : 0; + } + + $tree = Tree::instance()->assembleChild($rows, 'pid', 'id'); + return $this->success('', [ - 'list' => $items, - 'total' => $res->total(), + 'list' => $tree, + 'total' => count($rows), 'remark' => get_route_remark(), ]); } + /** + * 查询同上级下剩余可分配分红比例(表单提示) + */ + public function commissionShareRemainder(Request $request): Response + { + $response = $this->initializeBackend($request); + if ($response !== null) { + return $response; + } + + $parentAdminId = intval($request->get('parent_admin_id', 0)); + $excludeId = intval($request->get('exclude_id', 0)); + if ($parentAdminId <= 0) { + return $this->success('', [ + 'used_rate' => '0.00', + 'remaining_rate' => '100.00', + 'parent_has_no_share' => false, + ]); + } + + if (!$this->canManageAdminId($parentAdminId)) { + return $this->error(__('You have no permission')); + } + + $stats = AdminCommissionDistributionService::getShareRemainder( + $parentAdminId, + $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, + ]); + } + /** * 远程下拉(重写:支持 top_group=1 仅返回顶级组管理员) */ @@ -88,7 +149,9 @@ class Admin extends Backend $quickSearchArr = is_array($this->quickSearchField) ? $this->quickSearchField : explode(',', (string) $this->quickSearchField); foreach ($quickSearchArr as $f) { $f = trim((string) $f); - if ($f === '') continue; + if ($f === '') { + continue; + } $f = str_contains($f, '.') ? substr($f, strrpos($f, '.') + 1) : $f; if ($f !== '' && !in_array($f, $fields, true)) { $fields[] = $f; @@ -99,15 +162,15 @@ class Admin extends Backend $modelTable = strtolower($this->model->getTable()); $mainAlias = ($alias[$modelTable] ?? $modelTable) . '.'; - // 联表时避免字段歧义:主表字段统一 select 为 "admin.xxx as xxx" $selectFields = []; foreach ($fields as $f) { $f = trim((string) $f); - if ($f === '') continue; + if ($f === '') { + continue; + } $selectFields[] = $mainAlias . $f . ' as ' . $f; } - // 联表时避免排序字段歧义:无前缀的字段默认加主表前缀 $qualifiedOrder = []; if (is_array($order)) { foreach ($order as $k => $v) { @@ -121,6 +184,14 @@ class Admin extends Backend ->alias($alias) ->where($where); + $visibleIds = AdminCommissionDistributionService::getVisibleAdminIdsForOperator( + intval($this->auth->id), + $this->auth->isSuperAdmin() + ); + if ($visibleIds !== []) { + $query->where($mainAlias . 'id', 'in', $visibleIds); + } + $topGroup = $this->request ? ($this->request->get('top_group') ?? $this->request->post('top_group')) : null; if ($topGroup === '1' || $topGroup === 1 || $topGroup === true) { $query = $query @@ -143,7 +214,9 @@ class Admin extends Backend public function add(Request $request): Response { $response = $this->initializeBackend($request); - if ($response !== null) return $response; + if ($response !== null) { + return $response; + } if ($request->method() === 'POST') { $data = $request->post(); @@ -179,6 +252,7 @@ class Admin extends Backend $data = $this->excludeFields($data); $creatorChannelId = $this->getCreatorChannelId(); $groupChannelId = $this->resolveChannelIdFromPrimaryGroup($data['group_arr'] ?? []); + if (!$this->auth->isSuperAdmin()) { if ($creatorChannelId === null || $creatorChannelId === '') { return $this->error(__('You have no permission')); @@ -192,13 +266,24 @@ class Admin extends Backend $data['channel_id'] = $creatorChannelId; $data['parent_admin_id'] = $this->auth->id; } else { - $data['channel_id'] = ($groupChannelId === null || $groupChannelId === '') ? null : $groupChannelId; + $postedChannel = $data['channel_id'] ?? null; + if ($postedChannel === null || $postedChannel === '') { + $data['channel_id'] = ($groupChannelId === null || $groupChannelId === '') ? null : $groupChannelId; + } } + + $parentErr = $this->normalizeParentAndShareFields($data, null); + if ($parentErr !== null) { + return $this->error($parentErr); + } + $data['invite_code'] = $this->generateUniqueInviteCode(); $result = false; if (!empty($data['group_arr'])) { $authRes = $this->checkGroupAuth($data['group_arr']); - if ($authRes !== null) return $authRes; + if ($authRes !== null) { + return $authRes; + } } $this->model->startTrans(); try { @@ -234,7 +319,9 @@ class Admin extends Backend public function edit(Request $request): Response { $response = $this->initializeBackend($request); - if ($response !== null) return $response; + if ($response !== null) { + return $response; + } $pk = $this->model->getPk(); $id = $request->get($pk) ?? $request->post($pk); @@ -243,8 +330,7 @@ class Admin extends Backend return $this->error(__('Record not found')); } - $dataLimitAdminIds = $this->getDataLimitAdminIds(); - if ($dataLimitAdminIds && !in_array($row[$this->dataLimitField], $dataLimitAdminIds)) { + if (!$this->canManageAdminId(intval($row['id']))) { return $this->error(__('You have no permission')); } @@ -255,7 +341,7 @@ class Admin extends Backend } $isSelfEdit = (int) $this->auth->id === (int) $id; if ($isSelfEdit) { - unset($data['group_arr'], $data['group_name_arr']); + unset($data['group_arr'], $data['group_name_arr'], $data['parent_admin_id'], $data['commission_share_rate']); } $editGroupArr = null; @@ -313,7 +399,9 @@ class Admin extends Backend ]; } $authRes = $this->checkGroupAuth($checkGroups); - if ($authRes !== null) return $authRes; + if ($authRes !== null) { + return $authRes; + } } $data = $this->excludeFields($data); @@ -336,6 +424,17 @@ class Admin extends Backend $data['channel_id'] = ($groupChannelId === null || $groupChannelId === '') ? null : $groupChannelId; } } + + if (!$isSelfEdit) { + if (!$this->auth->isSuperAdmin()) { + unset($data['parent_admin_id']); + } + $parentErr = $this->normalizeParentAndShareFields($data, intval($id)); + if ($parentErr !== null) { + return $this->error($parentErr); + } + } + $result = false; $this->model->startTrans(); try { @@ -361,37 +460,54 @@ class Admin extends Backend unset($row['salt'], $row['login_failure']); $row['password'] = ''; + $rowData = $row->toArray(); + $enriched = $this->enrichAdminRows([$rowData]); + $rowData = $enriched[0] ?? $rowData; + $parentId = intval($rowData['parent_admin_id'] ?? 0); + if ($parentId > 0) { + $remainder = AdminCommissionDistributionService::getShareRemainder($parentId, intval($id)); + $rowData['share_remainder'] = $remainder; + } + return $this->success('', [ - 'row' => $row + 'row' => $rowData, ]); } public function del(Request $request): Response { $response = $this->initializeBackend($request); - if ($response !== null) return $response; - - $where = []; - $dataLimitAdminIds = $this->getDataLimitAdminIds(); - if ($dataLimitAdminIds) { - $where[] = [$this->dataLimitField, 'in', $dataLimitAdminIds]; + if ($response !== null) { + return $response; } $ids = $request->get('ids') ?? $request->post('ids') ?? []; $ids = is_array($ids) ? $ids : []; - $where[] = [$this->model->getPk(), 'in', $ids]; - $data = $this->model->where($where)->select(); + if ($ids === []) { + return $this->error(__('Parameter error')); + } + + $data = $this->model->where($this->model->getPk(), 'in', $ids)->select(); $count = 0; $this->model->startTrans(); try { foreach ($data as $v) { - if ($v->id != $this->auth->id) { - $count += $v->delete(); - Db::name('admin_group_access') - ->where('uid', $v['id']) - ->delete(); + if ($v->id == $this->auth->id) { + continue; } + if (!$this->canManageAdminId(intval($v->id))) { + continue; + } + $childCount = Db::name('admin')->where('parent_admin_id', $v->id)->count(); + if ($childCount > 0) { + $this->model->rollback(); + return $this->error(__('Cannot delete administrator with sub-agents')); + } + $count += $v->delete(); + Db::name('admin_group_access') + ->where('uid', $v['id']) + ->delete(); } $this->model->commit(); } catch (Throwable $e) { @@ -404,9 +520,6 @@ class Admin extends Backend return $this->error(__('No rows were deleted')); } - /** - * 远程下拉(Admin 无自定义,走父类默认列表) - */ public function select(Request $request): Response { return parent::select($request); @@ -465,6 +578,141 @@ class Admin extends Backend return Db::name('admin_group')->where('id', $gid)->value('channel_id'); } + private function canManageAdminId(int $adminId): bool + { + if ($this->auth->isSuperAdmin()) { + return true; + } + if ($adminId === intval($this->auth->id)) { + return true; + } + $visible = AdminCommissionDistributionService::getVisibleAdminIdsForOperator( + intval($this->auth->id), + false + ); + return in_array($adminId, $visible, true); + } + + /** + * @param array $data + */ + private function normalizeParentAndShareFields(array &$data, ?int $editAdminId): ?string + { + $parentId = isset($data['parent_admin_id']) && $data['parent_admin_id'] !== '' && $data['parent_admin_id'] !== null + ? intval($data['parent_admin_id']) + : 0; + if ($parentId <= 0 && $editAdminId !== null && $editAdminId > 0) { + $existingParent = Db::name('admin')->where('id', $editAdminId)->value('parent_admin_id'); + $parentId = intval($existingParent ?? 0); + } + if ($parentId <= 0) { + $data['parent_admin_id'] = null; + $data['commission_share_rate'] = null; + return null; + } + + if ($editAdminId !== null && $parentId === $editAdminId) { + return (string) __('Cannot set yourself as parent administrator'); + } + + $parent = Db::name('admin')->where('id', $parentId)->find(); + if (!is_array($parent)) { + return (string) __('Invalid parent administrator'); + } + if (!$this->canManageAdminId($parentId) && !$this->auth->isSuperAdmin()) { + return (string) __('You have no permission'); + } + + $channelId = $data['channel_id'] ?? null; + if ($channelId !== null && $channelId !== '' && (string) ($parent['channel_id'] ?? '') !== (string) $channelId) { + return (string) __('Parent administrator must belong to the same channel'); + } + if (($channelId === null || $channelId === '') && !empty($parent['channel_id'])) { + $data['channel_id'] = $parent['channel_id']; + } + + $shareErr = AdminCommissionDistributionService::validateCommissionShareRate( + $parentId, + $data['commission_share_rate'] ?? null, + $editAdminId + ); + if ($shareErr !== null) { + return $shareErr; + } + $data['commission_share_rate'] = bcadd(strval($data['commission_share_rate'] ?? '0'), '0', 2); + $data['parent_admin_id'] = $parentId; + + return null; + } + + /** + * @param array> $rows + * @return array> + */ + private function enrichAdminRows(array $rows): array + { + if ($rows === []) { + return []; + } + $adminIds = []; + $channelIds = []; + $parentIds = []; + foreach ($rows as $row) { + $aid = intval($row['id'] ?? 0); + if ($aid > 0) { + $adminIds[] = $aid; + } + $cid = $row['channel_id'] ?? null; + if ($cid !== null && $cid !== '') { + $channelIds[] = intval($cid); + } + $pid = intval($row['parent_admin_id'] ?? 0); + if ($pid > 0) { + $parentIds[] = $pid; + } + } + $channelNames = $channelIds !== [] + ? Db::name('channel')->where('id', 'in', array_unique($channelIds))->column('name', 'id') + : []; + $parentNames = $parentIds !== [] + ? Db::name('admin')->where('id', 'in', array_unique($parentIds))->column('username', 'id') + : []; + + $groupMap = []; + if ($adminIds !== []) { + $accessRows = Db::name('admin_group_access')->where('uid', 'in', $adminIds)->select()->toArray(); + $groupIds = array_unique(array_map(static fn(array $r): int => intval($r['group_id'] ?? 0), $accessRows)); + $groupNames = $groupIds !== [] + ? Db::name('admin_group')->where('id', 'in', $groupIds)->column('name', 'id') + : []; + foreach ($accessRows as $access) { + $uid = intval($access['uid'] ?? 0); + $gid = intval($access['group_id'] ?? 0); + if ($uid <= 0 || $gid <= 0) { + continue; + } + if (!isset($groupMap[$uid])) { + $groupMap[$uid] = []; + } + $name = $groupNames[$gid] ?? ''; + if ($name !== '') { + $groupMap[$uid][] = $name; + } + } + } + + foreach ($rows as $k => $row) { + $cid = $row['channel_id'] ?? null; + $rows[$k]['channel_name'] = ($cid !== null && $cid !== '') ? strval($channelNames[intval($cid)] ?? '') : ''; + $pid = intval($row['parent_admin_id'] ?? 0); + $rows[$k]['parent_admin_username'] = $pid > 0 ? strval($parentNames[$pid] ?? '') : ''; + $aid = intval($row['id'] ?? 0); + $rows[$k]['group_name_arr'] = $groupMap[$aid] ?? []; + } + + return $rows; + } + private function generateUniqueInviteCode(): string { $tries = 0; diff --git a/app/common/lang/en/service.php b/app/common/lang/en/service.php index 67ba33c..014e8b3 100644 --- a/app/common/lang/en/service.php +++ b/app/common/lang/en/service.php @@ -6,6 +6,16 @@ return [ 'Channel not found' => 'Channel not found', 'Settlement number conflict, please retry' => 'Settlement number conflict, please retry', 'No available admin share ratios under this channel; cannot settle' => 'No available admin share ratios under this channel; cannot settle', + 'No channel root agent configured for commission distribution' => 'No top-level agent is configured for this channel; cannot settle with tree commission', + '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%', + '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', + 'Cannot set yourself as parent administrator' => 'Cannot set yourself as parent administrator', + 'Top-level agent does not need a commission share rate' => 'Top-level agent does not need a commission share rate', + 'Cannot delete administrator with sub-agents' => 'Cannot delete an administrator who has sub-agents', 'This flow pays commissions automatically after super admin settlement; channel admin does not need to settle again' => 'This flow pays commissions automatically after super admin settlement; channel admin does not need to settle again', 'Invalid channel data' => 'Invalid channel data', 'Invalid settlement period (start time is not earlier than now)' => 'Invalid settlement period (start time is not earlier than now)', diff --git a/app/common/lang/zh-cn/service.php b/app/common/lang/zh-cn/service.php index 78f1048..6359b29 100644 --- a/app/common/lang/zh-cn/service.php +++ b/app/common/lang/zh-cn/service.php @@ -6,6 +6,16 @@ return [ 'Channel not found' => '渠道不存在', 'Settlement number conflict, please retry' => '结算单号冲突,请重试', 'No available admin share ratios under this channel; cannot settle' => '渠道下无可用管理员分配比例,无法结算', + 'No channel root agent configured for commission distribution' => '渠道未配置顶级代理管理员,无法按树形分红结算', + '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%', + 'Parent administrator is required for sub-agent' => '子代理须绑定上级管理员', + 'Invalid parent administrator' => '上级管理员无效', + 'Parent administrator must belong to the same channel' => '上级管理员须与当前渠道一致', + 'Cannot set yourself as parent administrator' => '不能将自己设为上级管理员', + 'Top-level agent does not need a commission share rate' => '顶级代理无需填写分红比例', + 'Cannot delete administrator with sub-agents' => '存在下级子代理的管理员不可删除', 'This flow pays commissions automatically after super admin settlement; channel admin does not need to settle again' => '当前流程为超管结算后自动发放,渠道管理员无需二次结算', 'Invalid channel data' => '渠道数据异常', 'Invalid settlement period (start time is not earlier than now)' => '结算区间无效(开始时间不早于当前)', diff --git a/app/common/service/AdminCommissionDistributionService.php b/app/common/service/AdminCommissionDistributionService.php new file mode 100644 index 0000000..4c82312 --- /dev/null +++ b/app/common/service/AdminCommissionDistributionService.php @@ -0,0 +1,238 @@ +where('parent_admin_id', $adminId) + ->where('status', 'enable') + ->column('id'); + $all = []; + foreach ($childIds as $cid) { + $cid = intval($cid); + if ($cid <= 0) { + continue; + } + $all[] = $cid; + foreach (self::getDescendantAdminIds($cid) as $descId) { + $all[] = $descId; + } + } + return $all; + } + + /** + * 非超管可见管理员 ID;超管返回空数组表示不限制 + * + * @return int[] + */ + public static function getVisibleAdminIdsForOperator(int $operatorAdminId, bool $isSuperAdmin): array + { + if ($isSuperAdmin || $operatorAdminId <= 0) { + return []; + } + $ids = self::getDescendantAdminIds($operatorAdminId); + $ids[] = $operatorAdminId; + return array_values(array_unique($ids)); + } + + /** + * @return array{used_rate:string,remaining_rate:string} + */ + public static function getShareRemainder(int $parentAdminId, ?int $excludeAdminId = null): array + { + $used = '0.00'; + if ($parentAdminId <= 0) { + return ['used_rate' => $used, 'remaining_rate' => '100.00']; + } + $query = Db::name('admin') + ->where('parent_admin_id', $parentAdminId) + ->where('status', 'enable'); + 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 validateCommissionShareRate(?int $parentAdminId, mixed $rateRaw, ?int $excludeAdminId = null): ?string + { + if ($parentAdminId === null || $parentAdminId <= 0) { + return null; + } + if ($rateRaw === null || $rateRaw === '') { + return (string) __('Sub-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::getShareRemainder($parentAdminId, $excludeAdminId); + if (bccomp(bcadd($remainder['used_rate'], $rate, 2), '100.00', 2) > 0) { + return (string) __('Sum of sibling commission share rates cannot exceed 100%'); + } + return null; + } + + /** + * 将渠道本期总佣金按管理员树分配,返回各管理员实得金额 + * + * @return array + */ + public static function distributeChannelCommission(int $channelId, string $totalCommission, string $calcBaseAmount): array + { + if ($channelId <= 0 || bccomp($totalCommission, '0', 2) <= 0) { + return []; + } + $roots = 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 === []) { + return []; + } + $rootCount = count($roots); + $perRoot = bcdiv($totalCommission, strval($rootCount), 2); + $assigned = '0.00'; + $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'; + } + $merged[$adminId] = bcadd($merged[$adminId], $amount, 2); + } + } + $out = []; + foreach ($merged as $adminId => $amount) { + if (bccomp($amount, '0', 2) <= 0) { + continue; + } + $effectiveRate = bccomp($calcBaseAmount, '0', 2) <= 0 + ? '0.0000' + : bcdiv($amount, $calcBaseAmount, 6); + $out[] = [ + 'admin_id' => intval($adminId), + 'commission_amount' => $amount, + 'commission_rate' => $effectiveRate, + 'calc_base_amount' => $calcBaseAmount, + ]; + } + return $out; + } + + /** + * @return array admin_id => amount + */ + private static function distributeFromAdmin(int $adminId, string $amount, string $calcBaseAmount): array + { + if ($adminId <= 0 || bccomp($amount, '0', 2) <= 0) { + return []; + } + $children = Db::name('admin') + ->where('parent_admin_id', $adminId) + ->where('status', 'enable') + ->order('id', 'asc') + ->field(['id', 'commission_share_rate']) + ->select() + ->toArray(); + $givenToChildren = '0.00'; + $result = []; + foreach ($children as $child) { + $childId = intval($child['id'] ?? 0); + if ($childId <= 0) { + continue; + } + $rate = bcadd(strval($child['commission_share_rate'] ?? '0'), '0', 2); + if (bccomp($rate, '0', 2) <= 0) { + continue; + } + $childAmount = bcmul($amount, bcdiv($rate, '100', 4), 2); + if (bccomp($childAmount, '0', 2) <= 0) { + continue; + } + $givenToChildren = bcadd($givenToChildren, $childAmount, 2); + $childParts = self::distributeFromAdmin($childId, $childAmount, $calcBaseAmount); + foreach ($childParts as $aid => $part) { + if (!isset($result[$aid])) { + $result[$aid] = '0.00'; + } + $result[$aid] = bcadd($result[$aid], $part, 2); + } + } + $selfKeep = bcsub($amount, $givenToChildren, 2); + if (bccomp($selfKeep, '0', 2) > 0) { + if (!isset($result[$adminId])) { + $result[$adminId] = '0.00'; + } + $result[$adminId] = bcadd($result[$adminId], $selfKeep, 2); + } + return $result; + } + + /** + * @return array + */ + public static function buildSplitPreview(int $channelId, string $commissionTotal, string $calcBaseAmount): array + { + $rows = self::distributeChannelCommission($channelId, $commissionTotal, $calcBaseAmount); + if ($rows === []) { + return []; + } + $adminIds = array_map(static fn(array $r): int => intval($r['admin_id']), $rows); + $adminNames = Db::name('admin')->where('id', 'in', $adminIds)->column('username', 'id'); + $parentMap = Db::name('admin')->where('id', 'in', $adminIds)->column('parent_admin_id', 'id'); + $shareRates = Db::name('admin')->where('id', 'in', $adminIds)->column('commission_share_rate', 'id'); + $out = []; + foreach ($rows as $row) { + $aid = intval($row['admin_id']); + $parentId = intval($parentMap[$aid] ?? 0); + $shareRate = $parentId > 0 ? bcadd(strval($shareRates[$aid] ?? '0'), '0', 2) : '100.00'; + $out[] = [ + 'admin_id' => $aid, + 'admin_username' => strval($adminNames[$aid] ?? ('#' . $aid)), + 'share_rate' => $shareRate, + 'commission_amount' => strval($row['commission_amount']), + ]; + } + return $out; + } +} diff --git a/app/common/service/ChannelSettlementService.php b/app/common/service/ChannelSettlementService.php index 1449f63..b47e0d7 100644 --- a/app/common/service/ChannelSettlementService.php +++ b/app/common/service/ChannelSettlementService.php @@ -23,9 +23,13 @@ class ChannelSettlementService if (Db::name('agent_settlement_period')->where('settlement_no', $settlementNo)->value('id')) { return ['ok' => false, 'msg' => __('Settlement number conflict, please retry')]; } - $shareRows = self::resolveCommissionSharesForChannel($channelId); - if ($shareRows === []) { - return ['ok' => false, 'msg' => __('No available admin share ratios under this channel; cannot settle')]; + $distributions = AdminCommissionDistributionService::distributeChannelCommission( + $channelId, + strval($payload['commission_amount']), + strval($payload['calc_base_amount']) + ); + if ($distributions === []) { + return ['ok' => false, 'msg' => __('No channel root agent configured for commission distribution')]; } $now = time(); Db::startTrans(); @@ -42,12 +46,10 @@ class ChannelSettlementService 'create_time' => $now, 'update_time' => $now, ])); - $rows = self::buildCommissionRowsForSplit( - $shareRows, + $rows = self::buildCommissionRowsFromDistribution( + $distributions, $channelId, $periodId, - strval($payload['calc_base_amount']), - strval($payload['commission_amount']), $remark !== '' ? $remark : '渠道待分红记录', $now ); @@ -189,7 +191,11 @@ class ChannelSettlementService 'calc_base_amount' => $commission['calc_base_amount'], 'commission_amount' => $commission['commission_amount'], 'agent_mode' => $mode, - 'commission_split' => self::buildCommissionSplitPreview(self::resolveCommissionSharesForChannel($channelId), $commission['commission_amount']), + 'commission_split' => AdminCommissionDistributionService::buildSplitPreview( + $channelId, + $commission['commission_amount'], + $commission['calc_base_amount'] + ), ]; } @@ -325,93 +331,33 @@ class ChannelSettlementService return $base . strtoupper(substr(bin2hex(random_bytes(4)), 0, 2)); } - private static function resolveCommissionSharesForChannel(int $channelId): array + /** + * @param array $distributions + */ + private static function buildCommissionRowsFromDistribution(array $distributions, int $channelId, int $periodId, string $remark, int $now): array { - $rows = Db::name('channel_admin_share')->alias('cas') - ->join('admin a', 'cas.admin_id = a.id') - ->field(['cas.admin_id', 'cas.share_rate']) - ->where('cas.channel_id', $channelId) - ->where('cas.status', 1) - ->where('a.status', 'enable') - ->order('cas.admin_id', 'asc') - ->select() - ->toArray(); - if ($rows === []) { - return []; - } - $sum = '0.00'; - $out = []; - foreach ($rows as $row) { - $adminId = intval($row['admin_id'] ?? 0); - $shareRate = bcadd(strval($row['share_rate'] ?? '0'), '0', 2); - if ($adminId <= 0 || bccomp($shareRate, '0', 2) <= 0) { + $rows = []; + foreach ($distributions as $dist) { + $adminId = intval($dist['admin_id'] ?? 0); + if ($adminId <= 0) { continue; } - $sum = bcadd($sum, $shareRate, 2); - $out[] = ['admin_id' => $adminId, 'share_rate' => $shareRate]; - } - if ($out === [] || bccomp($sum, '100.00', 2) !== 0) { - return []; - } - return $out; - } - - private static function buildCommissionRowsForSplit(array $shareRows, int $channelId, int $periodId, string $calcBaseAmount, string $commissionTotal, string $remark, int $now): array - { - $sum = '0.00'; - $rows = []; - $lastIndex = count($shareRows) - 1; - foreach ($shareRows as $index => $shareRow) { - $shareRate = bcadd(strval($shareRow['share_rate'] ?? '0.00'), '0', 2); - $shareDec = bcdiv($shareRate, '100', 4); - $amount = $index === $lastIndex ? bcsub($commissionTotal, $sum, 2) : bcmul($commissionTotal, $shareDec, 2); - if ($index !== $lastIndex) { - $sum = bcadd($sum, $amount, 2); - } - $effectiveRate = bccomp($calcBaseAmount, '0', 2) <= 0 ? '0.0000' : bcdiv($amount, $calcBaseAmount, 6); + $amount = strval($dist['commission_amount'] ?? '0.00'); $rows[] = [ 'settlement_period_id' => $periodId, 'channel_id' => $channelId, - 'admin_id' => intval($shareRow['admin_id'] ?? 0), - 'commission_rate' => $effectiveRate, - 'calc_base_amount' => $calcBaseAmount, + 'admin_id' => $adminId, + 'commission_rate' => strval($dist['commission_rate'] ?? '0.0000'), + 'calc_base_amount' => strval($dist['calc_base_amount'] ?? '0.00'), 'commission_amount' => $amount, 'status' => 0, 'settled_at' => null, - 'remark' => $remark . ' | 分配比例=' . $shareRate . '%', + 'remark' => $remark . ' | 树形分红实发', 'create_time' => $now, 'update_time' => $now, ]; } return $rows; } - - private static function buildCommissionSplitPreview(array $shareRows, string $commissionTotal): array - { - if ($shareRows === []) { - return []; - } - $adminIds = array_map(static fn(array $row): int => intval($row['admin_id'] ?? 0), $shareRows); - $adminNames = Db::name('admin')->where('id', 'in', $adminIds)->column('username', 'id'); - $sum = '0.00'; - $out = []; - $lastIndex = count($shareRows) - 1; - foreach ($shareRows as $index => $shareRow) { - $shareRate = bcadd(strval($shareRow['share_rate'] ?? '0.00'), '0', 2); - $shareDec = bcdiv($shareRate, '100', 4); - $amount = $index === $lastIndex ? bcsub($commissionTotal, $sum, 2) : bcmul($commissionTotal, $shareDec, 2); - if ($index !== $lastIndex) { - $sum = bcadd($sum, $amount, 2); - } - $aid = intval($shareRow['admin_id'] ?? 0); - $out[] = [ - 'admin_id' => $aid, - 'admin_username' => strval($adminNames[$aid] ?? ('#' . $aid)), - 'share_rate' => $shareRate, - 'commission_amount' => $amount, - ]; - } - return $out; - } } diff --git a/docs/36字花-数据库与实施计划.md b/docs/36字花-数据库与实施计划.md index c9c2555..f48af92 100644 --- a/docs/36字花-数据库与实施计划.md +++ b/docs/36字花-数据库与实施计划.md @@ -169,14 +169,19 @@ | 渠道归属 | `channel_id` 表示该子代理属于哪个顶级渠道 | | 邀请管理 | `invite_code` 自动生成且全局唯一,用于发展玩家并做归属绑定 | | 角色标识 | `agent_role`(如 `agent_admin` / `sub_agent` / `staff`) | -| 分红设置 | 不再使用 `admin` / `admin_group` 分红字段;统一改为渠道维度结算后按 `channel_admin_share` 二次分配 | +| 分红设置 | 在 **管理员管理** 维护:`parent_admin_id`(上级代理)、`commission_share_rate`(从上级分红抽取比例 %);顶级代理留空上级与比例 | | 开奖权限 | 不在数据层开放给渠道/子代理;开奖权限仅由超管 RBAC 控制 | -### 5.1 分红计算口径(现行) +### 5.1 分红计算口径(现行,2026-05-29) -- 渠道分红:先按 `channel.agent_mode` 计算渠道总佣金 -- 管理员分红:再按 `channel_admin_share.share_rate` 对渠道总佣金进行二次分配 -- 角色组仅用于权限与数据范围,不再参与金额拆分 +- **渠道分红**:先按 `channel.agent_mode` 与已结算注单(`bet_order.status=2`)计算 **渠道总佣金**(非充值口径) +- **管理员分红**:总佣金进入渠道 **顶级代理** 后,按 `admin.parent_admin_id` 树递归拆分: + - 子代理实得 = 上级本期实得 × `commission_share_rate`(%) + - 上级保留 = 上级实得 − 所有直属子代理实得之和 + - 同一上级下子代理比例合计 **≤ 100%** +- **配置入口**:`/admin/auth/admin`(树形列表;非超管仅见本人及下级) +- **角色组**仅用于权限与数据范围,不参与金额拆分 +- **历史**:`channel_admin_share` 已退出结算主流程,渠道页不再维护分配比例 --- @@ -308,12 +313,13 @@ 3. **大盘盈利**:流水占比分桶 + 级差,各级金额之和与线总包一致(可对照文档数值验算)。 4. **联营**:客损为负产生负结转;下期盈利先抵扣再分佣。 -### 8.3.1 渠道结算新流程(2026-04-23) +### 8.3.1 渠道结算新流程(2026-04-23,2026-05-29 树形拆分) 1. **仅超管可结算**:按渠道结算周期(支持自动任务与手动提前结算)执行渠道结算。 -2. **结算即发放**:结算时按 `channel_admin_share` 比例,直接发放到管理员钱包并写入 `admin_wallet_record`(`commission_income`),同时写 `agent_settlement_period` / `agent_commission_record`。 -3. **提前结算规则**:手动提前结算后,新的周期起点从本次结算结束时间开始,后续自动周期归入下个结算段。 -4. **停用渠道限制**:`channel.status != 1` 时,该渠道不再允许玩家注册与登录。 +2. **结算即发放**:结算时按 **代理树**(`admin.parent_admin_id` + `admin.commission_share_rate`)拆分各管理员实得,直接发放到管理员钱包并写入 `admin_wallet_record`(`commission_income`),同时写 `agent_settlement_period` / `agent_commission_record`。 +3. **配置位置**:分红比例在 **管理员管理** 维护,不在渠道管理页维护 flat 分配表。 +4. **提前结算规则**:手动提前结算后,新的周期起点从本次结算结束时间开始,后续自动周期归入下个结算段。 +5. **停用渠道限制**:`channel.status != 1` 时,该渠道不再允许玩家注册与登录。 ### 8.3.2 后台管理员资金与提现流程(2026-04-23) @@ -398,6 +404,7 @@ | V1.14 | 2026-04-20 | 服务端 Redis 热点缓存:`GameHotDataRedis`(`user` / `game_config` / `game_record`),`config/game_hot_cache.php` 与 `.env-example` 中 `GAME_HOT_CACHE_*`;更新 §1.1、§2.1、§8.4、第九章风险与依赖、附录 `user`;与 `CACHE_DRIVER`(系统 `config` 表文件缓存)区分说明 | | 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` 入口;管理员列表树形展示与下级可见范围 | --- @@ -440,10 +447,11 @@ |---------|------| | `admin.parent_admin_id` | 子代理上下级 | | `admin.channel_id` | 所属渠道 | +| `admin.commission_share_rate` | 从上级本期分红抽取比例(%);顶级代理为空 | | `admin.invite_code` | 子代理邀请码,注册归属 | | `admin.agent_role` | 角色类型(均无开奖权) | | `admin_group.channel_id` | 角色组归属渠道;`NULL` 可为系统级(仅超管) | -| `channel_admin_share.share_rate` | 渠道内管理员分配比例(百分比,启用项合计=100) | +| `channel_admin_share` | **历史表**,2026-05-29 起结算不再使用;配置改在 `admin` 树 | ### 11.4 `game_config`(动态参数) diff --git a/docs/36字花-移动端接口设计草案.md b/docs/36字花-移动端接口设计草案.md index 2d08996..dd1bd02 100644 --- a/docs/36字花-移动端接口设计草案.md +++ b/docs/36字花-移动端接口设计草案.md @@ -962,53 +962,42 @@ flowchart TD --- -## 10. 后台渠道分红比例配置(管理端补充) +## 10. 后台代理分红配置(管理端补充,2026-05-29) -> 本节为管理后台 `/admin/channel`「分配比例」弹窗补充口径,用于便于管理员按角色层级设置二次分红比例。 +> 分红比例在 **管理员管理** `/admin/auth/admin` 维护,不再使用渠道页「分配比例」弹窗。 -### 10.1 角色组展示规则 +### 10.1 页面与展示 -- 表格列顺序调整为:`角色组层级` -> `负责人` -> `状态` -> `分配比例(%)` -- `角色组层级` 在 `负责人` 前展示,降低识别与分配成本 -- 层级路径使用 `/` 拼接,如:`顶级组 / 运营组 / 一组` -- 同一负责人若存在多个角色组,按多标签展示多条路径 -- 无角色组时显示 `-` +- 列表为 **树形表格**(按 `parent_admin_id`),参考角色组管理样式;支持展开/收起 +- 列含:用户名、昵称、**渠道**、**上级代理**、**分红比例(%)**、角色组、邀请码、状态等 +- 超管公共搜索支持 **渠道下拉筛选** +- 非超管仅见 **本人及全部下级** 代理,不见其他代理线下的子代理 -### 10.2 接口:读取渠道管理员分配配置 +### 10.2 表单字段 -- **GET** `/admin/channel/channelAdminShareList?id={channel_id}` +| 字段 | 说明 | +|------|------| +| `channel_id` | 所属渠道(超管可选;非超管随角色组/当前账号) | +| `parent_admin_id` | 上级代理;留空表示渠道 **顶级代理** | +| `commission_share_rate` | 从上级本期分红抽取的比例(0–100);有上级时必填 | +| `group_arr` | 角色组(单选,仅权限) | -返回参数(`data.list[]`)新增: -- `group_paths`:array(负责人所属角色组层级路径列表) -- `group_paths_text`:string(层级路径拼接文本,`|` 分隔,用于兼容纯文本场景) +### 10.3 校验与提示 -返回示例(节选): -```json -{ - "code": 1, - "message": "ok", - "data": { - "channel_id": 1, - "channel_name": "渠道A", - "list": [ - { - "admin_id": 12, - "username": "zhuguan1", - "group_paths": ["顶级组 / 运营组 / A组"], - "group_paths_text": "顶级组 / 运营组 / A组", - "status": 1, - "share_rate": "30.00" - } - ] - } -} -``` +- 同一 `parent_admin_id` 下,启用子代理的 `commission_share_rate` **合计 ≤ 100%** +- 表单调用 **GET** `/admin/auth.Admin/commissionShareRemainder?parent_admin_id=&exclude_id=` 展示剩余可分配比例 +- 合计 100% 时提示:上级在本层将无分红留存 -### 10.3 保存约束(沿用现有) +### 10.4 结算拆分(与渠道结算联动) -- **POST** `/admin/channel/saveChannelAdminShare` -- 仅 `status=1` 的行参与占比汇总 -- 启用项分配比例总和必须严格等于 `100.00` +- 渠道结算得到总佣金后,由 `AdminCommissionDistributionService` 从顶级代理起递归拆分 +- 每个管理员实得写入 `agent_commission_record` 并 **即时入账** `admin_wallet` +- 须存在至少一名渠道顶级代理,否则结算失败 + +### 10.5 历史接口(已废弃于 UI,勿新接) + +- ~~GET `/admin/channel/channelAdminShareList`~~ +- ~~POST `/admin/channel/saveChannelAdminShare`~~ --- diff --git a/docs/业务流程.md b/docs/业务流程.md index 46a72e6..3482064 100644 --- a/docs/业务流程.md +++ b/docs/业务流程.md @@ -70,6 +70,8 @@ 2. **总代拓客**:总代登录 Agent Portal,生成自己的推广链接发给散户;或者生成【子代专属邀请码】发展下级代理。 3. **分配比例**:总代在给子代开户时,将自己手中 80% 的分红权,划拨一部分(如 70%)给子代,自己赚取 10% 的级差。 +> **系统实现(2026-05-29)**:在后台 **管理员管理**(`/admin/auth/admin`)为子代理设置 **上级代理** 与 **分红比例(%)**;渠道结算后从上级实得中按该比例向子代理拆分,上级保留剩余部分。同一上级下子代理比例合计不得超过 100%。详见 `docs/分红说明文档.md`。 + ### 2.2 🌟 核心分佣结算流程(流水占比分桶模式) 系统设定为每周一凌晨(或每日)自动执行结算脚本: diff --git a/docs/分红说明文档.md b/docs/分红说明文档.md index b1aa73d..97fdf70 100644 --- a/docs/分红说明文档.md +++ b/docs/分红说明文档.md @@ -1,182 +1,126 @@ -# 分红说明文档(现状分析 + 简化设计建议) +# 分红说明文档 ## 1. 文档目的 -本文用于回答两个问题: - -1. 甲方管理员是否容易看懂当前分红方式? -2. 是否有更简单、更容易执行和对账的分红设计? - -并给出推荐方案与落地路径。 +说明当前系统中 **渠道分红 → 代理树形拆分 → 管理员钱包入账** 的完整口径,便于运营、财务与开发对齐配置方式与结算行为。 --- -## 2. 当前分红设计现状(按代码实际行为) +## 2. 总体结构(两层 + 树形) -## 2.1 配置层面(看起来是三级) +分红分为两个明确步骤: -历史上后台有三处比例字段(其中后两项已下线): +### A. 一级:渠道结算(平台 → 渠道) -- `channel`:渠道分红参数(`turnover_share_rate`、`affiliate_*` 等) -- `admin_group`:角色组 `commission_rate`(已删除) -- `admin`:管理员 `commission_rate`(已删除) +- 按渠道配置的 `agent_mode` 计算本期 **渠道总佣金**: + - **turnover(流水/返水)**:基数 = 已结算注单总投注,佣金 = 总投注 × `turnover_share_rate` + - **affiliate(联营)**:基数 = 平台盈亏扣成本后金额,佣金 = 基数 × 阶梯占成 +- 统计范围:`bet_order.status = 2`(已结算),周期为 **上次结算结束时间 ~ 本次结算时刻** +- 产出:`agent_settlement_period`(结算周期快照) -从“配置界面”角度,容易被理解为: -**渠道比例 -> 角色组比例 -> 管理员比例** 的三级链路。 +### B. 二级:代理树拆分(渠道总佣金 → 各管理员实得) -## 2.2 结算层面(实际执行是单层) +- **不再**在渠道管理页维护「渠道内管理员分配比例」(`channel_admin_share` 已废弃于 UI,历史表可保留) +- 在 **管理员管理**(`/admin/auth/admin`)维护代理树: + - `parent_admin_id`:上级代理(顶级留空) + - `commission_share_rate`:从 **上级本期分红** 中抽取的比例(%),仅子代理需要填写 +- 结算时由 `AdminCommissionDistributionService` 递归拆分: + 1. 渠道总佣金先进入该渠道 **顶级代理**(`parent_admin_id` 为空) + 2. 每个代理按直属子代理的 `commission_share_rate`,从 **自己拿到的金额** 中划出子代理份额 + 3. **上级保留** = 自己拿到的金额 − 已分给所有子代理的金额 -当前手动结算逻辑在 `Channel` 控制器中: +**示例**(上级本期拿到 3000): -- 按渠道下注汇总计算 `total_bet_amount / platform_profit_amount` -- 根据 `channel.agent_mode` 计算佣金基数与佣金比例 -- 生成 `agent_settlement_period` 与 `agent_commission_record` +| 子代理 | 配置比例 | 实得 | +|--------|----------|------| +| 子代理 A | 20% | 600 | +| 子代理 B | 40% | 1200 | +| 上级自身 | — | 1200(3000 − 600 − 1200) | -关键点:**佣金记录只写了一条 admin_id(渠道下首个管理员)**,并未按角色组/管理员比例拆分多条佣金明细。 -也就是说,目前“角色组比例、管理员比例”主要在新增/编辑时做约束校验,但**没有进入最终结算分账公式**。 +若子代理还有下级,在其 **实得金额** 上继续按同样规则向下拆分。 --- -## 3. 当前方案对甲方可理解性的评估 +## 3. 配置入口与权限 -结论:**一般不容易看懂,且容易产生“配置了却不生效”的认知落差。** +| 能力 | 入口 | 说明 | +|------|------|------| +| 渠道分红参数 | `/admin/channel` | `agent_mode`、返水/联营比例、结算周期等 | +| 代理树与分红比例 | `/admin/auth/admin` | 树形列表;配置上级代理、分红比例、渠道归属 | +| 渠道筛选 | 管理员列表公共搜索 | 超管可按渠道筛选 | +| 数据可见范围 | 管理员列表 | 非超管仅见 **本人 + 全部下级**,不见其他代理线 | +| 结算执行 | `/admin/channel` 手动结算 / 定时任务 | **仅超管**可结算;结算即发放至 `admin_wallet` | -主要原因: +### 3.1 子代理分红比例校验 -1. **心智复杂**:甲方要同时理解渠道模式(返水/联营)、角色组比例、管理员比例三层规则。 -2. **口径不一致**:界面上有三级比例,但结算时是渠道单层落账,容易质疑“为什么不是按我配的三级比例发放”。 -3. **对账难**:财务看到佣金记录时只能看到渠道维度,不容易反推角色组和管理员分配关系。 -4. **运维负担高**:层级变化(调组、换管理员)后,历史解释成本高。 +- 同一 `parent_admin_id` 下,所有启用子代理的 `commission_share_rate` **合计不得超过 100%** +- 创建/编辑子代理时,表单会提示 **当前剩余可分配比例** +- 若合计为 100%,上级在本层 **不再保留分红** +- 顶级代理(无上级)**不需要**填写 `commission_share_rate` + +### 3.2 角色组的作用 + +- `admin_group` **只负责**菜单权限、数据范围、角色隔离 +- **不参与**金额运算 --- -## 4. 更简单的分红设计方式(推荐) +## 4. 结算执行流程 -## 4.1 推荐方案:`渠道结算 + 渠道内二次分配`(两层) +1. 超管触发渠道结算(手动或 `ChannelAutoSettleTicker` 周期任务) +2. `ChannelSettlementService::buildSettlePayload` 汇总注单并计算渠道总佣金 +3. `AdminCommissionDistributionService::distributeChannelCommission` 按代理树拆分各管理员实得 +4. 事务内写入: + - `agent_settlement_period`(`status = 2` 已完成) + - 多条 `agent_commission_record`(每个实得 > 0 的管理员一条,`status = 1` 已发放) + - `AdminWalletService::creditCommission` → `admin_wallet` + `admin_wallet_record`(`biz_type = commission_income`) +5. 渠道 `carryover_balance` 重置为 0(超管结算即发放,不再保留渠道待分红池) -将分红流程拆为两个明确步骤: - -### A. 一级:渠道结算(平台对渠道) - -- 保留当前 `channel.agent_mode` 的计算方式(turnover/affiliate) -- 先得到一个渠道应发佣金:`channel_commission_amount` -- 生成渠道结算单(当前已具备) - -### B. 二级:渠道内分配(渠道对管理员) - -- 不再使用“角色组分红比例”参与金额运算(角色组只负责权限) -- 仅维护“渠道下管理员分配权重”(例如总和=100%) -- 自动拆分:`admin_commission = channel_commission_amount × admin_weight` -- 为每个管理员生成独立佣金记录(多条) - -> 这样甲方只需要理解一句话: -> **平台先算渠道总佣金,再按渠道内管理员权重拆分。** - -## 4.2 为什么推荐该方案 - -1. **业务更直观**:甲方看“渠道总佣金 + 管理员占比”即可。 -2. **和现有代码贴近**:你们当前已经是“先算渠道佣金”,只需补“二次拆分”。 -3. **对账容易**:每个管理员佣金来源清晰,可直接汇总对账。 -4. **后续可扩展**:以后要按团队、按代理线拆分,可以在“二次分配”层升级,不影响一级结算。 +**前置条件**:渠道下须存在至少一名 **顶级代理**(`channel_id` 匹配且 `parent_admin_id` 为空);否则结算失败并提示「渠道未配置顶级代理管理员」。 --- -## 5. 备选方案对比 +## 5. 给甲方/运营的话术(推荐) -## 5.1 方案A(现状表象):渠道 -> 角色组 -> 管理员三级 - -- 优点:理论上精细 -- 缺点:配置理解成本高、维护复杂、容易和实际结算脱节 -- 适用:组织结构非常稳定、财务系统成熟的大盘 - -## 5.2 方案B(推荐):渠道 -> 管理员两级 - -- 优点:简单、易讲、易对账、改造成本低 -- 缺点:若强依赖“角色组抽成”会减少一层表达 -- 适用:当前阶段(快速上线、减少运营误解) - -## 5.3 方案C(最简):仅渠道一级 - -- 优点:最简单 -- 缺点:无法直接落地到管理员收益,不利于激励 -- 适用:仅用于统计,不用于发佣 +1. **渠道分红**:系统按渠道模式(返水或联营)自动计算该周期 **渠道总佣金**(基于玩家已结算注单,**不是**按充值金额)。 +2. **人员分配**:总佣金进入渠道顶级代理后,按 **管理员管理中配置的上下级与分红比例** 自动向下拆分;上级只保留分给下级后的余额。 +3. **角色组**:仅管权限,不管分钱。 +4. **结算可追溯**:每条 `agent_commission_record` 可关联 `agent_settlement_period`;钱包流水 `commission_income` 可反查佣金记录。 --- -## 6. 建议的产品口径(给甲方的话术) +## 6. 核心数据字段 -建议统一说明为: +| 表/字段 | 作用 | +|---------|------| +| `channel.agent_mode` / `turnover_share_rate` / `affiliate_*` | 渠道总佣金计算参数 | +| `admin.parent_admin_id` | 上级代理 | +| `admin.channel_id` | 所属渠道 | +| `admin.commission_share_rate` | 从上级分红抽取比例(%),顶级为空 | +| `agent_settlement_period` | 结算周期与大盘快照 | +| `agent_commission_record` | 各管理员本期实发佣金 | +| `admin_wallet` / `admin_wallet_record` | 管理员钱包与入账流水 | -1. **渠道分红**:系统按渠道配置(返水或联营)自动计算该周期渠道总佣金。 -2. **人员分配**:渠道总佣金按“渠道内管理员分配比例”自动拆分。 -3. **角色组作用**:角色组只负责菜单权限与数据权限,不参与金额运算。 -4. **结算可追溯**:每条管理员佣金都能追溯到对应渠道结算单。 +> **历史表** `channel_admin_share`:2026-04-18 曾用于「渠道内 flat 100% 拆分」,2026-05-29 起结算改走代理树,后台入口已移除,请勿再依赖该表配置。 --- -## 7. 数据与实现改造建议(按最小改动) +## 7. 相关代码 -## 7.1 数据字段建议 - -- 保留:`channel` 的分红计算参数(现有) -- 建议新增(任选其一): - - 在 `admin` 增加 `channel_share_rate`(该管理员在所属渠道的拆分比例) - - 或新增 `channel_admin_share(channel_id, admin_id, share_rate)` - -## 7.2 逻辑改造建议 - -1. 渠道结算得到 `commission_amount` -2. 拉取该渠道有效管理员分配比例列表(总和=100%) -3. 按比例拆分并批量写入 `agent_commission_record` -4. 若分配比例未配置: - - 方案一:默认100%给渠道负责人 - - 方案二:阻止结算并提示“请先配置渠道管理员分配比例” - -## 7.3 风险控制建议 - -- 分配比例总和强校验(必须=100%) -- 管理员离职/禁用时自动重算或禁止结算 -- 结算后锁单,后改比例不影响历史账单 +| 模块 | 路径 | +|------|------| +| 渠道结算 | `app/common/service/ChannelSettlementService.php` | +| 树形拆分 | `app/common/service/AdminCommissionDistributionService.php` | +| 管理员 CRUD / 校验 | `app/admin/controller/auth/Admin.php` | +| 管理员前端 | `web/src/views/backend/auth/admin/` | +| 自动结算 | `app/process/ChannelAutoSettleTicker.php` | --- -## 8. 迁移策略建议 - -分三步上线: - -1. **第1步(兼容期)**:保留现有字段,新增“渠道内管理员分配比例”配置 -2. **第2步(双轨期)**:结算时同时产出“旧口径结果 + 新口径预览”用于核对 -3. **第3步(切换期)**:正式切到“渠道+管理员两级”,角色组比例仅保留展示或下线 - ---- - -## 9. 最终建议结论 - -从“甲方能否看懂”和“系统可维护性”来看,建议采用: - -**渠道结算 + 渠道内管理员二次分配(两级)**。 - -这是在你们当前代码基础上改造成本最低、解释成本最低、财务对账最清晰的方案。 -不建议继续强化“渠道->角色组->管理员”三级分红公式作为主线。 - ---- - -## 10. 本次已落地改造(2026-04-18) - -已在系统中完成以下改造: - -1. 新增渠道管理员分配表:`channel_admin_share` -2. 下线并删除旧字段:`admin_group.commission_rate`、`admin.commission_rate` -3. 新增后台接口: - - `channelAdminShareList`:读取渠道管理员分配配置 - - `saveChannelAdminShare`:保存渠道管理员分配配置(启用项合计必须100%) -4. 手动结算改造: - - 先算渠道总佣金 - - 再按 `channel_admin_share` 比例拆分为多条 `agent_commission_record` - - 结算预览支持查看拆分明细 -5. 渠道后台页面新增“分配比例”按钮与配置弹窗,用于维护管理员分配比例 -6. 角色组与管理员页面已移除旧分红比例字段展示与编辑项 - -说明: -- 若未配置 `channel_admin_share`,系统会回退为“渠道首个管理员100%”以保证兼容历史流程。 +## 8. 变更记录 +| 日期 | 说明 | +|------|------| +| 2026-04-18 | 落地 `channel_admin_share` 渠道内 flat 拆分;移除 `admin`/`admin_group.commission_rate` | +| 2026-04-23 | 超管结算即发放至管理员钱包;新增 `admin_wallet` 体系 | +| 2026-05-29 | **改为代理树形分红**:配置迁移至管理员管理 `commission_share_rate` + `parent_admin_id`;移除渠道页分配比例入口;管理员列表树形展示与下级可见范围 | diff --git a/docs/后端.md b/docs/后端.md index 753d052..11bf13d 100644 --- a/docs/后端.md +++ b/docs/后端.md @@ -76,13 +76,14 @@ * **提现手续费收益报表**:单独统计系统抽取的 0.5% 手续费总收入。 ### 3.5 🏢 代理中枢与佣金结算系统 (Agent System) -* **创建总代账号**:由于前端不支持散户变代理,只能由管理员在此处点击 `[新增总代 Master Agent]`,生成专属邀请链接。 -* **代理树状图 (Tree View)**:可视化查看整个多级代理层级及人数。 -* **佣金结算看板 (Commission Settlement)**: - * 后端按周/月跑批处理脚本(Cron Job)。 - * 展示全平台周期总盈利(大盘盈亏)。 - * 列出各代理线的流水占比%、应得分红、GM盘口扣除利润、实际下放分红。 - * 提供 `[一键发佣]` 按钮,将佣金批量转入各代理的 Agent 钱包。 +* **创建总代/子代账号**:在 **管理员管理**(`/admin/auth/admin`)维护代理树:`parent_admin_id`、`commission_share_rate`(从上级分红抽取 %)、`channel_id`、邀请码。 +* **代理树状图 (Tree View)**:管理员列表以树形展示;非超管仅见本人及全部下级。 +* **渠道佣金结算**(仅超管): + * 按渠道 `agent_mode` 与已结算注单计算渠道总佣金(非充值口径)。 + * 按代理树拆分各管理员实得,写入 `agent_commission_record` 并 **即时入账** `admin_wallet`。 + * 支持周期自动结算(`ChannelAutoSettleTicker`)与手动提前结算。 +* **佣金结算看板**:`agent_settlement_period`、`agent_commission_record` 列表查询与对账。 +* 详细口径见 `docs/分红说明文档.md`。 ### 3.6 🤝 联营契约与合营代理管控大厅 (Affiliate Management) 设计为与普通流水分佣系统并行的**客损占成代理模块**方案: diff --git a/web/src/lang/backend/en/auth/admin.ts b/web/src/lang/backend/en/auth/admin.ts index ce648ed..9313ea9 100644 --- a/web/src/lang/backend/en/auth/admin.ts +++ b/web/src/lang/backend/en/auth/admin.ts @@ -2,6 +2,9 @@ export default { username: 'Username', nickname: 'Nickname', group: 'Group', + channel: 'Channel', + parent_admin: 'Parent agent', + commission_share_rate: 'Commission share (%)', avatar: 'Avatar', email: 'Email', mobile: 'Mobile Number', @@ -12,4 +15,12 @@ export default { 'Please leave blank if not modified': 'Please leave blank if you do not modify.', 'Personal signature': 'Personal Signature', 'Administrator login': 'Administrator Login Name', + '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', + '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.', + 'Share remainder none after current': 'This rate exceeds the remaining allocatable share. Please lower it before saving.', + 'Commission share rate range': 'Commission share must be between 0 and 100', } diff --git a/web/src/lang/backend/zh-cn/auth/admin.ts b/web/src/lang/backend/zh-cn/auth/admin.ts index 3c3404b..c82aa83 100644 --- a/web/src/lang/backend/zh-cn/auth/admin.ts +++ b/web/src/lang/backend/zh-cn/auth/admin.ts @@ -2,6 +2,9 @@ export default { username: '用户名', nickname: '昵称', group: '角色组', + channel: '渠道', + parent_admin: '上级代理', + commission_share_rate: '分红比例(%)', avatar: '头像', email: '电子邮箱', mobile: '手机号', @@ -12,4 +15,10 @@ export default { 'Please leave blank if not modified': '不修改请留空', 'Personal signature': '个性签名', 'Administrator login': '管理员登录名', + 'Manage subordinate agents here': '在此管理您下级代理管理员;仅显示您本人及所有下级,无法查看其他代理线下的子代理。', + 'Parent admin placeholder': '留空表示渠道顶级代理', + 'Share remainder hint': '同上级下已分配 {used}%,当前剩余可设 {remaining}%;若本项设为当前值,保存后上级约剩 {after}%。', + 'Share remainder none for parent': '同上级下子代理比例已合计 100%,上级在本层将无分红留存。', + 'Share remainder none after current': '当前比例将超过剩余可分配额度,请调低后再保存。', + 'Commission share rate range': '分红比例须在 0~100 之间', } diff --git a/web/src/views/backend/auth/admin/index.vue b/web/src/views/backend/auth/admin/index.vue index cfa0217..9ee7fd5 100644 --- a/web/src/views/backend/auth/admin/index.vue +++ b/web/src/views/backend/auth/admin/index.vue @@ -1,24 +1,27 @@ - + diff --git a/web/src/views/backend/auth/admin/popupForm.vue b/web/src/views/backend/auth/admin/popupForm.vue index 81529fa..cc0007a 100644 --- a/web/src/views/backend/auth/admin/popupForm.vue +++ b/web/src/views/backend/auth/admin/popupForm.vue @@ -1,5 +1,4 @@