1.优化充值跳转链接的问题
2.优化后台渠道管理页面的显示样式
This commit is contained in:
@@ -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<int, int> 当前管理员绑定的渠道(写操作范围) */
|
||||
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<int, int>
|
||||
*/
|
||||
/**
|
||||
* 写操作可作用的渠道(角色组绑定渠道 + 账号 channel_id,不含全平台只读)
|
||||
*
|
||||
* @return array<int, int>
|
||||
*/
|
||||
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<int, string>|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;
|
||||
|
||||
@@ -477,7 +477,7 @@ class Backend extends Api
|
||||
}
|
||||
|
||||
/**
|
||||
* 全平台只读范围:角色组均未绑定渠道,或拥有查看所有渠道权限,或超管
|
||||
* 全平台只读范围:超管,或未绑定渠道且拥有渠道模块基础权限
|
||||
*/
|
||||
protected function hasGlobalReadScope(): bool
|
||||
{
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' => '联营阶梯规则不能为空',
|
||||
|
||||
@@ -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<int, int>|null
|
||||
*/
|
||||
public static function readableChannelIds(Auth $auth): ?array
|
||||
{
|
||||
return self::resolveScopedChannelIds($auth);
|
||||
}
|
||||
|
||||
/**
|
||||
* 可写渠道 ID 列表;null 表示不限制(全部渠道)
|
||||
*
|
||||
* @return array<int, int>|null
|
||||
*/
|
||||
public static function writableChannelIds(Auth $auth): ?array
|
||||
{
|
||||
return self::resolveScopedChannelIds($auth);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, int>|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=不过滤
|
||||
*
|
||||
|
||||
@@ -152,11 +152,154 @@ class AdminCommissionDistributionService
|
||||
}
|
||||
|
||||
/**
|
||||
* 将渠道本期总佣金按管理员树分配,返回各管理员实得金额
|
||||
*
|
||||
* @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
|
||||
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<int, string>|null $handlingFeeRateByAdmin admin_id => 手续费比例(%)
|
||||
* @return array<int, array{admin_id:int,commission_amount:string,commission_rate:string,calc_base_amount:string,handling_fee_rate:string,handling_fee:string,net_commission_amount:string}>
|
||||
*/
|
||||
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<int, string>|null $handlingFeeRateByAdmin admin_id => 手续费比例(%)
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
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<int, array{admin_id:int,admin_username:string,parent_admin_id:int,level:int,settlement_base_amount:string,share_rate:string,commission_amount:string}>
|
||||
*/
|
||||
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<int, array{admin_id:int,admin_username:string,parent_admin_id:int,level:int,settlement_base_amount:string,share_rate:string,commission_amount:string}> $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<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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,16 @@ use Throwable;
|
||||
|
||||
class ChannelSettlementService
|
||||
{
|
||||
public static function settleBySuperAdmin(int $channelId, int $operatorAdminId, string $remark = '', bool $auto = false): array
|
||||
{
|
||||
/**
|
||||
* @param array<int, string>|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<int, array{admin_id:int,commission_amount:string,commission_rate:string,calc_base_amount:string}> $distributions
|
||||
* @param array<int, array{admin_id:int,commission_amount:string,commission_rate:string,calc_base_amount:string,handling_fee:string,net_commission_amount:string}> $distributions
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
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 . ' | 树形分红实发',
|
||||
|
||||
@@ -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`。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
| 渠道分红参数 | `/admin/channel` | `agent_mode`、返水/联营比例、结算周期等;详见 [渠道管理后台说明.md](./渠道管理后台说明.md) |
|
||||
| 代理树与分红比例 | `/admin/auth/admin` | 树形列表;配置上级代理、分红比例、渠道归属 |
|
||||
| 渠道筛选 | 管理员列表公共搜索 | 超管可按渠道筛选 |
|
||||
| 渠道列表可见范围 | `/admin/channel` | 账号或角色组绑定渠道 → 仅该渠道;均未绑定且 `viewAllChannels` → 全平台;超管不限 |
|
||||
| 渠道列表可见范围 | `/admin/channel` | 绑定渠道 → 仅该渠道;未绑定且有渠道模块权限 → 全平台;超管不限 |
|
||||
| 数据可见范围 | 管理员列表 | 非超管仅见 **本人 + 全部下级**,不见其他代理线 |
|
||||
| 结算执行 | `/admin/channel` 手动结算 / 定时任务 | **超管**或 `channel/manualSettle`(渠道可读);批量结算仍仅超管;结算即发放至 `admin_wallet` |
|
||||
|
||||
|
||||
@@ -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`;未绑定渠道且拥有渠道模块基础权限时读写全平台渠道 |
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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%.',
|
||||
|
||||
@@ -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': '已发放',
|
||||
|
||||
@@ -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%。',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
<el-radio-button label="enabled">{{ t('channel.settle_filter_enabled') }}</el-radio-button>
|
||||
<el-radio-button label="disabled">{{ t('channel.settle_filter_disabled') }}</el-radio-button>
|
||||
</el-radio-group>
|
||||
<el-button v-if="adminInfo.super && auth('batchSettlePending')" type="warning" @click="onBatchSettlePending">
|
||||
<el-button v-if="auth('batchSettlePending')" type="warning" @click="onBatchSettlePending">
|
||||
{{ t('channel.batch_settle_pending') }}
|
||||
</el-button>
|
||||
</div>
|
||||
@@ -53,17 +53,26 @@
|
||||
<PopupForm />
|
||||
|
||||
<el-dialog
|
||||
class="ba-operate-dialog manual-settle-dialog"
|
||||
class="manual-settle-dialog"
|
||||
:close-on-click-modal="false"
|
||||
:model-value="manualSettle.visible"
|
||||
width="860px"
|
||||
:width="manualSettleDialogWidth"
|
||||
align-center
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
@close="closeManualSettleDialog"
|
||||
@opened="dismissFloatingTooltips"
|
||||
>
|
||||
<template #header>
|
||||
<div class="title">{{ t('channel.manual_settle') }}</div>
|
||||
</template>
|
||||
<div v-loading="manualSettle.previewLoading" class="manual-settle-dialog-body">
|
||||
<el-form :model="manualSettle.form" label-width="140px" class="manual-settle-form">
|
||||
<el-form
|
||||
:model="manualSettle.form"
|
||||
:label-width="manualSettleFormLabelWidth"
|
||||
:label-position="manualSettleFormLabelPosition"
|
||||
class="manual-settle-form"
|
||||
>
|
||||
<el-form-item :label="t('channel.manual_settle_settlement_no')">
|
||||
<el-input v-model="manualSettle.form.settlement_no" readonly />
|
||||
</el-form-item>
|
||||
@@ -92,13 +101,97 @@
|
||||
<el-input v-model="manualSettle.form.commission_amount" readonly />
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('channel.share_config')" class="manual-settle-form-item-full">
|
||||
<el-table :data="manualSettle.form.commission_split" border size="small" class="w100" max-height="220">
|
||||
<el-table-column prop="admin_username" :label="t('channel.admin__username')" min-width="100" />
|
||||
<el-table-column prop="share_rate" :label="t('channel.share_rate_percent')" min-width="90">
|
||||
<template #default="scope">{{ scope.row.share_rate }}%</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="commission_amount" :label="t('channel.manual_settle_commission_amount')" min-width="110" />
|
||||
</el-table>
|
||||
<div class="manual-settle-split-block">
|
||||
<div
|
||||
class="manual-settle-split-table-scroll"
|
||||
:class="{ 'is-mobile': manualSettleViewportMobile }"
|
||||
>
|
||||
<div
|
||||
class="manual-settle-split-table-inner"
|
||||
:class="{ 'is-mobile': manualSettleViewportMobile }"
|
||||
:style="manualSettleSplitInnerStyle"
|
||||
>
|
||||
<el-table
|
||||
:data="manualSettleSplitTree"
|
||||
row-key="admin_id"
|
||||
:tree-props="{ children: 'children' }"
|
||||
default-expand-all
|
||||
border
|
||||
size="small"
|
||||
:fit="!manualSettleViewportMobile"
|
||||
class="manual-settle-split-table"
|
||||
:class="{ 'is-mobile': manualSettleViewportMobile, 'w100': !manualSettleViewportMobile }"
|
||||
:table-layout="manualSettleTableLayout"
|
||||
>
|
||||
<el-table-column
|
||||
prop="admin_username"
|
||||
:label="t('channel.admin__username')"
|
||||
:min-width="manualSettleViewportMobile ? undefined : manualSettleSplitCol.adminWidth"
|
||||
:width="manualSettleViewportMobile ? manualSettleSplitCol.adminWidth : undefined"
|
||||
:show-overflow-tooltip="!manualSettleViewportMobile"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="settlement_base_amount"
|
||||
:label="t('channel.manual_settle_col_base')"
|
||||
:width="manualSettleSplitCol.baseWidth"
|
||||
align="center"
|
||||
:formatter="formatSplitAmountCell"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="share_rate"
|
||||
:label="t('channel.manual_settle_col_share')"
|
||||
:width="manualSettleSplitCol.shareWidth"
|
||||
align="center"
|
||||
>
|
||||
<template #default="scope">{{ scope.row.share_rate }}%</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="commission_amount"
|
||||
:label="t('channel.manual_settle_col_gross')"
|
||||
:width="manualSettleSplitCol.grossWidth"
|
||||
align="center"
|
||||
:formatter="formatSplitAmountCell"
|
||||
/>
|
||||
<el-table-column
|
||||
prop="commission_share_percent"
|
||||
:label="t('channel.manual_settle_col_commission_share')"
|
||||
:width="manualSettleSplitCol.commissionShareWidth"
|
||||
align="center"
|
||||
>
|
||||
<template #default="scope">{{ formatCommissionSharePercent(scope.row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
prop="handling_fee"
|
||||
:label="t('channel.manual_settle_col_fee')"
|
||||
:width="manualSettleSplitCol.feeWidth"
|
||||
align="center"
|
||||
:formatter="formatSplitAmountCell"
|
||||
/>
|
||||
<el-table-column
|
||||
:label="t('channel.manual_settle_col_net')"
|
||||
:width="manualSettleSplitCol.netWidth"
|
||||
align="center"
|
||||
>
|
||||
<template #default="scope">{{ formatNetCommission(scope.row) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<p v-if="manualSettleViewportMobile" class="manual-settle-split-scroll-tip">
|
||||
{{ t('channel.manual_settle_split_scroll_tip') }}
|
||||
</p>
|
||||
</div>
|
||||
<el-alert
|
||||
class="manual-settle-calc-alert"
|
||||
:title="t('channel.manual_settle_calc_title')"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
>
|
||||
<ul class="manual-settle-calc-list">
|
||||
<li v-for="(line, idx) in manualSettleCalcDescLines" :key="idx">{{ line }}</li>
|
||||
</ul>
|
||||
</el-alert>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item :label="t('channel.manual_settle_remark')" class="manual-settle-form-item-full">
|
||||
<el-input v-model="manualSettle.form.remark" type="textarea" :rows="2" />
|
||||
@@ -328,13 +421,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, provide, reactive, ref, useTemplateRef } from 'vue'
|
||||
import { computed, nextTick, onMounted, onUnmounted, provide, reactive, ref, useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import PopupForm from './popupForm.vue'
|
||||
import { baTableApi } from '/@/api/common'
|
||||
import { auth } from '/@/utils/common'
|
||||
import { useAdminInfo } from '/@/stores/adminInfo'
|
||||
import { defaultOptButtons } from '/@/components/table'
|
||||
import TableHeader from '/@/components/table/header/index.vue'
|
||||
import Table from '/@/components/table/index.vue'
|
||||
@@ -346,7 +438,76 @@ defineOptions({
|
||||
})
|
||||
|
||||
const { t } = useI18n()
|
||||
const adminInfo = useAdminInfo()
|
||||
|
||||
const MANUAL_SETTLE_MOBILE_BREAKPOINT = 768
|
||||
const manualSettleViewportMobile = ref(typeof window !== 'undefined' ? window.innerWidth <= MANUAL_SETTLE_MOBILE_BREAKPOINT : false)
|
||||
const manualSettleDialogWidth = computed(() => (manualSettleViewportMobile.value ? '96%' : '860px'))
|
||||
const manualSettleFormLabelPosition = computed(() => (manualSettleViewportMobile.value ? 'top' : 'right'))
|
||||
const manualSettleFormLabelWidth = computed(() => (manualSettleViewportMobile.value ? 'auto' : '140px'))
|
||||
const manualSettleTableLayout = computed(() => 'fixed')
|
||||
const manualSettleSplitCol = computed(() => {
|
||||
if (manualSettleViewportMobile.value) {
|
||||
return {
|
||||
adminWidth: 120,
|
||||
baseWidth: 72,
|
||||
shareWidth: 58,
|
||||
grossWidth: 72,
|
||||
commissionShareWidth: 62,
|
||||
feeWidth: 64,
|
||||
netWidth: 72,
|
||||
}
|
||||
}
|
||||
return {
|
||||
adminWidth: 108,
|
||||
baseWidth: 76,
|
||||
shareWidth: 58,
|
||||
grossWidth: 72,
|
||||
commissionShareWidth: 62,
|
||||
feeWidth: 64,
|
||||
netWidth: 72,
|
||||
}
|
||||
})
|
||||
|
||||
const manualSettleSplitTableMinWidth = computed(() => {
|
||||
const col = manualSettleSplitCol.value
|
||||
return (
|
||||
col.adminWidth +
|
||||
col.baseWidth +
|
||||
col.shareWidth +
|
||||
col.grossWidth +
|
||||
col.commissionShareWidth +
|
||||
col.feeWidth +
|
||||
col.netWidth +
|
||||
32
|
||||
)
|
||||
})
|
||||
|
||||
const manualSettleSplitInnerStyle = computed(() => {
|
||||
if (!manualSettleViewportMobile.value) {
|
||||
return {}
|
||||
}
|
||||
return {
|
||||
width: `${manualSettleSplitTableMinWidth.value}px`,
|
||||
}
|
||||
})
|
||||
|
||||
const syncManualSettleViewport = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
manualSettleViewportMobile.value = window.innerWidth <= MANUAL_SETTLE_MOBILE_BREAKPOINT
|
||||
}
|
||||
|
||||
const dismissFloatingTooltips = () => {
|
||||
if (typeof document === 'undefined') {
|
||||
return
|
||||
}
|
||||
document.querySelectorAll('.el-popper[role="tooltip"]').forEach((node) => node.remove())
|
||||
const active = document.activeElement
|
||||
if (active instanceof HTMLElement) {
|
||||
active.blur()
|
||||
}
|
||||
}
|
||||
const tableRef = useTemplateRef('tableRef')
|
||||
let optButtons: OptButton[] = [
|
||||
{
|
||||
@@ -371,7 +532,7 @@ let optButtons: OptButton[] = [
|
||||
type: 'warning',
|
||||
icon: 'el-icon-Clock',
|
||||
class: 'table-row-manual-settle',
|
||||
disabledTip: false,
|
||||
disabledTip: true,
|
||||
display: () => auth('manualSettle'),
|
||||
click: (row: TableRow) => {
|
||||
void openManualSettleDialog(row)
|
||||
@@ -385,6 +546,33 @@ const formatAmountInt = (_row: any, _column: any, cellValue: number | string | n
|
||||
if (Number.isNaN(num)) return '-'
|
||||
return `${num}`
|
||||
}
|
||||
const formatCommissionSharePercent = (row: { commission_amount?: string | number; commission_share_percent?: string | number }) => {
|
||||
const preset = row.commission_share_percent
|
||||
if (preset !== null && preset !== undefined && preset !== '') {
|
||||
const n = Number(preset)
|
||||
if (Number.isFinite(n)) {
|
||||
return `${n.toFixed(2)}%`
|
||||
}
|
||||
}
|
||||
const total = Number(manualSettle.form.commission_amount ?? 0)
|
||||
const gross = Number(row.commission_amount ?? 0)
|
||||
if (!Number.isFinite(total) || total <= 0 || !Number.isFinite(gross) || gross <= 0) {
|
||||
return '0.00%'
|
||||
}
|
||||
return `${((gross / total) * 100).toFixed(2)}%`
|
||||
}
|
||||
|
||||
const formatSplitAmountCell = (_row: anyObj, _column: any, cellValue: unknown) => {
|
||||
if (cellValue === null || cellValue === undefined || cellValue === '') {
|
||||
return '-'
|
||||
}
|
||||
const n = Number(cellValue)
|
||||
if (!Number.isFinite(n)) {
|
||||
return String(cellValue)
|
||||
}
|
||||
return n.toFixed(2)
|
||||
}
|
||||
|
||||
const formatAmount2 = (_row: any, _column: any, cellValue: number | string | null) => {
|
||||
if (cellValue === null || cellValue === undefined || cellValue === '') return '-'
|
||||
const num = Number(cellValue)
|
||||
@@ -434,7 +622,21 @@ const manualSettle = reactive({
|
||||
commission_rate: '',
|
||||
calc_base_amount: '',
|
||||
commission_amount: '',
|
||||
commission_split: [] as Array<{ admin_id: number; admin_username: string; share_rate: string; commission_amount: string }>,
|
||||
agent_mode: 'turnover' as string,
|
||||
settlement_handling_fee: '0.00',
|
||||
commission_split: [] as Array<{
|
||||
admin_id: number
|
||||
admin_username: string
|
||||
parent_admin_id?: number
|
||||
level?: number
|
||||
settlement_base_amount?: string
|
||||
share_rate: string
|
||||
commission_amount: string
|
||||
commission_share_percent?: string
|
||||
handling_fee_rate: number | string
|
||||
handling_fee?: string
|
||||
net_commission_amount?: string
|
||||
}>,
|
||||
remark: '',
|
||||
},
|
||||
})
|
||||
@@ -496,18 +698,105 @@ const resetManualSettleForm = () => {
|
||||
manualSettle.form.calc_base_amount = ''
|
||||
manualSettle.form.commission_amount = ''
|
||||
manualSettle.form.commission_split = []
|
||||
manualSettle.form.agent_mode = 'turnover'
|
||||
manualSettle.form.settlement_handling_fee = '0.00'
|
||||
manualSettle.form.remark = ''
|
||||
}
|
||||
|
||||
type ManualSettleSplitRow = {
|
||||
admin_id: number
|
||||
admin_username: string
|
||||
parent_admin_id?: number
|
||||
level?: number
|
||||
settlement_base_amount?: string
|
||||
share_rate: string
|
||||
commission_amount: string
|
||||
commission_share_percent?: string
|
||||
handling_fee_rate: number | string
|
||||
handling_fee?: string
|
||||
net_commission_amount?: string
|
||||
children?: ManualSettleSplitRow[]
|
||||
}
|
||||
|
||||
const buildCommissionSplitTree = (flat: ManualSettleSplitRow[]): ManualSettleSplitRow[] => {
|
||||
if (!flat.length) {
|
||||
return []
|
||||
}
|
||||
const nodeMap = new Map<number, ManualSettleSplitRow>()
|
||||
for (const row of flat) {
|
||||
nodeMap.set(row.admin_id, { ...row, children: [] })
|
||||
}
|
||||
const roots: ManualSettleSplitRow[] = []
|
||||
for (const row of flat) {
|
||||
const node = nodeMap.get(row.admin_id)
|
||||
if (!node) {
|
||||
continue
|
||||
}
|
||||
const parentId = Number(row.parent_admin_id ?? 0)
|
||||
if (parentId > 0 && nodeMap.has(parentId)) {
|
||||
const parent = nodeMap.get(parentId)
|
||||
if (parent) {
|
||||
if (!parent.children) {
|
||||
parent.children = []
|
||||
}
|
||||
parent.children.push(node)
|
||||
}
|
||||
continue
|
||||
}
|
||||
roots.push(node)
|
||||
}
|
||||
const pruneEmptyChildren = (nodes: ManualSettleSplitRow[]) => {
|
||||
for (const node of nodes) {
|
||||
if (node.children && node.children.length > 0) {
|
||||
pruneEmptyChildren(node.children)
|
||||
} else {
|
||||
delete node.children
|
||||
}
|
||||
}
|
||||
}
|
||||
pruneEmptyChildren(roots)
|
||||
return roots
|
||||
}
|
||||
|
||||
const manualSettleSplitTree = computed(() => buildCommissionSplitTree(manualSettle.form.commission_split))
|
||||
|
||||
const manualSettleCalcDescLines = computed(() => {
|
||||
const mode = manualSettle.form.agent_mode === 'affiliate' ? 'affiliate' : 'turnover'
|
||||
const feeRate = manualSettle.form.settlement_handling_fee
|
||||
const prefix =
|
||||
mode === 'affiliate'
|
||||
? [
|
||||
t('channel.manual_settle_calc_intro_affiliate_1'),
|
||||
t('channel.manual_settle_calc_intro_affiliate_2'),
|
||||
t('channel.manual_settle_calc_intro_affiliate_3'),
|
||||
]
|
||||
: [
|
||||
t('channel.manual_settle_calc_intro_turnover_1'),
|
||||
t('channel.manual_settle_calc_intro_turnover_2'),
|
||||
t('channel.manual_settle_calc_intro_turnover_3'),
|
||||
]
|
||||
return [
|
||||
...prefix,
|
||||
t('channel.manual_settle_calc_tree_1'),
|
||||
t('channel.manual_settle_calc_tree_2'),
|
||||
t('channel.manual_settle_calc_tree_3'),
|
||||
t('channel.manual_settle_calc_handling_fee', { rate: feeRate }),
|
||||
]
|
||||
})
|
||||
|
||||
const closeManualSettleDialog = () => {
|
||||
manualSettle.visible = false
|
||||
resetManualSettleForm()
|
||||
}
|
||||
|
||||
const openManualSettleDialog = async (row: TableRow) => {
|
||||
dismissFloatingTooltips()
|
||||
syncManualSettleViewport()
|
||||
manualSettle.channelId = row.id
|
||||
resetManualSettleForm()
|
||||
manualSettle.visible = true
|
||||
await nextTick()
|
||||
dismissFloatingTooltips()
|
||||
manualSettle.previewLoading = true
|
||||
try {
|
||||
const res = await createAxios(
|
||||
@@ -532,7 +821,9 @@ const openManualSettleDialog = async (row: TableRow) => {
|
||||
manualSettle.form.commission_rate = d.commission_rate ?? ''
|
||||
manualSettle.form.calc_base_amount = d.calc_base_amount ?? ''
|
||||
manualSettle.form.commission_amount = d.commission_amount ?? ''
|
||||
manualSettle.form.commission_split = Array.isArray(d.commission_split) ? d.commission_split : []
|
||||
manualSettle.form.agent_mode = d.agent_mode ?? 'turnover'
|
||||
manualSettle.form.settlement_handling_fee = d.settlement_handling_fee ?? '0.00'
|
||||
manualSettle.form.commission_split = normalizeManualSettleSplit(d.commission_split, d.settlement_handling_fee)
|
||||
manualSettle.form.remark = `${t('channel.manual_settle')}-CH${row.id}`
|
||||
} catch {
|
||||
manualSettle.visible = false
|
||||
@@ -541,6 +832,64 @@ const openManualSettleDialog = async (row: TableRow) => {
|
||||
}
|
||||
}
|
||||
|
||||
const calcHandlingFeeByPercent = (gross: number, ratePercent: number) => {
|
||||
const g = Number.isFinite(gross) && gross > 0 ? gross : 0
|
||||
let rate = Number.isFinite(ratePercent) ? ratePercent : 0
|
||||
if (rate < 0) rate = 0
|
||||
if (rate > 100) rate = 100
|
||||
return Math.round(g * rate * 100) / 10000
|
||||
}
|
||||
|
||||
const applyManualSettleRowAmounts = (row: {
|
||||
commission_amount?: string | number
|
||||
handling_fee_rate?: string | number
|
||||
handling_fee?: string | number
|
||||
net_commission_amount?: string
|
||||
}) => {
|
||||
const gross = Number(row.commission_amount ?? 0)
|
||||
const rate = Number(row.handling_fee_rate ?? 0)
|
||||
const feeAmount = calcHandlingFeeByPercent(gross, rate)
|
||||
row.handling_fee = feeAmount.toFixed(2)
|
||||
row.net_commission_amount = Math.max(0, (Number.isFinite(gross) ? gross : 0) - feeAmount).toFixed(2)
|
||||
}
|
||||
|
||||
const normalizeManualSettleSplit = (rows: unknown, defaultFeeRate: unknown) => {
|
||||
const feeDefault = Number(defaultFeeRate)
|
||||
const baseRate = Number.isFinite(feeDefault) && feeDefault >= 0 ? Math.min(100, feeDefault) : 0
|
||||
if (!Array.isArray(rows)) {
|
||||
return []
|
||||
}
|
||||
return rows.map((row: anyObj) => {
|
||||
let rate = Number(row.handling_fee_rate ?? row.handling_fee ?? baseRate)
|
||||
if (!Number.isFinite(rate) || rate < 0) {
|
||||
rate = baseRate
|
||||
}
|
||||
if (rate > 100) {
|
||||
rate = 100
|
||||
}
|
||||
const normalized = {
|
||||
...row,
|
||||
handling_fee_rate: rate,
|
||||
}
|
||||
applyManualSettleRowAmounts(normalized)
|
||||
return normalized
|
||||
})
|
||||
}
|
||||
|
||||
const formatNetCommission = (row: {
|
||||
commission_amount?: string | number
|
||||
handling_fee_rate?: string | number
|
||||
net_commission_amount?: string
|
||||
}) => {
|
||||
if (row.net_commission_amount !== undefined && row.net_commission_amount !== '') {
|
||||
return row.net_commission_amount
|
||||
}
|
||||
const gross = Number(row.commission_amount ?? 0)
|
||||
const rate = Number(row.handling_fee_rate ?? 0)
|
||||
const net = Math.max(0, (Number.isFinite(gross) ? gross : 0) - calcHandlingFeeByPercent(gross, rate))
|
||||
return net.toFixed(2)
|
||||
}
|
||||
|
||||
const submitManualSettle = async () => {
|
||||
if (!manualSettle.channelId) return
|
||||
manualSettle.loading = true
|
||||
@@ -552,6 +901,10 @@ const submitManualSettle = async () => {
|
||||
data: {
|
||||
id: manualSettle.channelId,
|
||||
remark: manualSettle.form.remark,
|
||||
commission_split: manualSettle.form.commission_split.map((row) => ({
|
||||
admin_id: row.admin_id,
|
||||
handling_fee_rate: row.handling_fee_rate ?? 0,
|
||||
})),
|
||||
},
|
||||
},
|
||||
{ showSuccessMessage: true }
|
||||
@@ -944,6 +1297,7 @@ const baTable = new baTableClass(
|
||||
defaultItems: {
|
||||
status: '1',
|
||||
agent_mode: 'turnover',
|
||||
settlement_handling_fee: 0,
|
||||
settle_cycle: 'weekly',
|
||||
settle_weekday: 1,
|
||||
settle_monthday: 1,
|
||||
@@ -976,6 +1330,8 @@ baTable.after.getData = () => {
|
||||
provide('baTable', baTable)
|
||||
|
||||
onMounted(() => {
|
||||
syncManualSettleViewport()
|
||||
window.addEventListener('resize', syncManualSettleViewport)
|
||||
baTable.table.ref = tableRef.value
|
||||
baTable.mount()
|
||||
baTable.getData()?.then(() => {
|
||||
@@ -984,6 +1340,10 @@ onMounted(() => {
|
||||
})
|
||||
void loadSettleStats()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', syncManualSettleViewport)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -1119,16 +1479,59 @@ onMounted(() => {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* 手动结算弹窗:可滚动(勿使用 ba-operate-dialog 固定 58vh 高度) */
|
||||
:deep(.manual-settle-dialog.el-dialog) {
|
||||
display: flex !important;
|
||||
flex-direction: column;
|
||||
width: 92% !important;
|
||||
max-width: 860px;
|
||||
max-height: 92vh;
|
||||
margin: 4vh auto !important;
|
||||
padding-bottom: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.manual-settle-dialog .el-dialog__header) {
|
||||
flex-shrink: 0;
|
||||
padding: 12px 16px;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
:deep(.manual-settle-dialog .el-dialog__body) {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
height: auto !important;
|
||||
max-height: calc(92vh - 120px);
|
||||
overflow-y: auto !important;
|
||||
overflow-x: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior: contain;
|
||||
padding: 12px 16px 16px;
|
||||
}
|
||||
|
||||
:deep(.manual-settle-dialog .el-dialog__footer) {
|
||||
flex-shrink: 0;
|
||||
position: static;
|
||||
width: auto;
|
||||
box-shadow: none;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.manual-settle-dialog-body {
|
||||
max-height: min(70vh, 680px);
|
||||
overflow: auto;
|
||||
padding-right: 2px;
|
||||
min-height: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
:deep(.manual-settle-dialog-body .el-loading-mask) {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.manual-settle-form {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
column-gap: 16px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.manual-settle-form :deep(.el-form-item) {
|
||||
@@ -1137,6 +1540,127 @@ onMounted(() => {
|
||||
|
||||
.manual-settle-form-item-full {
|
||||
grid-column: 1 / -1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.manual-settle-form-item-full :deep(.el-form-item__content) {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.manual-settle-split-block {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.manual-settle-split-table-scroll {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.manual-settle-split-table-scroll.is-mobile {
|
||||
overflow-x: scroll;
|
||||
overflow-y: hidden;
|
||||
overscroll-behavior-x: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
touch-action: pan-x;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 6px;
|
||||
background: var(--el-fill-color-blank);
|
||||
}
|
||||
|
||||
.manual-settle-split-table-inner.is-mobile {
|
||||
display: inline-block;
|
||||
max-width: none;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.manual-settle-split-table-scroll.is-mobile :deep(.el-table) {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.manual-settle-split-table-scroll.is-mobile :deep(.el-table__inner-wrapper) {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.manual-settle-split-table-scroll.is-mobile :deep(.el-table__body-wrapper),
|
||||
.manual-settle-split-table-scroll.is-mobile :deep(.el-table__header-wrapper) {
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.manual-settle-split-table-scroll.is-mobile :deep(.el-scrollbar__wrap) {
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.manual-settle-split-table-scroll.is-mobile :deep(.el-scrollbar__bar.is-horizontal) {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.manual-settle-split-scroll-tip {
|
||||
margin: 6px 0 0;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
color: var(--el-text-color-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.manual-settle-split-table:not(.is-mobile) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.manual-settle-split-table :deep(.el-table__header th.el-table__cell),
|
||||
.manual-settle-split-table :deep(.el-table__body td.el-table__cell) {
|
||||
padding: 6px 4px;
|
||||
}
|
||||
|
||||
.manual-settle-split-table :deep(.el-table__header .cell),
|
||||
.manual-settle-split-table :deep(.el-table__body .cell) {
|
||||
padding: 0 2px;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.manual-settle-split-table :deep(.el-table__header .cell) {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.manual-settle-split-table.is-mobile :deep(.el-table__header th.el-table__cell),
|
||||
.manual-settle-split-table.is-mobile :deep(.el-table__body td.el-table__cell) {
|
||||
padding: 5px 3px;
|
||||
}
|
||||
|
||||
.manual-settle-split-table.is-mobile :deep(.el-table__header .cell),
|
||||
.manual-settle-split-table.is-mobile :deep(.el-table__body .cell) {
|
||||
font-size: 11px;
|
||||
line-height: 1.3;
|
||||
white-space: nowrap;
|
||||
word-break: keep-all;
|
||||
overflow-wrap: normal;
|
||||
}
|
||||
|
||||
.manual-settle-split-table.is-mobile :deep(.el-table__body td:first-child .cell) {
|
||||
white-space: nowrap;
|
||||
word-break: keep-all;
|
||||
}
|
||||
|
||||
.manual-settle-calc-alert {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.manual-settle-calc-list {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
line-height: 1.6;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.manual-settle-calc-list li + li {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.manual-settle-footer {
|
||||
@@ -1207,6 +1731,82 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
:deep(.manual-settle-dialog.el-dialog) {
|
||||
width: 96% !important;
|
||||
max-width: none;
|
||||
max-height: 94vh;
|
||||
margin: 3vh auto !important;
|
||||
}
|
||||
|
||||
:deep(.manual-settle-dialog .el-dialog__header) {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
:deep(.manual-settle-dialog .el-dialog__body) {
|
||||
max-height: calc(94vh - 96px);
|
||||
padding: 10px 10px 12px;
|
||||
}
|
||||
|
||||
:deep(.manual-settle-dialog .el-dialog__footer) {
|
||||
padding: 8px 10px 10px;
|
||||
}
|
||||
|
||||
.manual-settle-form :deep(.el-form-item) {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.manual-settle-form :deep(.el-form-item__label) {
|
||||
padding-bottom: 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.manual-settle-form-item-full :deep(.el-form-item__content) {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.manual-settle-split-block {
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.manual-settle-split-table-scroll.is-mobile {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.manual-settle-calc-alert {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.manual-settle-calc-alert :deep(.el-alert__title) {
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.manual-settle-calc-alert :deep(.el-alert__content) {
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.manual-settle-calc-list {
|
||||
font-size: 12px;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
.manual-settle-footer {
|
||||
flex-wrap: wrap;
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.manual-settle-footer .el-button {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:deep(.channel-record-dialog.el-dialog) {
|
||||
width: 96% !important;
|
||||
max-height: 94vh;
|
||||
|
||||
@@ -69,6 +69,14 @@
|
||||
:input-attr="{ step: 0.01, precision: 2, min: 0, max: 100 }"
|
||||
:placeholder="`${t('Please input field', { field: t('channel.turnover_share_rate') })} (例如 30.5)`"
|
||||
/>
|
||||
<FormItem
|
||||
:label="t('channel.settlement_handling_fee')"
|
||||
type="number"
|
||||
v-model="baTable.form.items!.settlement_handling_fee"
|
||||
prop="settlement_handling_fee"
|
||||
:input-attr="{ step: 0.01, precision: 2, min: 0, max: 100 }"
|
||||
:placeholder="t('channel.settlement_handling_fee_tip')"
|
||||
/>
|
||||
<FormItem
|
||||
v-if="currentAgentMode === 'affiliate'"
|
||||
:label="t('channel.affiliate_contract_no')"
|
||||
|
||||
Reference in New Issue
Block a user