1.优化分红方式
This commit is contained in:
@@ -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<string, mixed> $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<int, array<string, mixed>> $rows
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
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;
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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)' => '结算区间无效(开始时间不早于当前)',
|
||||
|
||||
238
app/common/service/AdminCommissionDistributionService.php
Normal file
238
app/common/service/AdminCommissionDistributionService.php
Normal file
@@ -0,0 +1,238 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use support\think\Db;
|
||||
|
||||
/**
|
||||
* 代理管理员树形分红:子代理从上级分红中按 commission_share_rate 抽取,上级保留剩余部分
|
||||
*/
|
||||
class AdminCommissionDistributionService
|
||||
{
|
||||
/**
|
||||
* @return int[]
|
||||
*/
|
||||
public static function getDescendantAdminIds(int $adminId): array
|
||||
{
|
||||
if ($adminId <= 0) {
|
||||
return [];
|
||||
}
|
||||
$childIds = Db::name('admin')
|
||||
->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<int, array{admin_id:int,commission_amount:string,commission_rate:string,calc_base_amount:string}>
|
||||
*/
|
||||
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<int, string> 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<int, array{admin_id:int,admin_username:string,share_rate:string,commission_amount:string}>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<int, array{admin_id:int,commission_amount:string,commission_rate:string,calc_base_amount:string}> $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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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`(动态参数)
|
||||
|
||||
|
||||
@@ -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<string>(负责人所属角色组层级路径列表)
|
||||
- `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`~~
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -70,6 +70,8 @@
|
||||
2. **总代拓客**:总代登录 Agent Portal,生成自己的推广链接发给散户;或者生成【子代专属邀请码】发展下级代理。
|
||||
3. **分配比例**:总代在给子代开户时,将自己手中 80% 的分红权,划拨一部分(如 70%)给子代,自己赚取 10% 的级差。
|
||||
|
||||
> **系统实现(2026-05-29)**:在后台 **管理员管理**(`/admin/auth/admin`)为子代理设置 **上级代理** 与 **分红比例(%)**;渠道结算后从上级实得中按该比例向子代理拆分,上级保留剩余部分。同一上级下子代理比例合计不得超过 100%。详见 `docs/分红说明文档.md`。
|
||||
|
||||
### 2.2 🌟 核心分佣结算流程(流水占比分桶模式)
|
||||
系统设定为每周一凌晨(或每日)自动执行结算脚本:
|
||||
|
||||
|
||||
224
docs/分红说明文档.md
224
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`;移除渠道页分配比例入口;管理员列表树形展示与下级可见范围 |
|
||||
|
||||
15
docs/后端.md
15
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)
|
||||
设计为与普通流水分佣系统并行的**客损占成代理模块**方案:
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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 之间',
|
||||
}
|
||||
|
||||
@@ -1,24 +1,27 @@
|
||||
<template>
|
||||
<div class="default-main ba-table-box">
|
||||
<el-alert
|
||||
class="ba-table-alert admin-tree-alert"
|
||||
v-if="!adminInfo.super"
|
||||
:title="t('auth.admin.Manage subordinate agents here')"
|
||||
type="info"
|
||||
show-icon
|
||||
/>
|
||||
<el-alert class="ba-table-alert" v-if="baTable.table.remark" :title="baTable.table.remark" type="info" show-icon />
|
||||
|
||||
<!-- 表格顶部菜单 -->
|
||||
<TableHeader
|
||||
:buttons="['refresh', 'add', 'edit', 'delete', 'comSearch', 'quickSearch', 'columnDisplay']"
|
||||
:buttons="['refresh', 'add', 'edit', 'delete', 'unfold', 'comSearch', 'quickSearch', 'columnDisplay']"
|
||||
:quick-search-placeholder="t('Quick search placeholder', { fields: t('auth.admin.username') + '/' + t('auth.admin.nickname') })"
|
||||
/>
|
||||
|
||||
<!-- 表格 -->
|
||||
<!-- 要使用`el-table`组件原有的属性,直接加在Table标签上即可 -->
|
||||
<Table />
|
||||
<Table ref="tableRef" :pagination="false" />
|
||||
|
||||
<!-- 表单 -->
|
||||
<PopupForm />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { provide } from 'vue'
|
||||
import { onMounted, provide, useTemplateRef } from 'vue'
|
||||
import baTableClass from '/@/utils/baTable'
|
||||
import PopupForm from './popupForm.vue'
|
||||
import Table from '/@/components/table/index.vue'
|
||||
@@ -34,6 +37,7 @@ defineOptions({
|
||||
|
||||
const { t } = useI18n()
|
||||
const adminInfo = useAdminInfo()
|
||||
const tableRef = useTemplateRef('tableRef')
|
||||
|
||||
const optButtons = defaultOptButtons(['edit', 'delete'])
|
||||
optButtons[1].display = (row) => {
|
||||
@@ -43,23 +47,69 @@ optButtons[1].display = (row) => {
|
||||
const baTable = new baTableClass(
|
||||
new baTableApi('/admin/auth.Admin/'),
|
||||
{
|
||||
expandAll: true,
|
||||
dblClickNotEditColumn: [undefined, 'status'],
|
||||
column: [
|
||||
{ type: 'selection', align: 'center', operator: false },
|
||||
{ label: t('Id'), prop: 'id', align: 'center', operator: '=', operatorPlaceholder: t('Id'), width: 70 },
|
||||
{ label: t('auth.admin.username'), prop: 'username', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
|
||||
{ label: t('Id'), prop: 'id', align: 'center', show: false, operator: '=', operatorPlaceholder: t('Id'), width: 70 },
|
||||
{
|
||||
label: t('auth.admin.username'),
|
||||
prop: 'username',
|
||||
align: 'left',
|
||||
minWidth: 160,
|
||||
operator: 'LIKE',
|
||||
operatorPlaceholder: t('Fuzzy query'),
|
||||
},
|
||||
{ label: t('auth.admin.nickname'), prop: 'nickname', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
|
||||
{
|
||||
label: t('auth.admin.channel'),
|
||||
prop: 'channel_id',
|
||||
show: false,
|
||||
operator: 'eq',
|
||||
comSearchRender: 'remoteSelect',
|
||||
comSearchInputAttr: {
|
||||
remoteUrl: '/admin/channel/index',
|
||||
field: 'name',
|
||||
pk: 'id',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('auth.admin.channel'),
|
||||
prop: 'channel_name',
|
||||
align: 'center',
|
||||
minWidth: 120,
|
||||
operator: false,
|
||||
},
|
||||
{
|
||||
label: t('auth.admin.parent_admin'),
|
||||
prop: 'parent_admin_username',
|
||||
align: 'center',
|
||||
minWidth: 120,
|
||||
operator: false,
|
||||
},
|
||||
{
|
||||
label: t('auth.admin.commission_share_rate'),
|
||||
prop: 'commission_share_rate',
|
||||
align: 'center',
|
||||
width: 120,
|
||||
operator: false,
|
||||
formatter: (_row, _col, val) => {
|
||||
if (val === null || val === undefined || val === '') return '-'
|
||||
return `${val}%`
|
||||
},
|
||||
},
|
||||
{
|
||||
label: t('auth.admin.group'),
|
||||
prop: 'group_name_arr',
|
||||
align: 'center',
|
||||
minWidth: 150,
|
||||
minWidth: 140,
|
||||
operator: false,
|
||||
render: 'tags',
|
||||
},
|
||||
{ label: t('auth.admin.invite_code'), prop: 'invite_code', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
|
||||
{ label: t('auth.admin.avatar'), prop: 'avatar', align: 'center', render: 'image', operator: false },
|
||||
{ label: t('auth.admin.email'), prop: 'email', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
|
||||
{ label: t('auth.admin.mobile'), prop: 'mobile', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query') },
|
||||
{ label: t('auth.admin.avatar'), prop: 'avatar', align: 'center', render: 'image', operator: false, show: false },
|
||||
{ label: t('auth.admin.email'), prop: 'email', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query'), show: false },
|
||||
{ label: t('auth.admin.mobile'), prop: 'mobile', align: 'center', operator: 'LIKE', operatorPlaceholder: t('Fuzzy query'), show: false },
|
||||
{
|
||||
label: t('auth.admin.Last login'),
|
||||
prop: 'last_login_time',
|
||||
@@ -68,6 +118,7 @@ const baTable = new baTableClass(
|
||||
sortable: 'custom',
|
||||
operator: 'RANGE',
|
||||
width: 160,
|
||||
show: false,
|
||||
},
|
||||
{ label: t('Create time'), prop: 'create_time', align: 'center', render: 'datetime', sortable: 'custom', operator: 'RANGE', width: 160 },
|
||||
{
|
||||
@@ -88,7 +139,7 @@ const baTable = new baTableClass(
|
||||
{
|
||||
label: t('Operate'),
|
||||
align: 'center',
|
||||
minWidth: '80',
|
||||
width: 100,
|
||||
render: 'buttons',
|
||||
buttons: optButtons,
|
||||
operator: false,
|
||||
@@ -100,22 +151,32 @@ const baTable = new baTableClass(
|
||||
{
|
||||
defaultItems: {
|
||||
status: 'enable',
|
||||
parent_admin_id: null,
|
||||
commission_share_rate: null,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// 编辑自身时不提交角色组,避免与后端「不可修改自己所在管理组」校验冲突
|
||||
baTable.before.onSubmit = ({ operate, items }) => {
|
||||
if (operate === 'edit' && items.id == adminInfo.id) {
|
||||
delete items.group_arr
|
||||
delete items.group_name_arr
|
||||
delete items.parent_admin_id
|
||||
delete items.commission_share_rate
|
||||
}
|
||||
}
|
||||
|
||||
provide('baTable', baTable)
|
||||
|
||||
baTable.mount()
|
||||
baTable.getData()
|
||||
onMounted(() => {
|
||||
baTable.table.ref = tableRef.value
|
||||
baTable.mount()
|
||||
baTable.getData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
<style scoped lang="scss">
|
||||
.admin-tree-alert {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<template>
|
||||
<!-- 对话框表单 -->
|
||||
<el-dialog
|
||||
class="ba-operate-dialog"
|
||||
:close-on-click-modal="false"
|
||||
@@ -41,6 +40,19 @@
|
||||
prop="nickname"
|
||||
:placeholder="t('Please input field', { field: t('auth.admin.nickname') })"
|
||||
/>
|
||||
<FormItem
|
||||
v-if="showChannelField"
|
||||
:label="t('auth.admin.channel')"
|
||||
v-model="baTable.form.items!.channel_id"
|
||||
type="remoteSelect"
|
||||
prop="channel_id"
|
||||
:input-attr="{
|
||||
remoteUrl: '/admin/channel/index',
|
||||
field: 'name',
|
||||
pk: 'id',
|
||||
placeholder: t('Click select'),
|
||||
}"
|
||||
/>
|
||||
<FormItem
|
||||
:label="t('auth.admin.group')"
|
||||
v-model="singleGroupValue"
|
||||
@@ -56,6 +68,37 @@
|
||||
placeholder: t('Click select'),
|
||||
}"
|
||||
/>
|
||||
<FormItem
|
||||
v-if="showParentField"
|
||||
:label="t('auth.admin.parent_admin')"
|
||||
v-model="baTable.form.items!.parent_admin_id"
|
||||
type="remoteSelect"
|
||||
prop="parent_admin_id"
|
||||
:input-attr="{
|
||||
remoteUrl: '/admin/auth.Admin/index',
|
||||
field: 'username',
|
||||
pk: 'id',
|
||||
params: parentSelectParams,
|
||||
placeholder: t('auth.admin.Parent admin placeholder'),
|
||||
}"
|
||||
/>
|
||||
<FormItem
|
||||
v-if="showShareRateField"
|
||||
:label="t('auth.admin.commission_share_rate')"
|
||||
v-model="baTable.form.items!.commission_share_rate"
|
||||
type="number"
|
||||
prop="commission_share_rate"
|
||||
:input-attr="{
|
||||
min: 0,
|
||||
max: 100,
|
||||
step: 0.01,
|
||||
precision: 2,
|
||||
class: 'w100',
|
||||
}"
|
||||
/>
|
||||
<el-form-item v-if="showShareRateField && shareHint" label=" ">
|
||||
<el-alert :title="shareHint" :type="shareHintType" :closable="false" show-icon />
|
||||
</el-form-item>
|
||||
<FormItem
|
||||
v-if="baTable.form.operate == 'Edit'"
|
||||
:label="t('auth.admin.invite_code')"
|
||||
@@ -123,7 +166,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, inject, watch, useTemplateRef } from 'vue'
|
||||
import { computed, inject, reactive, ref, useTemplateRef, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type baTableClass from '/@/utils/baTable'
|
||||
import { regularPassword, buildValidatorData } from '/@/utils/validate'
|
||||
@@ -131,6 +174,7 @@ import type { FormItemRule } from 'element-plus'
|
||||
import FormItem from '/@/components/formItem/index.vue'
|
||||
import { useAdminInfo } from '/@/stores/adminInfo'
|
||||
import { useConfig } from '/@/stores/config'
|
||||
import createAxios from '/@/utils/axios'
|
||||
|
||||
const config = useConfig()
|
||||
const adminInfo = useAdminInfo()
|
||||
@@ -139,6 +183,45 @@ const baTable = inject('baTable') as baTableClass
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const shareHint = ref('')
|
||||
const shareHintType = ref<'info' | 'warning'>('info')
|
||||
|
||||
const isSelfEdit = computed(() => baTable.form.operate === 'Edit' && adminInfo.id == baTable.form.items?.id)
|
||||
|
||||
const showChannelField = computed(() => adminInfo.super && !isSelfEdit.value)
|
||||
|
||||
const showParentField = computed(() => adminInfo.super && !isSelfEdit.value)
|
||||
|
||||
const showShareRateField = computed(() => {
|
||||
if (isSelfEdit.value) return false
|
||||
if (adminInfo.super) {
|
||||
const pid = baTable.form.items?.parent_admin_id
|
||||
return pid !== null && pid !== undefined && pid !== '' && Number(pid) > 0
|
||||
}
|
||||
if (baTable.form.operate === 'Add') {
|
||||
return true
|
||||
}
|
||||
const pid = baTable.form.items?.parent_admin_id
|
||||
return pid !== null && pid !== undefined && pid !== '' && Number(pid) > 0
|
||||
})
|
||||
|
||||
const parentSelectParams = computed(() => {
|
||||
const params: anyObj = { select: true, limit: 200 }
|
||||
const cid = baTable.form.items?.channel_id
|
||||
if (cid !== null && cid !== undefined && cid !== '') {
|
||||
params['search[0][field]'] = 'channel_id'
|
||||
params['search[0][operator]'] = 'eq'
|
||||
params['search[0][val]'] = cid
|
||||
}
|
||||
const excludeId = baTable.form.items?.id
|
||||
if (excludeId) {
|
||||
params['search[1][field]'] = 'id'
|
||||
params['search[1][operator]'] = 'neq'
|
||||
params['search[1][val]'] = excludeId
|
||||
}
|
||||
return params
|
||||
})
|
||||
|
||||
const singleGroupValue = computed({
|
||||
get: () => {
|
||||
const group = baTable.form.items?.group_arr
|
||||
@@ -155,6 +238,73 @@ const singleGroupValue = computed({
|
||||
},
|
||||
})
|
||||
|
||||
const loadShareRemainder = async () => {
|
||||
if (!showShareRateField.value) {
|
||||
shareHint.value = ''
|
||||
return
|
||||
}
|
||||
let parentId = baTable.form.items?.parent_admin_id
|
||||
if (!adminInfo.super) {
|
||||
parentId = adminInfo.id
|
||||
}
|
||||
if (!parentId || Number(parentId) <= 0) {
|
||||
shareHint.value = ''
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await createAxios({
|
||||
url: '/admin/auth.Admin/commissionShareRemainder',
|
||||
method: 'get',
|
||||
params: {
|
||||
parent_admin_id: parentId,
|
||||
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.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.Share remainder none for parent')
|
||||
} else if (Number(afterCurrent) <= 0 && current) {
|
||||
shareHint.value = t('auth.admin.Share remainder none after current')
|
||||
}
|
||||
} catch {
|
||||
shareHint.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [baTable.form.items?.parent_admin_id, baTable.form.items?.commission_share_rate, baTable.form.operate],
|
||||
() => {
|
||||
void loadShareRemainder()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => baTable.form.items?.share_remainder,
|
||||
(val) => {
|
||||
if (val && baTable.form.operate === 'Edit') {
|
||||
shareHint.value = t('auth.admin.Share remainder hint', {
|
||||
used: val.used_rate ?? '0.00',
|
||||
remaining: val.remaining_rate ?? '100.00',
|
||||
after: val.remaining_rate ?? '100.00',
|
||||
})
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
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') })],
|
||||
@@ -179,6 +329,24 @@ const rules: Partial<Record<string, FormItemRule[]>> = reactive({
|
||||
trigger: 'change',
|
||||
},
|
||||
],
|
||||
commission_share_rate: [
|
||||
{
|
||||
validator: (_rule: any, val: unknown, callback: Function) => {
|
||||
if (!showShareRateField.value) {
|
||||
return callback()
|
||||
}
|
||||
if (val === null || val === undefined || val === '') {
|
||||
return callback(new Error(t('Please input field', { field: t('auth.admin.commission_share_rate') })))
|
||||
}
|
||||
const num = Number(val)
|
||||
if (!Number.isFinite(num) || num < 0 || num > 100) {
|
||||
return callback(new Error(t('auth.admin.Commission share rate range')))
|
||||
}
|
||||
return callback()
|
||||
},
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
email: [buildValidatorData({ name: 'email', message: t('Please enter the correct field', { field: t('auth.admin.email') }) })],
|
||||
mobile: [buildValidatorData({ name: 'mobile', message: t('Please enter the correct field', { field: t('auth.admin.mobile') }) })],
|
||||
password: [
|
||||
|
||||
@@ -106,51 +106,6 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog class="ba-operate-dialog" :close-on-click-modal="false" :model-value="shareDialog.visible" @close="closeShareDialog">
|
||||
<template #header>
|
||||
<div class="title">{{ t('channel.share_config_title') }}</div>
|
||||
</template>
|
||||
<div v-loading="shareDialog.loading" class="manual-settle-dialog-body">
|
||||
<el-alert type="info" :closable="false" show-icon class="mb-12">
|
||||
{{ t('channel.share_config_tip') }}
|
||||
</el-alert>
|
||||
<el-table :data="shareDialog.list" border size="small">
|
||||
<el-table-column :label="t('channel.admin_group_names')" min-width="260">
|
||||
<template #default="scope">
|
||||
<span v-if="scope.row.role_group_name" class="share-group-single">{{ scope.row.role_group_name }}</span>
|
||||
<span v-else class="share-group-empty">-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="username" :label="t('channel.admin__username')" min-width="120" />
|
||||
<el-table-column :label="t('channel.status')" width="120">
|
||||
<template #default="scope">
|
||||
<el-switch v-model="scope.row.status" :active-value="1" :inactive-value="0" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="t('channel.share_rate_percent')" min-width="180">
|
||||
<template #default="scope">
|
||||
<el-input-number
|
||||
v-model="scope.row.share_rate"
|
||||
:disabled="scope.row.status !== 1"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:step="0.01"
|
||||
:precision="2"
|
||||
class="w100"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="share-total-row">
|
||||
<span>{{ t('channel.share_total_enabled') }}: </span>
|
||||
<el-tag :type="shareEnabledTotal === '100.00' ? 'success' : 'danger'">{{ shareEnabledTotal }}%</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="closeShareDialog">{{ t('Cancel') }}</el-button>
|
||||
<el-button type="primary" :loading="shareDialog.saving" @click="saveShareDialog">{{ t('Save') }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -176,20 +131,6 @@ const { t } = useI18n()
|
||||
const adminInfo = useAdminInfo()
|
||||
const tableRef = useTemplateRef('tableRef')
|
||||
let optButtons: OptButton[] = [
|
||||
{
|
||||
render: 'tipButton',
|
||||
name: 'shareConfig',
|
||||
title: 'channel.share_config',
|
||||
text: '',
|
||||
type: 'primary',
|
||||
icon: 'el-icon-Setting',
|
||||
class: 'table-row-share-config',
|
||||
disabledTip: false,
|
||||
display: () => auth('edit'),
|
||||
click: (row: TableRow) => {
|
||||
void openShareDialog(row)
|
||||
},
|
||||
},
|
||||
{
|
||||
render: 'tipButton',
|
||||
name: 'manualSettle',
|
||||
@@ -242,13 +183,6 @@ const manualSettle = reactive({
|
||||
},
|
||||
})
|
||||
|
||||
const shareDialog = reactive({
|
||||
visible: false,
|
||||
loading: false,
|
||||
saving: false,
|
||||
channelId: 0,
|
||||
list: [] as Array<{ admin_id: number; username: string; role_group_name: string; role_level: number; status: number; share_rate: number | null }>,
|
||||
})
|
||||
const settleFilterMode = ref<'all' | 'with_balance' | 'no_balance' | 'enabled' | 'disabled'>('all')
|
||||
const settleStats = reactive({
|
||||
channel_total: 0,
|
||||
@@ -259,90 +193,6 @@ const settleStats = reactive({
|
||||
carryover_positive_total: '0.00',
|
||||
})
|
||||
|
||||
const shareEnabledTotal = computed(() => {
|
||||
let sum = 0
|
||||
for (const row of shareDialog.list) {
|
||||
if (row.status === 1) {
|
||||
const n = Number(row.share_rate ?? 0)
|
||||
if (Number.isFinite(n)) {
|
||||
sum += n
|
||||
}
|
||||
}
|
||||
}
|
||||
return sum.toFixed(2)
|
||||
})
|
||||
|
||||
const closeShareDialog = () => {
|
||||
shareDialog.visible = false
|
||||
shareDialog.channelId = 0
|
||||
shareDialog.list = []
|
||||
}
|
||||
|
||||
const openShareDialog = async (row: TableRow) => {
|
||||
shareDialog.channelId = Number(row.id || 0)
|
||||
shareDialog.visible = true
|
||||
shareDialog.loading = true
|
||||
try {
|
||||
const res = await createAxios(
|
||||
{
|
||||
url: '/admin/channel/channelAdminShareList',
|
||||
method: 'get',
|
||||
params: { id: row.id },
|
||||
},
|
||||
{ showErrorMessage: true }
|
||||
)
|
||||
if (res.code !== 1 || !res.data) {
|
||||
closeShareDialog()
|
||||
return
|
||||
}
|
||||
const list = Array.isArray(res.data.list) ? res.data.list : []
|
||||
shareDialog.list = list.map((item: anyObj) => {
|
||||
const rate = item.share_rate
|
||||
return {
|
||||
admin_id: Number(item.admin_id || 0),
|
||||
username: String(item.username || ''),
|
||||
role_group_name: String(item.role_group_name || ''),
|
||||
role_level: Number(item.role_level ?? 9999),
|
||||
status: Number(item.status ?? 1) === 1 ? 1 : 0,
|
||||
share_rate: rate === null || rate === undefined || rate === '' ? null : Number(rate),
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
shareDialog.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
const saveShareDialog = async () => {
|
||||
if (!shareDialog.channelId) {
|
||||
return
|
||||
}
|
||||
if (shareEnabledTotal.value !== '100.00') {
|
||||
ElMessage.error(t('channel.share_total_must_100'))
|
||||
return
|
||||
}
|
||||
shareDialog.saving = true
|
||||
try {
|
||||
await createAxios(
|
||||
{
|
||||
url: '/admin/channel/saveChannelAdminShare',
|
||||
method: 'post',
|
||||
data: {
|
||||
id: shareDialog.channelId,
|
||||
list: shareDialog.list.map((row) => ({
|
||||
admin_id: row.admin_id,
|
||||
status: row.status,
|
||||
share_rate: Number(row.share_rate || 0).toFixed(2),
|
||||
})),
|
||||
},
|
||||
},
|
||||
{ showSuccessMessage: true }
|
||||
)
|
||||
closeShareDialog()
|
||||
} finally {
|
||||
shareDialog.saving = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetManualSettleForm = () => {
|
||||
manualSettle.form.settlement_no = ''
|
||||
manualSettle.form.period_start_at = ''
|
||||
|
||||
Reference in New Issue
Block a user