diff --git a/app/admin/controller/Channel.php b/app/admin/controller/Channel.php index 5d44cb7..7bb3645 100644 --- a/app/admin/controller/Channel.php +++ b/app/admin/controller/Channel.php @@ -22,7 +22,6 @@ class Channel extends Backend 'manualSettlePreview', 'channelAdminShareList', 'saveChannelAdminShare', - 'batchSettlePending', 'settleStats', 'dividendRecordList', 'directBetRecordList', @@ -44,13 +43,9 @@ class Channel extends Backend protected bool $modelSceneValidate = true; - /** @var array 当前管理员绑定的渠道(写操作范围) */ - private array $ownChannelIds = []; - protected function initController(WebmanRequest $request): ?Response { $this->model = new \app\common\model\Channel(); - $this->ownChannelIds = $this->resolveOwnChannelIds(); return null; } @@ -257,7 +252,7 @@ class Channel extends Backend } /** - * 删除:仅允许删除本人绑定渠道(查看所有渠道不扩大写权限) + * 删除:须在可写渠道范围内 */ protected function _del(): Response { @@ -685,8 +680,12 @@ class Channel extends Backend } $remark = trim((string) $request->post('remark', '')); + $handlingFeeByAdmin = $this->parseCommissionSplitHandlingFees($request->post('commission_split')); + if ($handlingFeeByAdmin === false) { + return $this->error(__('Settlement handling fee rate must be between 0 and 100')); + } - $res = ChannelSettlementService::settleBySuperAdmin((int) $row['id'], intval($this->auth->id), $remark, false); + $res = ChannelSettlementService::settleBySuperAdmin((int) $row['id'], intval($this->auth->id), $remark, false, $handlingFeeByAdmin); if (($res['ok'] ?? false) !== true) { return $this->error((string) ($res['msg'] ?? __('Settlement failed'))); } @@ -694,7 +693,7 @@ class Channel extends Backend } /** - * 超管批量结算全部待结算渠道(可作为“提前结算”入口) + * 批量结算待结算渠道(需 channel/batchSettlePending;范围=当前账号可写渠道) */ public function batchSettlePending(WebmanRequest $request): Response { @@ -702,11 +701,15 @@ class Channel extends Backend if ($response !== null) { return $response; } - if (!$this->auth->isSuperAdmin()) { + if (!$this->canBatchSettle()) { + return $this->error(__('You have no permission')); + } + $scope = AdminChannelScopeService::writableChannelIds($this->auth); + if ($scope !== null && $scope === []) { return $this->error(__('You have no permission')); } // 批量按钮语义:手动触发“待结算渠道”结算,不受结算周期到点限制。 - $res = ChannelSettlementService::settleAllDueChannels(intval($this->auth->id), false); + $res = ChannelSettlementService::settleAllDueChannels(intval($this->auth->id), false, $scope); return $this->success(__('Batch settlement completed'), $res); } @@ -1003,13 +1006,15 @@ class Channel extends Backend private function assertChannelWritable(int $channelId): bool { - if ($channelId <= 0) { + if ($channelId <= 0 || $this->auth === null) { return false; } - if ($this->auth->isSuperAdmin()) { + $scope = AdminChannelScopeService::writableChannelIds($this->auth); + if ($scope === null) { return Db::name('channel')->where('id', $channelId)->value('id') !== null; } - return in_array($channelId, $this->ownChannelIds, true); + + return in_array($channelId, $scope, true); } private function assertChannelAccessible(int $channelId): bool @@ -1032,6 +1037,18 @@ class Channel extends Backend return $this->auth->check('channel/manualSettle'); } + private function canBatchSettle(): bool + { + if ($this->auth === null) { + return false; + } + if ($this->auth->isSuperAdmin()) { + return true; + } + + return $this->auth->check('channel/batchSettlePending'); + } + private function buildChannelPlayRecordQuery(int $channelId, bool $settledOnly) { $query = Db::name('game_play_record')->alias('pr') @@ -1412,34 +1429,6 @@ class Channel extends Backend return $chosen; } - /** - * @return array - */ - /** - * 写操作可作用的渠道(角色组绑定渠道 + 账号 channel_id,不含全平台只读) - * - * @return array - */ - private function resolveOwnChannelIds(): array - { - if ($this->auth === null) { - return []; - } - $ids = AdminChannelScopeService::resolveBoundGroupChannelIds($this->auth); - if ($ids !== []) { - return $ids; - } - $admin = Db::name('admin') - ->field(['id', 'channel_id']) - ->where('id', $this->auth->id) - ->find(); - if ($admin && !empty($admin['channel_id'])) { - $ids[] = (int) $admin['channel_id']; - } - - return array_values(array_unique($ids)); - } - /** * 佣金归属管理员:取该渠道下 admin.channel_id 匹配的首个管理员(按 id 升序)。 */ @@ -1656,6 +1645,16 @@ class Channel extends Backend } $mode = isset($data['agent_mode']) ? (string) $data['agent_mode'] : ''; + if (isset($data['settlement_handling_fee']) && $data['settlement_handling_fee'] !== '' && $data['settlement_handling_fee'] !== null) { + $fee = (float) $data['settlement_handling_fee']; + if ($fee < 0 || $fee > 100) { + return (string) __('Settlement handling fee rate must be between 0 and 100'); + } + $data['settlement_handling_fee'] = number_format($fee, 2, '.', ''); + } else { + $data['settlement_handling_fee'] = '0.00'; + } + if ($mode === 'turnover') { if (isset($data['turnover_share_rate']) && $data['turnover_share_rate'] !== '' && $data['turnover_share_rate'] !== null) { $num = (float) $data['turnover_share_rate']; @@ -1714,6 +1713,33 @@ class Channel extends Backend return $negative ? ('-' . $v) : $v; } + /** + * @return array|null|false admin_id => 手续费比例(%);false=比例非法 + */ + private function parseCommissionSplitHandlingFees(mixed $splitRaw): array|null|false + { + if (!is_array($splitRaw) || $splitRaw === []) { + return null; + } + $map = []; + foreach ($splitRaw as $item) { + if (!is_array($item)) { + continue; + } + $adminId = intval($item['admin_id'] ?? 0); + if ($adminId <= 0) { + continue; + } + $rateRaw = $item['handling_fee_rate'] ?? ($item['handling_fee'] ?? '0'); + $rate = bcadd(strval($rateRaw), '0', 2); + if (bccomp($rate, '0', 2) < 0 || bccomp($rate, '100', 2) > 0) { + return false; + } + $map[$adminId] = $rate; + } + return $map === [] ? null : $map; + } + private function validateLadderRulesField(array &$data): ?string { $rulesRaw = $data['affiliate_ladder_rules'] ?? null; diff --git a/app/common/controller/Backend.php b/app/common/controller/Backend.php index aaf51b8..3e8ee14 100644 --- a/app/common/controller/Backend.php +++ b/app/common/controller/Backend.php @@ -477,7 +477,7 @@ class Backend extends Api } /** - * 全平台只读范围:角色组均未绑定渠道,或拥有查看所有渠道权限,或超管 + * 全平台只读范围:超管,或未绑定渠道且拥有渠道模块基础权限 */ protected function hasGlobalReadScope(): bool { diff --git a/app/common/lang/en/service.php b/app/common/lang/en/service.php index e978fdd..6ba21ac 100644 --- a/app/common/lang/en/service.php +++ b/app/common/lang/en/service.php @@ -123,6 +123,8 @@ return [ 'Weekly settlement must select Monday to Sunday' => 'Weekly settlement must select Monday to Sunday', 'Monthly settlement day must be between 1 and 31' => 'Monthly settlement day must be between 1 and 31', 'Turnover commission rate must be between 0 and 100' => 'Turnover commission rate must be between 0 and 100', + 'Settlement handling fee cannot be negative' => 'Settlement handling fee cannot be negative', + 'Settlement handling fee rate must be between 0 and 100' => 'Settlement handling fee rate must be between 0 and 100', 'Affiliate share/fee rates are required' => 'Affiliate share/fee rates are required', 'Affiliate share/fee rates must be between 0 and 1' => 'Affiliate share/fee rates must be between 0 and 1', 'Affiliate ladder rules are required' => 'Affiliate ladder rules are required', diff --git a/app/common/lang/zh-cn/service.php b/app/common/lang/zh-cn/service.php index a04785a..f76dcc4 100644 --- a/app/common/lang/zh-cn/service.php +++ b/app/common/lang/zh-cn/service.php @@ -123,6 +123,8 @@ return [ 'Weekly settlement must select Monday to Sunday' => '周结必须选择周一到周日', 'Monthly settlement day must be between 1 and 31' => '月结日期必须在1到31之间', 'Turnover commission rate must be between 0 and 100' => '返水分红比例必须在0到100之间', + 'Settlement handling fee cannot be negative' => '代理结算手续费不能为负数', + 'Settlement handling fee rate must be between 0 and 100' => '代理结算手续费比例必须在0到100之间', 'Affiliate share/fee rates are required' => '联营占成比例不能为空', 'Affiliate share/fee rates must be between 0 and 1' => '联营占成比例必须在0到1之间', 'Affiliate ladder rules are required' => '联营阶梯规则不能为空', diff --git a/app/common/service/AdminChannelScopeService.php b/app/common/service/AdminChannelScopeService.php index fd572dc..8d37315 100644 --- a/app/common/service/AdminChannelScopeService.php +++ b/app/common/service/AdminChannelScopeService.php @@ -9,13 +9,14 @@ use support\think\Db; /** * 后台管理员渠道数据范围: - * - 账号 channel_id 或角色组 channel_id 任一绑定 → 仅可读对应渠道(优先于「查看所有渠道」) - * - 均未绑定且拥有 viewAllChannels → 全平台只读 + * - 账号或角色组绑定 channel_id → 仅该批渠道(读+写) + * - 均未绑定且拥有渠道模块基础权限(channel/index、edit 等)→ 全部渠道(读+写) + * - 均未绑定且无渠道模块权限 → 不可见 */ class AdminChannelScopeService { /** - * 是否具备全平台只读范围(超管 / 未绑定任何渠道且拥有查看所有渠道) + * 是否具备全平台只读范围(其它菜单按 admin 收窄时跳过):超管 / 未绑定渠道且拥有渠道模块权限 */ public static function hasGlobalReadScope(Auth $auth): bool { @@ -29,7 +30,7 @@ class AdminChannelScopeService return false; } - return self::hasViewAllChannelsPermission($auth); + return self::hasAnyChannelMenuPermission($auth); } /** @@ -38,6 +39,24 @@ class AdminChannelScopeService * @return array|null */ public static function readableChannelIds(Auth $auth): ?array + { + return self::resolveScopedChannelIds($auth); + } + + /** + * 可写渠道 ID 列表;null 表示不限制(全部渠道) + * + * @return array|null + */ + public static function writableChannelIds(Auth $auth): ?array + { + return self::resolveScopedChannelIds($auth); + } + + /** + * @return array|null + */ + private static function resolveScopedChannelIds(Auth $auth): ?array { if (!$auth->isLogin()) { return [0]; @@ -51,13 +70,55 @@ class AdminChannelScopeService return $ids; } - if (self::hasViewAllChannelsPermission($auth)) { + if (self::hasAnyChannelMenuPermission($auth)) { return null; } return [0]; } + /** + * 是否拥有渠道管理模块相关权限(菜单或任一 channel/* 按钮) + */ + public static function hasAnyChannelMenuPermission(Auth $auth): bool + { + if (!$auth->isLogin()) { + return false; + } + if ($auth->isSuperAdmin()) { + return true; + } + + $checkNodes = [ + 'channel', + 'channel/index', + 'channel/add', + 'channel/edit', + 'channel/del', + 'channel/manualSettle', + 'channel/batchSettlePending', + 'channel/settleStats', + ]; + foreach ($checkNodes as $node) { + if ($auth->check($node)) { + return true; + } + } + + $ruleList = $auth->getRuleList(); + foreach ($ruleList as $name) { + if (!is_string($name) || $name === '') { + continue; + } + $lower = strtolower($name); + if ($lower === 'channel' || str_starts_with($lower, 'channel/')) { + return true; + } + } + + return false; + } + /** * 管理员实际绑定的渠道(角色组 channel_id + 账号 admin.channel_id,去重) * @@ -124,30 +185,6 @@ class AdminChannelScopeService return array_values(array_unique($ids)); } - /** - * 是否拥有「查看所有渠道」按钮权限 - */ - public static function hasViewAllChannelsPermission(Auth $auth): bool - { - if (!$auth->isLogin()) { - return false; - } - $nodes = ['channel/viewAllChannels', 'channel/viewallchannels']; - foreach ($nodes as $node) { - if ($auth->check($node)) { - return true; - } - } - $ruleList = $auth->getRuleList(); - foreach ($nodes as $node) { - if (in_array(strtolower($node), $ruleList, true)) { - return true; - } - } - - return false; - } - /** * 列表按 channel_id 过滤时使用的 ID;null=不过滤 * diff --git a/app/common/service/AdminCommissionDistributionService.php b/app/common/service/AdminCommissionDistributionService.php index 5f3a41c..9d071b4 100644 --- a/app/common/service/AdminCommissionDistributionService.php +++ b/app/common/service/AdminCommissionDistributionService.php @@ -152,11 +152,154 @@ class AdminCommissionDistributionService } /** - * 将渠道本期总佣金按管理员树分配,返回各管理员实得金额 - * - * @return array + * 代理费前佣金占渠道本期总佣金的比例(%) */ - public static function distributeChannelCommission(int $channelId, string $totalCommission, string $calcBaseAmount): array + public static function calcCommissionSharePercent(string $gross, string $totalCommission): string + { + if (bccomp($totalCommission, '0', 2) <= 0 || bccomp($gross, '0', 2) <= 0) { + return '0.00'; + } + return bcdiv(bcmul($gross, '100', 4), $totalCommission, 2); + } + + /** + * 按百分比计算手续费金额:手续费 = 费前佣金 × (费率% / 100) + */ + public static function calcHandlingFeeAmount(string $gross, string $ratePercent): string + { + $rate = bcadd($ratePercent, '0', 2); + if (bccomp($gross, '0', 2) <= 0 || bccomp($rate, '0', 2) <= 0) { + return '0.00'; + } + if (bccomp($rate, '100', 2) > 0) { + $rate = '100.00'; + } + return bcmul($gross, bcdiv($rate, '100', 4), 2); + } + + /** + * 将渠道本期总佣金按管理员树分配,返回各管理员实得金额(费前) + * + * @param array|null $handlingFeeRateByAdmin admin_id => 手续费比例(%) + * @return array + */ + public static function distributeChannelCommission( + int $channelId, + string $totalCommission, + string $calcBaseAmount, + string $defaultHandlingFeeRate = '0.00', + ?array $handlingFeeRateByAdmin = null + ): array { + $nodes = self::collectHierarchicalNodes($channelId, $totalCommission); + if ($nodes === []) { + return []; + } + $defaultRate = self::normalizeHandlingFeeRatePercent($defaultHandlingFeeRate); + $merged = []; + foreach ($nodes as $node) { + $adminId = intval($node['admin_id'] ?? 0); + if ($adminId <= 0) { + continue; + } + $gross = strval($node['commission_amount'] ?? '0.00'); + if (bccomp($gross, '0', 2) <= 0) { + continue; + } + $settlementBase = strval($node['settlement_base_amount'] ?? '0.00'); + $feeRate = $defaultRate; + if ($handlingFeeRateByAdmin !== null && isset($handlingFeeRateByAdmin[$adminId])) { + $feeRate = self::normalizeHandlingFeeRatePercent(strval($handlingFeeRateByAdmin[$adminId])); + } + $feeAmount = self::calcHandlingFeeAmount($gross, $feeRate); + $net = bcsub($gross, $feeAmount, 2); + if (bccomp($net, '0', 2) < 0) { + $net = '0.00'; + } + $effectiveRate = bccomp($settlementBase, '0', 2) <= 0 + ? '0.0000' + : bcdiv($gross, $settlementBase, 6); + $merged[$adminId] = [ + 'admin_id' => $adminId, + 'commission_amount' => $gross, + 'commission_rate' => $effectiveRate, + 'calc_base_amount' => $settlementBase, + 'commission_share_percent' => self::calcCommissionSharePercent($gross, $totalCommission), + 'handling_fee_rate' => $feeRate, + 'handling_fee' => $feeAmount, + 'net_commission_amount' => $net, + ]; + } + return array_values($merged); + } + + /** + * 层级分配预览(先序遍历),含结算基数与手续费 + * + * @param array|null $handlingFeeRateByAdmin admin_id => 手续费比例(%) + * @return array> + */ + public static function buildSplitPreview( + int $channelId, + string $commissionTotal, + string $calcBaseAmount, + string $defaultHandlingFeeRate = '0.00', + ?array $handlingFeeRateByAdmin = null + ): array { + unset($calcBaseAmount); + $nodes = self::collectHierarchicalNodes($channelId, $commissionTotal); + if ($nodes === []) { + return []; + } + $defaultRate = self::normalizeHandlingFeeRatePercent($defaultHandlingFeeRate); + $out = []; + foreach ($nodes as $node) { + $adminId = intval($node['admin_id'] ?? 0); + if ($adminId <= 0) { + continue; + } + $gross = strval($node['commission_amount'] ?? '0.00'); + $feeRate = $defaultRate; + if ($handlingFeeRateByAdmin !== null && isset($handlingFeeRateByAdmin[$adminId])) { + $feeRate = self::normalizeHandlingFeeRatePercent(strval($handlingFeeRateByAdmin[$adminId])); + } + $feeAmount = self::calcHandlingFeeAmount($gross, $feeRate); + $net = bcsub($gross, $feeAmount, 2); + if (bccomp($net, '0', 2) < 0) { + $net = '0.00'; + } + $out[] = [ + 'admin_id' => $adminId, + 'admin_username' => strval($node['admin_username'] ?? ('#' . $adminId)), + 'parent_admin_id' => intval($node['parent_admin_id'] ?? 0), + 'level' => intval($node['level'] ?? 0), + 'settlement_base_amount' => strval($node['settlement_base_amount'] ?? '0.00'), + 'share_rate' => strval($node['share_rate'] ?? '0.00'), + 'commission_amount' => $gross, + 'commission_share_percent' => self::calcCommissionSharePercent($gross, $commissionTotal), + 'handling_fee_rate' => $feeRate, + 'handling_fee' => $feeAmount, + 'net_commission_amount' => $net, + ]; + } + return $out; + } + + private static function normalizeHandlingFeeRatePercent(string $rateRaw): string + { + $rate = bcadd($rateRaw, '0', 2); + if (bccomp($rate, '0', 2) < 0) { + return '0.00'; + } + if (bccomp($rate, '100', 2) > 0) { + return '100.00'; + } + return $rate; + } + + /** + * @return array + */ + private static function collectHierarchicalNodes(int $channelId, string $totalCommission): array { if ($channelId <= 0 || bccomp($totalCommission, '0', 2) <= 0) { return []; @@ -166,7 +309,7 @@ class AdminCommissionDistributionService ->where('status', 'enable') ->whereRaw('(parent_admin_id IS NULL OR parent_admin_id = 0)') ->order('id', 'asc') - ->field(['id', 'commission_share_rate']) + ->field(['id', 'commission_share_rate', 'username']) ->select() ->toArray(); if ($rootRows === []) { @@ -180,7 +323,7 @@ class AdminCommissionDistributionService break; } } - $merged = []; + $nodes = []; if ($useRateSplit) { foreach ($rootRows as $rootRow) { $rootId = intval($rootRow['id'] ?? 0); @@ -192,13 +335,7 @@ class AdminCommissionDistributionService if (bccomp($rootAmount, '0', 2) <= 0) { continue; } - $parts = self::distributeFromAdmin($rootId, $rootAmount, $calcBaseAmount); - foreach ($parts as $adminId => $amount) { - if (!isset($merged[$adminId])) { - $merged[$adminId] = '0.00'; - } - $merged[$adminId] = bcadd($merged[$adminId], $amount, 2); - } + self::appendNodeTree($rootId, $rootAmount, 0, 0, $rate, $nodes); } } else { $rootCount = count($rootRows); @@ -214,31 +351,74 @@ class AdminCommissionDistributionService 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); + if (bccomp($rootAmount, '0', 2) <= 0) { + continue; } + self::appendNodeTree($rootId, $rootAmount, 0, 0, '100.00', $nodes); } } - $out = []; - foreach ($merged as $adminId => $amount) { - if (bccomp($amount, '0', 2) <= 0) { + return $nodes; + } + + /** + * @param array $nodes + */ + private static function appendNodeTree( + int $adminId, + string $incomingAmount, + int $level, + int $parentAdminId, + string $shareRateFromParent, + array &$nodes + ): void { + if ($adminId <= 0 || bccomp($incomingAmount, '0', 2) <= 0) { + return; + } + $adminRow = Db::name('admin') + ->where('id', $adminId) + ->field(['id', 'username', 'commission_share_rate']) + ->find(); + if (!is_array($adminRow)) { + 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'; + $childPlans = []; + foreach ($children as $child) { + $childId = intval($child['id'] ?? 0); + if ($childId <= 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, - ]; + $rate = bcadd(strval($child['commission_share_rate'] ?? '0'), '0', 2); + if (bccomp($rate, '0', 2) <= 0) { + continue; + } + $childAmount = bcmul($incomingAmount, bcdiv($rate, '100', 4), 2); + if (bccomp($childAmount, '0', 2) <= 0) { + continue; + } + $givenToChildren = bcadd($givenToChildren, $childAmount, 2); + $childPlans[] = ['id' => $childId, 'amount' => $childAmount, 'rate' => $rate]; + } + $selfKeep = bcsub($incomingAmount, $givenToChildren, 2); + $nodes[] = [ + 'admin_id' => $adminId, + 'admin_username' => strval($adminRow['username'] ?? ('#' . $adminId)), + 'parent_admin_id' => $parentAdminId, + 'level' => $level, + 'settlement_base_amount' => $incomingAmount, + 'share_rate' => $shareRateFromParent, + 'commission_amount' => bccomp($selfKeep, '0', 2) > 0 ? $selfKeep : '0.00', + ]; + foreach ($childPlans as $plan) { + self::appendNodeTree(intval($plan['id']), strval($plan['amount']), $level + 1, $adminId, strval($plan['rate']), $nodes); } - return $out; } /** @@ -246,6 +426,7 @@ class AdminCommissionDistributionService */ private static function distributeFromAdmin(int $adminId, string $amount, string $calcBaseAmount): array { + unset($calcBaseAmount); if ($adminId <= 0 || bccomp($amount, '0', 2) <= 0) { return []; } @@ -272,7 +453,7 @@ class AdminCommissionDistributionService continue; } $givenToChildren = bcadd($givenToChildren, $childAmount, 2); - $childParts = self::distributeFromAdmin($childId, $childAmount, $calcBaseAmount); + $childParts = self::distributeFromAdmin($childId, $childAmount, '0.00'); foreach ($childParts as $aid => $part) { if (!isset($result[$aid])) { $result[$aid] = '0.00'; @@ -289,32 +470,4 @@ class AdminCommissionDistributionService } 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 b47e0d7..23a6c9e 100644 --- a/app/common/service/ChannelSettlementService.php +++ b/app/common/service/ChannelSettlementService.php @@ -9,8 +9,16 @@ use Throwable; class ChannelSettlementService { - public static function settleBySuperAdmin(int $channelId, int $operatorAdminId, string $remark = '', bool $auto = false): array - { + /** + * @param array|null $handlingFeeByAdmin admin_id => handling fee + */ + public static function settleBySuperAdmin( + int $channelId, + int $operatorAdminId, + string $remark = '', + bool $auto = false, + ?array $handlingFeeByAdmin = null + ): array { $channel = Db::name('channel')->where('id', $channelId)->find(); if (!is_array($channel)) { return ['ok' => false, 'msg' => __('Channel not found')]; @@ -19,6 +27,7 @@ class ChannelSettlementService if (is_string($payload)) { return ['ok' => false, 'msg' => $payload]; } + $defaultFee = bcadd(strval($channel['settlement_handling_fee'] ?? '0'), '0', 2); $settlementNo = self::generateAgentSettlementNo($auto ? 'A' : 'M', $channelId, intval($payload['period_end_ts'])); if (Db::name('agent_settlement_period')->where('settlement_no', $settlementNo)->value('id')) { return ['ok' => false, 'msg' => __('Settlement number conflict, please retry')]; @@ -26,7 +35,9 @@ class ChannelSettlementService $distributions = AdminCommissionDistributionService::distributeChannelCommission( $channelId, strval($payload['commission_amount']), - strval($payload['calc_base_amount']) + strval($payload['calc_base_amount']), + $defaultFee, + $handlingFeeByAdmin ); if ($distributions === []) { return ['ok' => false, 'msg' => __('No channel root agent configured for commission distribution')]; @@ -58,20 +69,21 @@ class ChannelSettlementService } foreach ($rows as $row) { $adminId = intval($row['admin_id'] ?? 0); - $amount = strval($row['commission_amount'] ?? '0.00'); + $netAmount = strval($row['net_commission_amount'] ?? '0.00'); if ($adminId <= 0) { continue; } + unset($row['net_commission_amount']); $row['status'] = 1; $row['settled_at'] = $now; $row['remark'] = strval($row['remark'] ?? '') . ' | 超管结算直接发放'; $row['update_time'] = $now; $commissionRecordId = intval(Db::name('agent_commission_record')->insertGetId($row)); - if (bccomp($amount, '0.00', 2) > 0) { + if (bccomp($netAmount, '0.00', 2) > 0) { AdminWalletService::creditCommission( $adminId, $channelId, - $amount, + $netAmount, 'agent_commission_record', $commissionRecordId, $remark !== '' ? $remark : '超管结算自动发放分红', @@ -97,9 +109,16 @@ class ChannelSettlementService return ['ok' => false, 'msg' => __('This flow pays commissions automatically after super admin settlement; channel admin does not need to settle again')]; } - public static function settleAllDueChannels(int $operatorAdminId, bool $respectCycle = true): array + public static function settleAllDueChannels(int $operatorAdminId, bool $respectCycle = true, ?array $channelIds = null): array { - $channels = Db::name('channel')->where('status', 1)->select()->toArray(); + $query = Db::name('channel')->where('status', 1); + if ($channelIds !== null) { + if ($channelIds === []) { + return ['ok_count' => 0, 'failed' => []]; + } + $query->whereIn('id', $channelIds); + } + $channels = $query->select()->toArray(); $ok = 0; $failed = []; $now = time(); @@ -191,10 +210,12 @@ class ChannelSettlementService 'calc_base_amount' => $commission['calc_base_amount'], 'commission_amount' => $commission['commission_amount'], 'agent_mode' => $mode, + 'settlement_handling_fee' => bcadd(strval($row['settlement_handling_fee'] ?? '0'), '0', 2), 'commission_split' => AdminCommissionDistributionService::buildSplitPreview( $channelId, $commission['commission_amount'], - $commission['calc_base_amount'] + $commission['calc_base_amount'], + bcadd(strval($row['settlement_handling_fee'] ?? '0'), '0', 2) ), ]; } @@ -332,7 +353,8 @@ class ChannelSettlementService } /** - * @param array $distributions + * @param array $distributions + * @return array> */ private static function buildCommissionRowsFromDistribution(array $distributions, int $channelId, int $periodId, string $remark, int $now): array { @@ -350,6 +372,8 @@ class ChannelSettlementService 'commission_rate' => strval($dist['commission_rate'] ?? '0.0000'), 'calc_base_amount' => strval($dist['calc_base_amount'] ?? '0.00'), 'commission_amount' => $amount, + 'handling_fee' => strval($dist['handling_fee'] ?? '0.00'), + 'net_commission_amount' => strval($dist['net_commission_amount'] ?? '0.00'), 'status' => 0, 'settled_at' => null, 'remark' => $remark . ' | 树形分红实发', diff --git a/docs/36字花-数据库与实施计划.md b/docs/36字花-数据库与实施计划.md index 2e066b0..6f7e56e 100644 --- a/docs/36字花-数据库与实施计划.md +++ b/docs/36字花-数据库与实施计划.md @@ -138,7 +138,7 @@ ### 3.3 注意 -`channel` 不承担子代理树结构;子代理层级与邀请码归属下沉到 `admin`。渠道列表**只读范围**:超管、所属角色组均未绑定 `channel_id`、或拥有 `channel/viewAllChannels` 时可查看全平台渠道;否则仅绑定渠道或本人 `admin.channel_id`。详见 `docs/渠道管理后台说明.md`。 +`channel` 不承担子代理树结构;子代理层级与邀请码归属下沉到 `admin`。渠道列表范围:超管不限;绑定 `channel_id` → 仅对应渠道;均未绑定且拥有渠道模块基础权限(`channel/index`、`edit` 等)→ 全平台可读可写。详见 `docs/渠道管理后台说明.md`。 --- diff --git a/docs/en/channel-admin-guide.md b/docs/en/channel-admin-guide.md index d110530..427c254 100644 --- a/docs/en/channel-admin-guide.md +++ b/docs/en/channel-admin-guide.md @@ -24,11 +24,11 @@ List filters: **All / With balance / No balance / Enabled only / Disabled only** `AdminChannelScopeService` applies to list and stats: -**Bound channel wins** (only those channels) if `admin.channel_id` > 0 and/or any role group has `channel_id` — even with `viewAllChannels`. +**Bound channel** (read + write): `admin.channel_id` and/or any role group `channel_id` → only those channels. -**Global read** only when: super admin, **or** no channel binding on account and all groups **and** `viewAllChannels`. +**All channels** (read + write): super admin, **or** no channel binding **and** any channel module permission (`channel/index`, `channel/edit`, etc.). -**Write** (add/edit/delete/manual settle DB) stays on **writable** channels only; `viewAllChannels` does not expand write scope. +**No access**: unbound and no channel module permissions → empty list. --- @@ -36,12 +36,11 @@ List filters: **All / With balance / No balance / Enabled only / Disabled only** | Node | Label | Behavior | |------|-------|----------| -| `channel/viewAllChannels` | View all channels | Global read scope | | `channel/viewDividendRecords` | Paid dividend records | Top card + dialog | | `channel/viewDirectBetRecords` | Direct bet records | Direct bet column click | | `channel/viewSettlementBetRecords` | Settlement-scope bets | Row action | | `channel/manualSettle` | Manual settle | Preview + submit (readable channel) | -| `channel/batchSettlePending` | Batch settle | **Super admin only** | +| `channel/batchSettlePending` | Batch settle | Writable enabled channels in scope | Re-login after role changes to refresh `authNode`. diff --git a/docs/en/commission-share-guide.md b/docs/en/commission-share-guide.md index fe7ab2e..53c8bc2 100644 --- a/docs/en/commission-share-guide.md +++ b/docs/en/commission-share-guide.md @@ -50,7 +50,7 @@ If a sub-agent has further downline, the same rules apply on **their received am | Channel commission params | `/admin/channel` | `agent_mode`, turnover/affiliate rates, etc.; see [channel-admin-guide.md](./channel-admin-guide.md) | | Agent tree & share rates | `/admin/auth/admin` | Tree list; parent agent, share rate, channel | | Channel filter | Admin list common search | Super admin can filter by channel | -| Channel list read scope | `/admin/channel` | Super admin / unbound groups / `viewAllChannels` → all channels read-only | +| Channel list scope | `/admin/channel` | Super admin / unbound + channel module perms → all channels; bound → bound channels only | | Visibility | Admin list | Non–super admin sees **self + all downline** only | | Settlement | `/admin/channel` manual / cron | Super admin **or** `channel/manualSettle`; batch still super admin only | diff --git a/docs/分红说明文档.md b/docs/分红说明文档.md index 09a4f53..8963d4c 100644 --- a/docs/分红说明文档.md +++ b/docs/分红说明文档.md @@ -50,7 +50,7 @@ | 渠道分红参数 | `/admin/channel` | `agent_mode`、返水/联营比例、结算周期等;详见 [渠道管理后台说明.md](./渠道管理后台说明.md) | | 代理树与分红比例 | `/admin/auth/admin` | 树形列表;配置上级代理、分红比例、渠道归属 | | 渠道筛选 | 管理员列表公共搜索 | 超管可按渠道筛选 | -| 渠道列表可见范围 | `/admin/channel` | 账号或角色组绑定渠道 → 仅该渠道;均未绑定且 `viewAllChannels` → 全平台;超管不限 | +| 渠道列表可见范围 | `/admin/channel` | 绑定渠道 → 仅该渠道;未绑定且有渠道模块权限 → 全平台;超管不限 | | 数据可见范围 | 管理员列表 | 非超管仅见 **本人 + 全部下级**,不见其他代理线 | | 结算执行 | `/admin/channel` 手动结算 / 定时任务 | **超管**或 `channel/manualSettle`(渠道可读);批量结算仍仅超管;结算即发放至 `admin_wallet` | diff --git a/docs/渠道管理后台说明.md b/docs/渠道管理后台说明.md index 5a255ba..41cd805 100644 --- a/docs/渠道管理后台说明.md +++ b/docs/渠道管理后台说明.md @@ -33,17 +33,17 @@ 上述情况合并去重后作为可读渠道 ID 列表。 -**全平台只读**(仅当同时满足): +**全平台渠道范围**(读、写一致,须同时满足): | 条件 | 说明 | |------|------| | 超管 | 权限含 `*` | | 未绑定任何渠道 | 账号 `channel_id` 为空且所有角色组 `channel_id` 为空 | -| 且拥有「查看所有渠道」 | 按钮权限 `channel/viewAllChannels` | +| 且拥有渠道模块基础权限 | 如 `channel/index`、`channel/edit` 等(含渠道菜单或任一 `channel/*` 按钮) | -未绑定渠道且无 `viewAllChannels` 时,渠道列表为空。 +未绑定渠道且无上述渠道权限时,渠道列表为空。 -**写操作**(新增/编辑/删除渠道、手动结算写库)仍限制在**可写渠道**:角色组绑定渠道 + 账号 `channel_id`,**不**因「查看所有渠道」而扩大写范围。 +**读、写范围一致**:绑定渠道 → 仅绑定渠道;未绑定且有渠道模块权限 → 全部渠道(含新增/编辑/删除/手动结算写库)。 其它菜单(用户、充值/提现订单、游玩记录、控制台等)在只读全平台时同样可不按 `admin_id` 收窄,逻辑与 `Backend::hasGlobalReadScope()` 一致。 @@ -62,12 +62,11 @@ | 按钮权限节点 | 名称 | 行为 | |--------------|------|------| | `channel/index` | 查看 | 列表与详情 | -| `channel/viewAllChannels` | 查看所有渠道 | 扩大**只读**范围至全平台渠道 | | `channel/viewDividendRecords` | 查看已分红记录 | 顶部「已分红金额」卡片与弹窗 | | `channel/viewDirectBetRecords` | 查看直属投注记录 | 「直属投注额」列点击 | | `channel/viewSettlementBetRecords` | 查看总投注金额 | 操作列;分红口径已结算注单 | | `channel/manualSettle` | 手动结算 | 操作列;预览并提交渠道结算(见 §5) | -| `channel/batchSettlePending` | 一键批量结算 | **仅超管**可见;结算全部待结算渠道 | +| `channel/batchSettlePending` | 一键批量结算 | 批量结算当前账号**可写范围**内启用渠道 | | `channel/add` / `edit` / `del` | 增删改 | 须对目标渠道具备写权限 | 角色组在 **权限管理 → 角色组** 中勾选对应按钮;保存后管理员需**重新登录**以刷新前端 `authNode`。 @@ -80,7 +79,9 @@ - **权限**:超管,或拥有 `channel/manualSettle` 且目标渠道在**可读范围**内 - **逻辑**:与超管结算相同,调用 `ChannelSettlementService::settleBySuperAdmin`,结算即按代理树发放至 `admin_wallet` - **周期**:上次结算结束时间 ~ 当前时间;金额来自期内 **已结算** 游玩记录(`game_play_record.status = 2`) -- **批量**:`POST /admin/channel/batchSettlePending` 仍**仅超管** +- **手续费**:渠道字段 `settlement_handling_fee` 为默认**手续费比例(%)**;计算公式:手续费金额 = 费前佣金 × 比例 / 100;预览弹窗可按代理修改比例,提交时 `commission_split[].handling_fee_rate` 回传;实发 = 费前佣金 − 手续费金额,记录表 `handling_fee` 存扣除金额 +- **分配预览**:按代理树先序展示,列含层级缩进、**结算基数**(上级分给该代理的金额)、分配比例、费前佣金、手续费、实发佣金 +- **批量**:`POST /admin/channel/batchSettlePending` 需 `channel/batchSettlePending`;仅结算当前账号可写范围内启用渠道 --- @@ -139,7 +140,7 @@ | GET | `/admin/channel/dividendRecordList` | 已分红记录 | | GET | `/admin/channel/manualSettlePreview` | 手动结算预览 | | POST | `/admin/channel/manualSettle` | 提交手动结算 | -| POST | `/admin/channel/batchSettlePending` | 超管批量结算 | +| POST | `/admin/channel/batchSettlePending` | 批量结算(可写渠道范围) | --- @@ -162,3 +163,4 @@ | 2026-05-30 | 新增:查看所有渠道、下注/分红查看按钮;下注记录弹窗列与筛选;移动端弹窗适配 | | 2026-05-30 | 手动结算:拥有 `channel/manualSettle` 且渠道可读即可操作(不再仅限超管展示按钮) | | 2026-05-30 | 修复:账号已设 `channel_id` 时不再因角色组未绑渠道而误判为全平台可见 | +| 2026-05-30 | 移除 `channel/viewAllChannels`;未绑定渠道且拥有渠道模块基础权限时读写全平台渠道 | diff --git a/web/src/lang/backend/en/agent/commissionRecord.ts b/web/src/lang/backend/en/agent/commissionRecord.ts index 558b06d..17b8328 100644 --- a/web/src/lang/backend/en/agent/commissionRecord.ts +++ b/web/src/lang/backend/en/agent/commissionRecord.ts @@ -9,7 +9,9 @@ export default { admin_username: 'Agent username', commission_rate: 'Commission rate', calc_base_amount: 'Calculation base amount', - commission_amount: 'Commission amount', + commission_amount: 'Commission amount (gross)', + handling_fee: 'Handling fee amount', + net_commission_amount: 'Net commission', status: 'Status', 'status 0': 'Pending', 'status 1': 'Paid', diff --git a/web/src/lang/backend/en/channel.ts b/web/src/lang/backend/en/channel.ts index b5ce6c1..379165d 100644 --- a/web/src/lang/backend/en/channel.ts +++ b/web/src/lang/backend/en/channel.ts @@ -15,6 +15,19 @@ export default { agent_mode_desc_affiliate_2: 'Formula: net loss after costs × affiliate share rate.', agent_mode_desc_affiliate_3: 'Fields: supports fee deduction rate and carryover balance; turnover rate is cleared automatically.', turnover_share_rate: 'turnover_share_rate', + settlement_handling_fee: 'settlement_handling_fee(%)', + settlement_handling_fee_tip: 'Percent of gross commission per agent per period; adjustable in manual settle', + settlement_handling_fee_percent: 'handling_fee(%)', + settlement_handling_fee_amount: 'handling_fee_amount', + manual_settle_settlement_base: 'settlement_base', + manual_settle_net_commission: 'net_commission', + manual_settle_col_base: 'Base', + manual_settle_col_share: 'Share', + manual_settle_col_gross: 'Gross', + manual_settle_col_commission_share: 'Share%', + manual_settle_col_net_short: 'Net', + manual_settle_col_fee: 'Fee', + manual_settle_col_net: 'Net', affiliate_share_rate: 'affiliate_share_rate', affiliate_fee_rate: 'affiliate_fee_rate', affiliate_contract_no: 'affiliate_contract_no', @@ -72,6 +85,19 @@ export default { manual_settle_commission_amount: 'Commission amount', manual_settle_submit: 'Settle', manual_settle_remark: 'Remark', + manual_settle_calc_title: 'Settlement calculation', + manual_settle_calc_intro_turnover_1: 'For this period, sum settled bets: total bet and total payout; platform PnL = bet − payout.', + manual_settle_calc_intro_turnover_2: 'Channel commission = settlement base (total bet) × turnover share rate; the table below splits it by agent tree.', + manual_settle_calc_intro_turnover_3: 'Commission applies only when platform-wide PnL is positive; otherwise channel commission is 0.', + manual_settle_calc_intro_affiliate_1: 'For this period, affiliate commission is based on net player loss and ladder rules in this channel.', + manual_settle_calc_intro_affiliate_2: 'Channel commission = base after costs × share rate; split in the table by agent hierarchy.', + manual_settle_calc_intro_affiliate_3: 'If net loss is not positive, commission may be 0 — use preview amounts.', + manual_settle_calc_tree_1: 'Expand/collapse the tree; each row is one agent share in this settlement.', + manual_settle_calc_tree_2: 'Settlement base = amount from parent; share % = cut from parent pool; commission = gross before fee.', + manual_settle_calc_tree_3: 'Commission share % = agent gross ÷ channel total commission × 100%.', + manual_settle_calc_handling_fee: 'Net = gross − fee; fee uses channel rate {rate}% of gross, once per agent per period.', + manual_settle_col_net: 'Net', + manual_settle_split_scroll_tip: 'Swipe horizontally to view all columns', share_config: 'Share config', share_config_title: 'Channel admin share config', share_config_tip: 'Only enabled rows participate in settlement split, and enabled share total must equal 100%.', diff --git a/web/src/lang/backend/zh-cn/agent/commissionRecord.ts b/web/src/lang/backend/zh-cn/agent/commissionRecord.ts index 86fd125..672ea18 100644 --- a/web/src/lang/backend/zh-cn/agent/commissionRecord.ts +++ b/web/src/lang/backend/zh-cn/agent/commissionRecord.ts @@ -9,7 +9,9 @@ export default { admin_username: '代理账号', commission_rate: '佣金比例', calc_base_amount: '结算基数', - commission_amount: '佣金金额', + commission_amount: '佣金金额(费前)', + handling_fee: '手续费金额', + net_commission_amount: '实发佣金', status: '状态', 'status 0': '待发放', 'status 1': '已发放', diff --git a/web/src/lang/backend/zh-cn/channel.ts b/web/src/lang/backend/zh-cn/channel.ts index fb65244..1e475fe 100644 --- a/web/src/lang/backend/zh-cn/channel.ts +++ b/web/src/lang/backend/zh-cn/channel.ts @@ -15,6 +15,20 @@ export default { agent_mode_desc_affiliate_2: '计算方式:净客损扣除成本后 × 联营占成比例(affiliate_share_rate)。', agent_mode_desc_affiliate_3: '字段说明:可配置联营成本扣除比例与负结转余额;切换到该模式会自动清空返水比例。', turnover_share_rate: '返水分红比例', + settlement_handling_fee: '代理结算手续费(%)', + settlement_handling_fee_tip: '按该代理费前佣金的百分比扣除,每期每代理扣一次;手动结算时可单独调整', + settlement_handling_fee_percent: '手续费(%)', + settlement_handling_fee_amount: '手续费金额', + manual_settle_settlement_base: '结算基数', + manual_settle_net_commission: '实发佣金', + manual_settle_col_base: '基数', + manual_settle_col_share: '比例', + manual_settle_col_gross: '佣金', + manual_settle_col_commission_share: '占比', + manual_settle_col_net_short: '实发', + manual_settle_col_fee: '手续费', + manual_settle_col_net: '实发', + manual_settle_split_scroll_tip: '左右滑动查看全部分配列', affiliate_share_rate: '联营占成比例', affiliate_fee_rate: '联营成本扣除比例', affiliate_contract_no: '联营契约编号', @@ -72,6 +86,17 @@ export default { manual_settle_commission_amount: '佣金金额', manual_settle_submit: '结算', manual_settle_remark: '备注', + manual_settle_calc_title: '结算计算说明', + manual_settle_calc_intro_turnover_1: '本期统计区间内,汇总该渠道已结算注单的总投注额与总派彩额,平台盈亏 = 总投注 − 总派彩。', + manual_settle_calc_intro_turnover_2: '渠道本期可分配佣金 = 结算基数(总投注额)× 返水分红比例;下方表格按代理树自上而下拆分该笔佣金。', + manual_settle_calc_intro_turnover_3: '仅当平台大盘盈利时参与分红;若大盘亏损或持平,本期渠道佣金为 0。', + manual_settle_calc_intro_affiliate_1: '本期统计区间内,按该渠道辖区净客损(平台盈亏)及联营规则计算可分配佣金。', + manual_settle_calc_intro_affiliate_2: '渠道本期可分配佣金 = 扣除联营成本后的结算基数 × 阶梯占成比例;下方表格按代理树拆分。', + manual_settle_calc_intro_affiliate_3: '净客损为负或持平时,本期可分配佣金可能为 0,以实际预览金额为准。', + manual_settle_calc_tree_1: '分配树可点击展开/收起;每一行对应该代理在本期分到的金额。', + manual_settle_calc_tree_2: '结算基数 = 上级代理结算后分给本代理的金额;分配比例 = 从上级佣金中抽取的百分比;佣金金额 = 本代理实得(费前)。', + manual_settle_calc_tree_3: '佣金占比 = 该代理费前佣金 ÷ 渠道本期总佣金 × 100%,表示占全部可分配佣金的比例。', + manual_settle_calc_handling_fee: '实发佣金 = 佣金金额 − 手续费金额;手续费按渠道配置 {rate}%(费前佣金比例)扣除,每期每代理扣一次。', share_config: '分配比例', share_config_title: '渠道管理员分配比例', share_config_tip: '仅启用项参与结算拆分,且启用项占比总和必须等于100%。', diff --git a/web/src/views/backend/agent/commissionRecord/index.vue b/web/src/views/backend/agent/commissionRecord/index.vue index 1f6fb01..13db5d4 100644 --- a/web/src/views/backend/agent/commissionRecord/index.vue +++ b/web/src/views/backend/agent/commissionRecord/index.vue @@ -109,6 +109,14 @@ const baTable = new baTableClass( operator: 'RANGE', formatter: formatAmount2, }, + { + label: t('agent.commissionRecord.handling_fee'), + prop: 'handling_fee', + align: 'center', + minWidth: 100, + operator: 'RANGE', + formatter: formatAmount2, + }, { label: t('agent.commissionRecord.status'), prop: 'status', diff --git a/web/src/views/backend/channel/index.vue b/web/src/views/backend/channel/index.vue index 8bf6b59..e3d277d 100644 --- a/web/src/views/backend/channel/index.vue +++ b/web/src/views/backend/channel/index.vue @@ -42,7 +42,7 @@ {{ t('channel.settle_filter_enabled') }} {{ t('channel.settle_filter_disabled') }} - + {{ t('channel.batch_settle_pending') }} @@ -53,17 +53,26 @@
- + @@ -92,13 +101,97 @@ - - - - - - - +
+
+
+ + + + + + + + + + + + + + + +
+

+ {{ t('channel.manual_settle_split_scroll_tip') }} +

+
+ +
    +
  • {{ line }}
  • +
+
+
@@ -328,13 +421,12 @@