@@ -13,6 +13,11 @@ use Webman\Http\Request as WebmanRequest;
*/
class Channel extends Backend
{
/**
* 预览接口与手动结算共用「手动结算」按钮权限(避免额外菜单节点)
*/
protected array $noNeedPermission = [ 'manualSettlePreview' ];
/**
* Channel模型对象
* @var object|null
@@ -137,6 +142,10 @@ class Channel extends Backend
$data = $this -> applyInputFilter ( $data );
$data = $this -> excludeFields ( $data );
$data = $this -> normalizeAgentModeFields ( $data );
$bizErr = $this -> validateAndNormalizeBusinessFields ( $data );
if ( $bizErr !== null ) {
return $this -> error ( $bizErr );
}
unset ( $data [ 'invite_code' ]);
$adminId = $data [ 'admin_id' ] ? ? null ;
@@ -160,7 +169,6 @@ class Channel extends Backend
}
$data [ 'admin_group_id' ] = $topGroupId ;
if ( ! $this -> auth -> isSuperAdmin ()) {
$data [ 'top_admin_id' ] = $this -> auth -> id ;
$data [ 'admin_id' ] = $this -> auth -> id ;
}
@@ -226,6 +234,10 @@ class Channel extends Backend
$data = $this -> applyInputFilter ( $data );
$data = $this -> excludeFields ( $data );
$data = $this -> normalizeAgentModeFields ( $data );
$bizErr = $this -> validateAndNormalizeBusinessFields ( $data );
if ( $bizErr !== null ) {
return $this -> error ( $bizErr );
}
unset ( $data [ 'invite_code' ]);
if ( array_key_exists ( 'admin_group_id' , $data )) {
@@ -247,7 +259,6 @@ class Channel extends Backend
$data [ 'admin_group_id' ] = $topGroupId ;
}
if ( ! $this -> auth -> isSuperAdmin ()) {
$data [ 'top_admin_id' ] = $this -> auth -> id ;
$data [ 'admin_id' ] = $this -> auth -> id ;
}
@@ -312,6 +323,353 @@ class Channel extends Backend
]);
}
/**
* 手动结算预览:区间=上次结算周期结束~当前时间;金额来自已结算注单汇总(服务端计算)
*/
public function manualSettlePreview ( WebmanRequest $request ) : Response
{
$response = $this -> initializeBackend ( $request );
if ( $response !== null ) {
return $response ;
}
if ( ! $this -> auth -> check ( 'channel/manualSettle' )) {
return $this -> error ( __ ( 'You have no permission' ));
}
$id = ( int ) ( $request -> get ( 'id' , 0 ));
if ( $id <= 0 ) {
return $this -> error ( __ ( 'Parameter error' ));
}
$row = $this -> model -> find ( $id );
if ( ! $row ) {
return $this -> error ( __ ( 'Record not found' ));
}
if ( ! $this -> auth -> isSuperAdmin () && ! in_array (( int ) $row [ 'id' ], $this -> currentChannelIds , true )) {
return $this -> error ( __ ( 'You have no permission' ));
}
$payload = $this -> buildManualSettlePayload ( $row -> toArray ());
if ( is_string ( $payload )) {
return $this -> error ( $payload );
}
return $this -> success ( '' , $payload );
}
/**
* 手动结算(渠道维度):仅接收备注;周期与金额全部由服务端按注单汇总计算,并写入结算周期与佣金记录
*/
public function manualSettle ( WebmanRequest $request ) : Response
{
$response = $this -> initializeBackend ( $request );
if ( $response !== null ) {
return $response ;
}
$id = ( int ) ( $request -> post ( 'id' , $request -> get ( 'id' , 0 )));
if ( $id <= 0 ) {
return $this -> error ( __ ( 'Parameter error' ));
}
$row = $this -> model -> find ( $id );
if ( ! $row ) {
return $this -> error ( __ ( 'Record not found' ));
}
if ( ! $this -> auth -> isSuperAdmin () && ! in_array (( int ) $row [ 'id' ], $this -> currentChannelIds , true )) {
return $this -> error ( __ ( 'You have no permission' ));
}
$remark = ( string ) $request -> post ( 'remark' , '' );
$payload = $this -> buildManualSettlePayload ( $row -> toArray ());
if ( is_string ( $payload )) {
return $this -> error ( $payload );
}
$settlementNo = $payload [ 'settlement_no' ];
if ( Db :: name ( 'agent_settlement_period' ) -> where ( 'settlement_no' , $settlementNo ) -> value ( 'id' )) {
return $this -> error ( '结算单号已存在,请稍后重试' );
}
$adminId = $row [ 'admin_id' ] ? ? null ;
if ( $adminId === null || $adminId === '' || ( int ) $adminId <= 0 ) {
return $this -> error ( '渠道未绑定代理管理员,无法生成佣金记录' );
}
$now = time ();
Db :: startTrans ();
try {
$periodId = ( int ) Db :: name ( 'agent_settlement_period' ) -> insertGetId ([
'settlement_no' => $settlementNo ,
'period_start_at' => $payload [ 'period_start_ts' ],
'period_end_at' => $payload [ 'period_end_ts' ],
'total_bet_amount' => $payload [ 'total_bet_amount' ],
'total_payout_amount' => $payload [ 'total_payout_amount' ],
'platform_profit_amount' => $payload [ 'platform_profit_amount' ],
'status' => 2 ,
'remark' => trim ( $remark ) !== '' ? $remark : ( '手动结算-渠道#' . $row [ 'id' ] . '-' . $row [ 'name' ]),
'create_time' => $now ,
'update_time' => $now ,
]);
Db :: name ( 'agent_commission_record' ) -> insert ([
'settlement_period_id' => $periodId ,
'channel_id' => ( int ) $row [ 'id' ],
'admin_id' => ( int ) $adminId ,
'commission_rate' => $payload [ 'commission_rate' ],
'calc_base_amount' => $payload [ 'calc_base_amount' ],
'commission_amount' => $payload [ 'commission_amount' ],
'status' => 0 ,
'settled_at' => null ,
'remark' => trim ( $remark ) !== '' ? $remark : ( '手动结算佣金-CH' . $row [ 'id' ]),
'create_time' => $now ,
'update_time' => $now ,
]);
Db :: name ( 'channel' ) -> where ( 'id' , $row [ 'id' ]) -> update ([
'update_time' => $now ,
]);
Db :: commit ();
} catch ( Throwable $e ) {
Db :: rollback ();
return $this -> error ( $e -> getMessage ());
}
return $this -> success ( '手动结算已完成,已生成结算周期与佣金记录' );
}
/**
* @return array|string 成功返回预览数据数组,失败返回错误文案
*/
private function buildManualSettlePayload ( array $row ) : array | string
{
$channelId = ( int ) ( $row [ 'id' ] ? ? 0 );
if ( $channelId <= 0 ) {
return '渠道数据异常' ;
}
$endTs = time ();
$lastEnd = $this -> getLastSettlementEndForChannel ( $channelId );
$channelCreateTs = ( int ) ( $row [ 'create_time' ] ? ? 0 );
if ( $lastEnd === null ) {
$periodStartTs = $channelCreateTs > 0 ? $channelCreateTs : 0 ;
} else {
$periodStartTs = ( int ) $lastEnd ;
}
if ( $periodStartTs >= $endTs ) {
return '结算区间无效(开始时间不早于当前)' ;
}
$stats = $this -> aggregateBetOrderForChannel ( $channelId , $periodStartTs , $lastEnd !== null , $endTs );
$totalBet = $stats [ 'total_bet' ];
$totalPayout = $stats [ 'total_payout' ];
$profit = bcsub ( $totalBet , $totalPayout , 4 );
$mode = ( string ) ( $row [ 'agent_mode' ] ? ? 'turnover' );
$commission = $this -> computeCommissionAmounts ( $row , $totalBet , $profit , $mode );
if ( is_string ( $commission )) {
return $commission ;
}
$settlementNo = $this -> generateAgentSettlementNo ( 'M' , $channelId , $endTs );
return [
'settlement_no' => $settlementNo ,
'period_start_ts' => $periodStartTs ,
'period_end_ts' => $endTs ,
'period_start_at' => date ( 'Y-m-d H:i:s' , $periodStartTs ),
'period_end_at' => date ( 'Y-m-d H:i:s' , $endTs ),
'total_bet_amount' => $totalBet ,
'total_payout_amount' => $totalPayout ,
'platform_profit_amount' => $profit ,
'commission_rate' => $commission [ 'commission_rate' ],
'calc_base_amount' => $commission [ 'calc_base_amount' ],
'commission_amount' => $commission [ 'commission_amount' ],
'agent_mode' => $mode ,
];
}
/**
* 生成代理结算周期单号:仅大写字母与数字、无分隔符;首字符 M=手动结算, A=自动结算(定时任务等复用)
*/
private function generateAgentSettlementNo ( string $sourceFlag , int $channelId , int $endTs ) : string
{
$flag = strtoupper ( trim ( $sourceFlag ));
if ( $flag !== 'M' && $flag !== 'A' ) {
$flag = 'M' ;
}
$channelPart = str_pad (( string ) max ( 0 , $channelId ), 6 , '0' , STR_PAD_LEFT );
$timePart = str_pad (( string ) max ( 0 , $endTs ), 10 , '0' , STR_PAD_LEFT );
$base = $flag . $channelPart . $timePart ;
for ( $i = 0 ; $i < 8 ; $i ++ ) {
$randPart = strtoupper ( substr ( bin2hex ( random_bytes ( 4 )), 0 , 8 ));
$no = $base . $randPart ;
if ( ! Db :: name ( 'agent_settlement_period' ) -> where ( 'settlement_no' , $no ) -> value ( 'id' )) {
return $no ;
}
}
return $base . strtoupper ( substr ( bin2hex ( random_bytes ( 8 )), 0 , 16 ));
}
private function getLastSettlementEndForChannel ( int $channelId ) : ? int
{
$row = Db :: name ( 'agent_commission_record' )
-> alias ( 'acr' )
-> join ( 'agent_settlement_period asp' , 'acr.settlement_period_id = asp.id' )
-> where ( 'acr.channel_id' , $channelId )
-> field ( 'MAX(asp.period_end_at) AS m' )
-> find ();
if ( ! $row ) {
return null ;
}
$m = $row [ 'm' ] ? ? null ;
if ( $m === null || $m === '' ) {
return null ;
}
return ( int ) $m ;
}
/**
* @return array{total_bet:string,total_payout:string}
*/
private function aggregateBetOrderForChannel ( int $channelId , int $periodStartTs , bool $hasPriorSettlement , int $endTs ) : array
{
$query = Db :: name ( 'bet_order' )
-> where ( 'channel_id' , $channelId )
-> where ( 'status' , 2 )
-> where ( 'create_time' , '<=' , $endTs );
if ( $hasPriorSettlement ) {
$query -> where ( 'create_time' , '>' , $periodStartTs );
} else {
$query -> where ( 'create_time' , '>=' , $periodStartTs );
}
$row = $query -> field ( 'SUM(total_amount) AS tb, SUM(win_amount) AS tw, SUM(jackpot_extra_amount) AS tj' ) -> find ();
$tb = $row && $row [ 'tb' ] !== null && $row [ 'tb' ] !== '' ? ( string ) $row [ 'tb' ] : '0.0000' ;
$tw = $row && $row [ 'tw' ] !== null && $row [ 'tw' ] !== '' ? ( string ) $row [ 'tw' ] : '0.0000' ;
$tj = $row && $row [ 'tj' ] !== null && $row [ 'tj' ] !== '' ? ( string ) $row [ 'tj' ] : '0.0000' ;
$totalPayout = bcadd ( $tw , $tj , 4 );
return [
'total_bet' => number_format (( float ) $tb , 4 , '.' , '' ),
'total_payout' => number_format (( float ) $totalPayout , 4 , '.' , '' ),
];
}
/**
* @return array{commission_rate:string,calc_base_amount:string,commission_amount:string}|string
*/
private function computeCommissionAmounts ( array $row , string $totalBet , string $platformProfit , string $mode ) : array | string
{
if ( $mode === 'turnover' ) {
$ratePercent = $row [ 'turnover_share_rate' ] ? ? null ;
if ( $ratePercent === null || $ratePercent === '' ) {
return '普通返水代理未配置返水分红比例' ;
}
$rateDec = bcdiv (( string ) $ratePercent , '100' , 6 );
$amount = bcmul ( $totalBet , $rateDec , 4 );
return [
'commission_rate' => $rateDec ,
'calc_base_amount' => $totalBet ,
'commission_amount' => $amount ,
];
}
if ( $mode === 'affiliate' ) {
$fee = $row [ 'affiliate_fee_rate' ] ? ? null ;
$rulesRaw = $row [ 'affiliate_ladder_rules' ] ? ? null ;
if ( $fee === null || $fee === '' ) {
return '联营代理未配置成本扣除比例' ;
}
$rules = $this -> normalizeLadderRulesForSettlement ( $rulesRaw );
if ( $rules === []) {
return '联营阶梯规则无效或为空' ;
}
if ( bccomp ( $platformProfit , '0' , 4 ) <= 0 ) {
return [
'commission_rate' => '0.000000' ,
'calc_base_amount' => '0.0000' ,
'commission_amount' => '0.0000' ,
];
}
$afterFee = bcmul ( $platformProfit , bcsub ( '1' , ( string ) $fee , 8 ), 4 );
if ( bccomp ( $afterFee , '0' , 4 ) <= 0 ) {
return [
'commission_rate' => '0.000000' ,
'calc_base_amount' => '0.0000' ,
'commission_amount' => '0.0000' ,
];
}
$playerLoss = $platformProfit ;
$share = $this -> pickAffiliateShareRateFromLadder ( $rules , $playerLoss );
$rateDec = number_format ( $share , 6 , '.' , '' );
$amount = bcmul ( $afterFee , $rateDec , 4 );
return [
'commission_rate' => $rateDec ,
'calc_base_amount' => $afterFee ,
'commission_amount' => $amount ,
];
}
return '未知的代理模式' ;
}
/**
* @return array<int, array{minLoss: string, shareRate: string}>
*/
private function normalizeLadderRulesForSettlement ( mixed $rulesRaw ) : array
{
if ( $rulesRaw === null || $rulesRaw === '' ) {
return [];
}
if ( is_string ( $rulesRaw )) {
$decoded = json_decode ( $rulesRaw , true );
$rulesRaw = is_array ( $decoded ) ? $decoded : [];
}
if ( ! is_array ( $rulesRaw )) {
return [];
}
$out = [];
foreach ( $rulesRaw as $rule ) {
if ( ! is_array ( $rule )) {
continue ;
}
$minLoss = $rule [ 'minLoss' ] ? ? ( $rule [ 'min_loss' ] ? ? null );
$shareRate = $rule [ 'shareRate' ] ? ? ( $rule [ 'share_rate' ] ? ? null );
if ( $minLoss === null || $shareRate === null || ! is_numeric (( string ) $minLoss ) || ! is_numeric (( string ) $shareRate )) {
continue ;
}
$out [] = [
'minLoss' => number_format (( float ) $minLoss , 4 , '.' , '' ),
'shareRate' => number_format (( float ) $shareRate , 6 , '.' , '' ),
];
}
usort ( $out , function ( $a , $b ) {
return bccomp ( $a [ 'minLoss' ], $b [ 'minLoss' ], 4 );
});
return $out ;
}
/**
* @param array<int, array{minLoss: string, shareRate: string}> $rules
*/
private function pickAffiliateShareRateFromLadder ( array $rules , string $playerLoss ) : float
{
$chosen = ( float ) $rules [ 0 ][ 'shareRate' ];
foreach ( $rules as $rule ) {
if ( bccomp ( $playerLoss , $rule [ 'minLoss' ], 4 ) >= 0 ) {
$chosen = ( float ) $rule [ 'shareRate' ];
}
}
return $chosen ;
}
private function getCurrentChannelIds () : array
{
if ( $this -> auth -> isSuperAdmin ()) {
@@ -325,18 +683,31 @@ class Channel extends Backend
if ( $admin && ! empty ( $admin [ 'channel_id' ])) {
$ids [] = $admin [ 'channel_id' ];
}
$owned = Db :: name ( 'channel' ) -> where ( 'top_ admin_id' , $this -> auth -> id ) -> column ( 'id' );
$created = Db :: name ( 'channel' ) -> wh ere( 'admin_ id' , $this -> auth -> id ) -> column ( 'id' );
return array_values ( array_unique ( array_merge ( $ids , $owned , $created )));
$byAdmin = Db :: name ( 'channel' ) -> where ( 'admin_id' , $this -> auth -> id ) -> column ( 'id' );
return array_values ( array_unique ( array_m erg e( $ ids , $byAdmin )) );
}
private function normalizeAgentModeFields ( array $data ) : array
{
$mode = $data [ 'agent_mode' ] ? ? null ;
if ( empty ( $data [ 'settle_cycle' ])) {
$data [ 'settle_cycle' ] = 'weekly' ;
}
if ( empty ( $data [ 'settle_weekday' ])) {
$data [ 'settle_weekday' ] = 1 ;
}
if ( empty ( $data [ 'settle_time' ])) {
$data [ 'settle_time' ] = '02:00:00' ;
}
if ( $mode === 'turnover' ) {
$data [ 'affiliate_share_rate' ] = null ;
$data [ 'affiliate_fee_rate' ] = null ;
$data [ 'carryover_balance' ] = 0 ;
$data [ 'affiliate_contract_no' ] = null ;
$data [ 'affiliate_contract_name' ] = null ;
$data [ 'affiliate_ladder_rules' ] = null ;
$data [ 'affiliate_effective_start_at' ] = null ;
$data [ 'affiliate_effective_end_at' ] = null ;
return $data ;
}
if ( $mode === 'affiliate' ) {
@@ -344,4 +715,123 @@ class Channel extends Backend
}
return $data ;
}
private function validateAndNormalizeBusinessFields ( array & $data ) : ? string
{
$cycle = isset ( $data [ 'settle_cycle' ]) ? trim (( string ) $data [ 'settle_cycle' ]) : 'weekly' ;
if ( ! in_array ( $cycle , [ 'daily' , 'weekly' , 'monthly' ], true )) {
return '结算周期不合法' ;
}
$data [ 'settle_cycle' ] = $cycle ;
$settleTime = isset ( $data [ 'settle_time' ]) ? trim (( string ) $data [ 'settle_time' ]) : '02:00:00' ;
if ( ! preg_match ( '/^\d{2}:\d{2}:\d{2}$/' , $settleTime )) {
return '结算时间格式不正确( HH:mm:ss) ' ;
}
$data [ 'settle_time' ] = $settleTime ;
if ( $cycle === 'weekly' ) {
$weekday = isset ( $data [ 'settle_weekday' ]) ? ( int ) $data [ 'settle_weekday' ] : 1 ;
if ( $weekday < 1 || $weekday > 7 ) {
return '周结必须选择周一到周日' ;
}
$data [ 'settle_weekday' ] = $weekday ;
} else {
$data [ 'settle_weekday' ] = 1 ;
}
if ( $cycle === 'monthly' ) {
$monthday = isset ( $data [ 'settle_monthday' ]) ? ( int ) $data [ 'settle_monthday' ] : 1 ;
if ( $monthday < 1 || $monthday > 31 ) {
return '月结日期必须在1到31之间' ;
}
$data [ 'settle_monthday' ] = $monthday ;
} else {
$data [ 'settle_monthday' ] = 1 ;
}
$mode = isset ( $data [ 'agent_mode' ]) ? ( string ) $data [ 'agent_mode' ] : '' ;
if ( $mode === 'turnover' ) {
if ( isset ( $data [ 'turnover_share_rate' ]) && $data [ 'turnover_share_rate' ] !== '' && $data [ 'turnover_share_rate' ] !== null ) {
$num = ( float ) $data [ 'turnover_share_rate' ];
if ( $num < 0 || $num > 100 ) {
return '返水分红比例必须在0到100之间' ;
}
}
return null ;
}
if ( $mode === 'affiliate' ) {
foreach ([ 'affiliate_share_rate' => '联营占成比例' , 'affiliate_fee_rate' => '联营成本扣除比例' ] as $field => $label ) {
if ( ! isset ( $data [ $field ]) || $data [ $field ] === '' || $data [ $field ] === null ) {
return $label . '不能为空' ;
}
$num = ( float ) $data [ $field ];
if ( $num < 0 || $num > 1 ) {
return $label . '必须在0到1之间' ;
}
}
$ladderErr = $this -> validateLadderRulesField ( $data );
if ( $ladderErr !== null ) {
return $ladderErr ;
}
}
return null ;
}
private function validateLadderRulesField ( array & $data ) : ? string
{
$rulesRaw = $data [ 'affiliate_ladder_rules' ] ? ? null ;
if ( $rulesRaw === null || $rulesRaw === '' ) {
return '联营阶梯规则不能为空' ;
}
if ( is_string ( $rulesRaw )) {
$decoded = json_decode ( $rulesRaw , true );
if ( ! is_array ( $decoded )) {
return '联营阶梯规则必须是有效JSON数组' ;
}
$rulesRaw = $decoded ;
}
if ( ! is_array ( $rulesRaw ) || $rulesRaw === []) {
return '联营阶梯规则至少需要一条' ;
}
$normalized = [];
$prevMinLoss = null ;
foreach ( $rulesRaw as $idx => $rule ) {
if ( ! is_array ( $rule )) {
return '联营阶梯规则第' . ( $idx + 1 ) . '行格式错误' ;
}
$minLoss = $rule [ 'minLoss' ] ? ? ( $rule [ 'min_loss' ] ? ? null );
$shareRate = $rule [ 'shareRate' ] ? ? ( $rule [ 'share_rate' ] ? ? null );
if ( $minLoss === null || $minLoss === '' || ! is_numeric (( string ) $minLoss )) {
return '联营阶梯规则第' . ( $idx + 1 ) . '行起始客损格式错误' ;
}
if ( $shareRate === null || $shareRate === '' || ! is_numeric (( string ) $shareRate )) {
return '联营阶梯规则第' . ( $idx + 1 ) . '行占成比例格式错误' ;
}
$minLossNum = ( float ) $minLoss ;
$shareRateNum = ( float ) $shareRate ;
if ( $minLossNum < 0 ) {
return '联营阶梯规则第' . ( $idx + 1 ) . '行起始客损不能为负' ;
}
if ( $shareRateNum < 0 || $shareRateNum > 1 ) {
return '联营阶梯规则第' . ( $idx + 1 ) . '行占成比例必须在0到1之间' ;
}
if ( $prevMinLoss !== null && $minLossNum <= $prevMinLoss ) {
return '联营阶梯规则需按起始客损递增' ;
}
$prevMinLoss = $minLossNum ;
$normalized [] = [
'minLoss' => number_format ( $minLossNum , 4 , '.' , '' ),
'shareRate' => number_format ( $shareRateNum , 6 , '.' , '' ),
];
}
$data [ 'affiliate_ladder_rules' ] = $normalized ;
return null ;
}
}