Files
webman-buildadmin/app/admin/controller/Channel.php

1284 lines
47 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace app\admin\controller;
use Throwable;
use app\common\controller\Backend;
use app\common\service\ChannelSettlementService;
use support\think\Db;
use support\Response;
use Webman\Http\Request as WebmanRequest;
/**
* 渠道管理
*/
class Channel extends Backend
{
/**
* 预览接口与手动结算共用「手动结算」按钮权限(避免额外菜单节点)
*/
protected array $noNeedPermission = ['manualSettlePreview', 'channelAdminShareList', 'saveChannelAdminShare', 'batchSettlePending', 'settleStats'];
/**
* Channel模型对象
* @var object|null
* @phpstan-var \app\common\model\Channel|null
*/
protected ?object $model = null;
protected array|string $preExcludeFields = ['id', 'user_count', 'profit_amount', 'create_time', 'update_time', 'admin_id'];
protected array $withJoinTable = [];
protected string|array $quickSearchField = ['id', 'code', 'name'];
protected bool $modelSceneValidate = true;
private array $currentChannelIds = [];
protected function initController(WebmanRequest $request): ?Response
{
$this->model = new \app\common\model\Channel();
$this->currentChannelIds = $this->getCurrentChannelIds();
return null;
}
/**
* 渠道-管理员树(父级=渠道,子级=管理员,仅可选择子级)
*/
public function adminTree(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) return $response;
$query = Db::name('channel')
->field(['id', 'name'])
->order('id', 'asc');
if (!$this->auth->isSuperAdmin()) {
$query = $query->where('id', 'in', $this->currentChannelIds ?: [0]);
}
$channels = $query->select()->toArray();
$tree = [];
foreach ($channels as $ch) {
$channelId = (int) ($ch['id'] ?? 0);
$admins = Db::name('admin')
->field(['id', 'username'])
->where('channel_id', $channelId)
->order('id', 'asc')
->select()
->toArray();
$children = [];
foreach ($admins as $a) {
$children[] = [
'value' => (string) $a['id'],
'label' => $a['username'],
'channel_id' => $ch['id'],
'is_leaf' => true,
];
}
$tree[] = [
'value' => 'channel_' . $ch['id'],
'label' => $ch['name'],
'disabled' => true,
'children' => $children,
];
}
return $this->success('', [
'list' => $tree,
]);
}
/**
* 添加(重写:渠道与角色组在「角色组」侧绑定 channel_id此处不再写入 admin_group_id
* @throws Throwable
*/
protected function _add(): Response
{
if ($this->request && $this->request->method() === 'POST') {
$data = $this->request->post();
if (!$data) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
$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)) {
unset($data['admin_group_id']);
}
if ($this->dataLimit && $this->dataLimitFieldAutoFill) {
$data[$this->dataLimitField] = $this->auth->id;
}
$result = false;
$this->model->startTrans();
try {
if ($this->modelValidate) {
$validate = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validate)) {
$validate = new $validate();
if ($this->modelSceneValidate) {
$validate->scene('add');
}
$validate->check($data);
}
}
$result = $this->model->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
return $this->error($e->getMessage());
}
if ($result !== false) {
return $this->success(__('Added successfully'));
}
return $this->error(__('No rows were added'));
}
return $this->error(__('Parameter error'));
}
/**
* 编辑(重写:不再维护 channel.admin_group_id
* @throws Throwable
*/
protected function _edit(): Response
{
$pk = $this->model->getPk();
$id = $this->request ? ($this->request->post($pk) ?? $this->request->get($pk)) : null;
$row = $this->model->find($id);
if (!$row) {
return $this->error(__('Record not found'));
}
if (!$this->auth->isSuperAdmin() && !in_array($row['id'], $this->currentChannelIds, true)) {
return $this->error(__('You have no permission'));
}
$dataLimitAdminIds = $this->getDataLimitAdminIds();
if ($dataLimitAdminIds && !in_array($row[$this->dataLimitField], $dataLimitAdminIds)) {
return $this->error(__('You have no permission'));
}
if ($this->request && $this->request->method() === 'POST') {
$data = $this->request->post();
if (!$data) {
return $this->error(__('Parameter %s can not be empty', ['']));
}
$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)) {
unset($data['admin_group_id']);
}
$result = false;
$this->model->startTrans();
try {
if ($this->modelValidate) {
$validate = str_replace("\\model\\", "\\validate\\", get_class($this->model));
if (class_exists($validate)) {
$validate = new $validate();
if ($this->modelSceneValidate) {
$validate->scene('edit');
}
$data[$pk] = $row[$pk];
$validate->check($data);
}
}
$result = $row->save($data);
$this->model->commit();
} catch (Throwable $e) {
$this->model->rollback();
return $this->error($e->getMessage());
}
if ($result !== false) {
return $this->success(__('Update successful'));
}
return $this->error(__('No rows updated'));
}
return $this->success('', [
'row' => $row
]);
}
/**
* 查看
* @throws Throwable
*/
protected function _index(): Response
{
if ($this->request && $this->request->get('select')) {
return $this->select($this->request);
}
list($where, $alias, $limit, $order) = $this->queryBuilder();
if (!$this->auth->isSuperAdmin()) {
$where[] = [$alias['channel'] . '.id', 'in', $this->currentChannelIds ?: [0]];
}
$res = $this->model
->alias($alias)
->where($where)
->order($order)
->paginate($limit);
$items = $res->items();
$channelIds = [];
foreach ($items as $item) {
$cid = intval($item['id'] ?? 0);
if ($cid > 0) {
$channelIds[] = $cid;
}
}
$channelIds = array_values(array_unique($channelIds));
if ($channelIds !== []) {
$userCountMap = Db::name('user')
->where('channel_id', 'in', $channelIds)
->group('channel_id')
->column('COUNT(*) AS c', 'channel_id');
$now = time();
foreach ($channelIds as $channelId) {
$latestCount = intval($userCountMap[$channelId] ?? 0);
Db::name('channel')
->where('id', intval($channelId))
->update([
'user_count' => $latestCount,
'update_time' => $now,
]);
}
$profitMap = Db::name('bet_order')
->where('channel_id', 'in', $channelIds)
->where('status', 2)
->group('channel_id')
->column('SUM(total_amount - win_amount - jackpot_extra_amount) AS p', 'channel_id');
foreach ($items as $k => $item) {
$cid = intval($item['id'] ?? 0);
if ($cid <= 0) {
continue;
}
$items[$k]['user_count'] = intval($userCountMap[$cid] ?? 0);
$profit = strval($profitMap[$cid] ?? '0.00');
$items[$k]['profit_amount'] = bcadd($profit, '0', 2);
if (!isset($items[$k]['total_profit_amount']) || $items[$k]['total_profit_amount'] === null || $items[$k]['total_profit_amount'] === '') {
$items[$k]['total_profit_amount'] = $items[$k]['profit_amount'];
}
if (!isset($items[$k]['commission_pool_amount']) || $items[$k]['commission_pool_amount'] === null || $items[$k]['commission_pool_amount'] === '') {
$items[$k]['commission_pool_amount'] = '0.00';
}
}
}
return $this->success('', [
'list' => $items,
'total' => $res->total(),
'remark' => get_route_remark(),
]);
}
/**
* 手动结算预览:区间=上次结算周期结束~当前时间;金额来自已结算注单汇总(服务端计算)
*/
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 = ChannelSettlementService::buildSettlePayload($row->toArray());
if (is_string($payload)) {
return $this->error($payload);
}
return $this->success('', $payload);
}
/**
* 渠道管理员分配比例列表(用于结算二次分配配置)
*/
public function channelAdminShareList(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if (!$this->auth->check('channel/edit')) {
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'));
}
$adminRows = Db::name('admin')
->field(['id', 'username', 'status'])
->where('channel_id', (int) $row['id'])
->order('id', 'asc')
->select()
->toArray();
$shareRows = Db::name('channel_admin_share')
->where('channel_id', (int) $row['id'])
->column(['share_rate', 'status'], 'admin_id');
$adminIds = [];
foreach ($adminRows as $adminRow) {
$aid = (int) ($adminRow['id'] ?? 0);
if ($aid > 0) {
$adminIds[] = $aid;
}
}
$adminIds = array_values(array_unique($adminIds));
$roleMetaMap = $this->resolveAdminRoleMetaForChannel((int) $row['id'], $adminIds);
$list = [];
foreach ($adminRows as $adminRow) {
$aid = (int) ($adminRow['id'] ?? 0);
if ($aid <= 0) {
continue;
}
$saved = $shareRows[$aid] ?? null;
$roleMeta = $roleMetaMap[$aid] ?? null;
$list[] = [
'admin_id' => $aid,
'username' => (string) ($adminRow['username'] ?? ''),
'role_group_name' => is_array($roleMeta) ? (string) ($roleMeta['role_group_name'] ?? '') : '',
'role_level' => is_array($roleMeta) ? (int) ($roleMeta['role_level'] ?? 9999) : 9999,
'admin_status' => (string) ($adminRow['status'] ?? ''),
'share_rate' => $saved['share_rate'] ?? null,
'status' => isset($saved['status']) ? (int) $saved['status'] : 1,
];
}
usort($list, static function (array $a, array $b): int {
$levelA = (int) ($a['role_level'] ?? 9999);
$levelB = (int) ($b['role_level'] ?? 9999);
if ($levelA !== $levelB) {
return $levelA <=> $levelB;
}
$idA = (int) ($a['admin_id'] ?? 0);
$idB = (int) ($b['admin_id'] ?? 0);
return $idA <=> $idB;
});
return $this->success('', [
'channel_id' => (int) $row['id'],
'channel_name' => (string) ($row['name'] ?? ''),
'list' => $list,
]);
}
/**
* @param array<int, int> $adminIds
* @return array<int, array{role_group_name:string,role_level:int}>
*/
private function resolveAdminRoleMetaForChannel(int $channelId, array $adminIds): array
{
if ($channelId <= 0 || $adminIds === []) {
return [];
}
$accessRows = Db::name('admin_group_access')
->field(['uid', 'group_id'])
->where('uid', 'in', $adminIds)
->order('uid', 'asc')
->order('group_id', 'asc')
->select()
->toArray();
$groupIds = [];
foreach ($accessRows as $accessRow) {
$gid = (int) ($accessRow['group_id'] ?? 0);
if ($gid > 0) {
$groupIds[] = $gid;
}
}
$groupIds = array_values(array_unique($groupIds));
if ($groupIds === []) {
return [];
}
$groupRows = Db::name('admin_group')
->field(['id', 'pid', 'name'])
->where('id', 'in', $groupIds)
->order('id', 'asc')
->select()
->toArray();
if ($groupRows === []) {
return [];
}
$groupMap = [];
foreach ($groupRows as $groupRow) {
$gid = (int) ($groupRow['id'] ?? 0);
if ($gid <= 0) {
continue;
}
$groupMap[$gid] = [
'pid' => (int) ($groupRow['pid'] ?? 0),
'name' => trim((string) ($groupRow['name'] ?? '')),
];
}
if ($groupMap === []) {
return [];
}
$groupDepthById = [];
$calcDepth = function (int $groupId) use (&$calcDepth, &$groupDepthById, $groupMap): int {
if (isset($groupDepthById[$groupId])) {
return $groupDepthById[$groupId];
}
$current = $groupMap[$groupId] ?? null;
if (!$current) {
$groupDepthById[$groupId] = 9999;
return 9999;
}
$pid = (int) ($current['pid'] ?? 0);
if ($pid <= 0 || !isset($groupMap[$pid])) {
$groupDepthById[$groupId] = 1;
return 1;
}
$depth = $calcDepth($pid) + 1;
$groupDepthById[$groupId] = $depth;
return $depth;
};
$metaMap = [];
foreach ($accessRows as $accessRow) {
$uid = (int) ($accessRow['uid'] ?? 0);
$groupId = (int) ($accessRow['group_id'] ?? 0);
if ($uid <= 0 || $groupId <= 0 || !isset($groupMap[$groupId])) {
continue;
}
$roleName = trim((string) ($groupMap[$groupId]['name'] ?? ''));
if ($roleName === '') {
continue;
}
$depth = $calcDepth($groupId);
if (!isset($metaMap[$uid])) {
$metaMap[$uid] = [
'role_group_name' => $roleName,
'role_level' => $depth,
'group_id' => $groupId,
];
continue;
}
$currentDepth = (int) ($metaMap[$uid]['role_level'] ?? 9999);
$currentGroupId = (int) ($metaMap[$uid]['group_id'] ?? 0);
if ($depth < $currentDepth || ($depth === $currentDepth && $groupId < $currentGroupId)) {
$metaMap[$uid]['role_group_name'] = $roleName;
$metaMap[$uid]['role_level'] = $depth;
$metaMap[$uid]['group_id'] = $groupId;
}
}
$out = [];
foreach ($metaMap as $uid => $meta) {
$out[$uid] = [
'role_group_name' => (string) ($meta['role_group_name'] ?? ''),
'role_level' => (int) ($meta['role_level'] ?? 9999),
];
}
return $out;
}
/**
* 保存渠道管理员分配比例(启用项总和必须=100
*/
public function saveChannelAdminShare(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if (!$this->auth->check('channel/edit')) {
return $this->error(__('You have no permission'));
}
if ($request->method() !== 'POST') {
return $this->error(__('Parameter error'));
}
$id = (int) ($request->post('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'));
}
$rowsRaw = $request->post('list', []);
if (!is_array($rowsRaw) || $rowsRaw === []) {
return $this->error(__('Please configure at least one share record'));
}
$adminIds = Db::name('admin')
->where('channel_id', (int) $row['id'])
->column('id');
$adminIdSet = [];
foreach ($adminIds as $adminId) {
$adminIdSet[(int) $adminId] = true;
}
if ($adminIdSet === []) {
return $this->error(__('There are no admins under this channel; cannot configure share ratios'));
}
$enabledSum = '0.00';
$insertRows = [];
foreach ($rowsRaw as $line) {
if (!is_array($line)) {
continue;
}
$adminId = (int) ($line['admin_id'] ?? 0);
if ($adminId <= 0 || !isset($adminIdSet[$adminId])) {
continue;
}
$status = ((int) ($line['status'] ?? 1)) === 1 ? 1 : 0;
$shareRaw = $line['share_rate'] ?? null;
$shareRate = self::normalizeAmountScale($shareRaw === null ? '0' : (string) $shareRaw, 2);
if (bccomp($shareRate, '0', 2) < 0 || bccomp($shareRate, '100', 2) > 0) {
return $this->error(__('Share ratio must be between 0 and 100'));
}
if ($status === 1) {
$enabledSum = bcadd($enabledSum, $shareRate, 2);
}
$insertRows[] = [
'channel_id' => (int) $row['id'],
'admin_id' => $adminId,
'share_rate' => $shareRate,
'status' => $status,
'create_time' => time(),
'update_time' => time(),
];
}
if ($insertRows === []) {
return $this->error(__('Please configure at least one valid share record'));
}
if (bccomp($enabledSum, '100.00', 2) !== 0) {
return $this->error(__('Sum of enabled share ratios must equal 100'));
}
Db::startTrans();
try {
Db::name('channel_admin_share')->where('channel_id', (int) $row['id'])->delete();
Db::name('channel_admin_share')->insertAll($insertRows);
Db::commit();
} catch (Throwable $e) {
Db::rollback();
return $this->error($e->getMessage());
}
return $this->success(__('Share ratios saved successfully'));
}
/**
* 手动结算(渠道维度):仅接收备注;周期与金额全部由服务端按注单汇总计算,并写入结算周期与佣金记录
*/
public function manualSettle(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if (!$this->auth->isSuperAdmin()) {
return $this->error(__('Only super admin can settle; after settlement, commissions will be automatically paid to admin wallets'));
}
$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 = trim((string) $request->post('remark', ''));
$res = ChannelSettlementService::settleBySuperAdmin((int) $row['id'], intval($this->auth->id), $remark, false);
if (($res['ok'] ?? false) !== true) {
return $this->error((string) ($res['msg'] ?? __('Settlement failed')));
}
return $this->success(__('Super admin settlement completed; paid automatically by share ratios'));
}
/**
* 超管批量结算全部待结算渠道(可作为“提前结算”入口)
*/
public function batchSettlePending(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if (!$this->auth->isSuperAdmin()) {
return $this->error(__('You have no permission'));
}
// 批量按钮语义:手动触发“待结算渠道”结算,不受结算周期到点限制。
$res = ChannelSettlementService::settleAllDueChannels(intval($this->auth->id), false);
return $this->success(__('Batch settlement completed'), $res);
}
/**
* 渠道结算统计卡片
*/
public function settleStats(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
$query = Db::name('channel');
if (!$this->auth->isSuperAdmin()) {
$query->where('id', 'in', $this->currentChannelIds ?: [0]);
}
$rows = $query->field(['id', 'status', 'carryover_balance'])->select()->toArray();
$total = count($rows);
$enabled = 0;
$disabled = 0;
$carryoverPositiveCount = 0;
$carryoverTotal = '0.00';
$carryoverPositiveTotal = '0.00';
foreach ($rows as $row) {
$status = intval($row['status'] ?? 0);
if ($status === 1) {
$enabled++;
} else {
$disabled++;
}
$carry = bcadd(strval($row['carryover_balance'] ?? '0'), '0', 2);
$carryoverTotal = bcadd($carryoverTotal, $carry, 2);
if (bccomp($carry, '0', 2) > 0) {
$carryoverPositiveCount++;
$carryoverPositiveTotal = bcadd($carryoverPositiveTotal, $carry, 2);
}
}
return $this->success('', [
'channel_total' => $total,
'enabled_count' => $enabled,
'disabled_count' => $disabled,
'carryover_positive_count' => $carryoverPositiveCount,
'carryover_total' => $carryoverTotal,
'carryover_positive_total' => $carryoverPositiveTotal,
]);
}
/**
* @return array|string 成功返回预览数据数组,失败返回错误文案
*/
private function buildManualSettlePayload(array $row): array|string
{
$channelId = (int) ($row['id'] ?? 0);
if ($channelId <= 0) {
return (string) __('Invalid channel data');
}
$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 (string) __('Invalid settlement period (start time is not earlier than now)');
}
$stats = $this->aggregateBetOrderForChannel($channelId, $periodStartTs, $lastEnd !== null, $endTs);
$totalBet = $stats['total_bet'];
$totalPayout = $stats['total_payout'];
$profit = bcsub($totalBet, $totalPayout, 2);
$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);
$splitRows = $this->resolveCommissionSharesForChannel($channelId);
$splitPreview = $this->buildCommissionSplitPreview($splitRows, $commission['commission_amount']);
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,
'commission_split' => $splitPreview,
];
}
/**
* 生成代理结算周期单号:仅大写字母与数字、无分隔符;首字符 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, 2));
$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.00';
$tw = $row && $row['tw'] !== null && $row['tw'] !== '' ? (string) $row['tw'] : '0.00';
$tj = $row && $row['tj'] !== null && $row['tj'] !== '' ? (string) $row['tj'] : '0.00';
$totalPayout = bcadd($tw, $tj, 2);
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 (string) __('Turnover agent commission rate is not configured');
}
$rateDec = bcdiv((string) $ratePercent, '100', 2);
$amount = bcmul($totalBet, $rateDec, 2);
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 (string) __('Affiliate agent fee rate is not configured');
}
$rules = $this->normalizeLadderRulesForSettlement($rulesRaw);
if ($rules === []) {
return (string) __('Affiliate ladder rules are empty or invalid');
}
if (bccomp($platformProfit, '0', 2) <= 0) {
return [
'commission_rate' => '0.00',
'calc_base_amount' => '0.00',
'commission_amount' => '0.00',
];
}
$afterFee = bcmul($platformProfit, bcsub('1', (string) $fee, 2), 2);
if (bccomp($afterFee, '0', 2) <= 0) {
return [
'commission_rate' => '0.00',
'calc_base_amount' => '0.00',
'commission_amount' => '0.00',
];
}
$playerLoss = $platformProfit;
$share = $this->pickAffiliateShareRateFromLadder($rules, $playerLoss);
$rateDec = number_format($share, 6, '.', '');
$amount = bcmul($afterFee, $rateDec, 2);
return [
'commission_rate' => $rateDec,
'calc_base_amount' => $afterFee,
'commission_amount' => $amount,
];
}
return (string) __('Unknown agent mode');
}
/**
* @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'], 2);
});
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'], 2) >= 0) {
$chosen = (float) $rule['shareRate'];
}
}
return $chosen;
}
private function getCurrentChannelIds(): array
{
if ($this->auth->isSuperAdmin()) {
return Db::name('channel')->column('id');
}
$admin = Db::name('admin')
->field(['id', 'channel_id'])
->where('id', $this->auth->id)
->find();
$ids = [];
if ($admin && !empty($admin['channel_id'])) {
$ids[] = $admin['channel_id'];
}
return array_values(array_unique($ids));
}
/**
* 佣金归属管理员:取该渠道下 admin.channel_id 匹配的首个管理员(按 id 升序)。
*/
private function resolveCommissionAdminIdForChannel(int $channelId): ?int
{
if ($channelId <= 0) {
return null;
}
$aid = Db::name('admin')
->where('channel_id', $channelId)
->order('id', 'asc')
->value('id');
if ($aid === null || $aid === '') {
return null;
}
return (int) $aid;
}
/**
* @return array<int, array{admin_id:int, share_rate:string}>
*/
private function resolveCommissionSharesForChannel(int $channelId): array
{
if ($channelId <= 0) {
return [];
}
$rows = Db::name('channel_admin_share')->alias('cas')
->join('admin a', 'cas.admin_id = a.id')
->field(['cas.admin_id', 'cas.share_rate'])
->where('cas.channel_id', $channelId)
->where('cas.status', 1)
->where('a.status', 'enable')
->order('cas.admin_id', 'asc')
->select()
->toArray();
if ($rows !== []) {
$sum = '0.00';
$out = [];
foreach ($rows as $row) {
$adminId = (int) ($row['admin_id'] ?? 0);
if ($adminId <= 0) {
continue;
}
$shareRate = self::normalizeAmountScale((string) ($row['share_rate'] ?? '0'), 2);
if (bccomp($shareRate, '0', 2) <= 0) {
continue;
}
$sum = bcadd($sum, $shareRate, 2);
$out[] = [
'admin_id' => $adminId,
'share_rate' => $shareRate,
];
}
if ($out !== [] && bccomp($sum, '100.00', 2) === 0) {
return $out;
}
}
$fallbackAdminId = $this->resolveCommissionAdminIdForChannel($channelId);
if ($fallbackAdminId === null || $fallbackAdminId <= 0) {
return [];
}
return [[
'admin_id' => (int) $fallbackAdminId,
'share_rate' => '100.00',
]];
}
/**
* @param array<int, array{admin_id:int, share_rate:string}> $shareRows
* @return array<int, array{settlement_period_id:int, channel_id:int, admin_id:int, commission_rate:string, calc_base_amount:string, commission_amount:string, status:int, settled_at:null, remark:string, create_time:int, update_time:int}>
*/
private function buildCommissionRowsForSplit(
array $shareRows,
int $channelId,
int $periodId,
string $calcBaseAmount,
string $commissionTotal,
string $remark,
int $now
): array {
if ($shareRows === []) {
return [];
}
$sum = '0.00';
$rows = [];
$lastIndex = count($shareRows) - 1;
foreach ($shareRows as $index => $shareRow) {
$shareRate = self::normalizeAmountScale((string) ($shareRow['share_rate'] ?? '0'), 2);
$shareDec = bcdiv($shareRate, '100', 2);
$amount = $index === $lastIndex
? bcsub($commissionTotal, $sum, 2)
: bcmul($commissionTotal, $shareDec, 2);
if ($index !== $lastIndex) {
$sum = bcadd($sum, $amount, 2);
}
$effectiveRate = bccomp($calcBaseAmount, '0', 2) <= 0 ? '0.00' : bcdiv($amount, $calcBaseAmount, 2);
$rows[] = [
'settlement_period_id' => $periodId,
'channel_id' => $channelId,
'admin_id' => (int) ($shareRow['admin_id'] ?? 0),
'commission_rate' => $effectiveRate,
'calc_base_amount' => $calcBaseAmount,
'commission_amount' => $amount,
'status' => 0,
'settled_at' => null,
'remark' => $remark . ' | 分配比例=' . $shareRate . '%',
'create_time' => $now,
'update_time' => $now,
];
}
return $rows;
}
/**
* @param array<int, array{admin_id:int, share_rate:string}> $shareRows
* @return array<int, array{admin_id:int, admin_username:string, share_rate:string, commission_amount:string}>
*/
private function buildCommissionSplitPreview(array $shareRows, string $commissionTotal): array
{
if ($shareRows === []) {
return [];
}
$adminIds = [];
foreach ($shareRows as $shareRow) {
$adminIds[] = (int) ($shareRow['admin_id'] ?? 0);
}
$adminNames = Db::name('admin')->where('id', 'in', $adminIds)->column('username', 'id');
$sum = '0.00';
$out = [];
$lastIndex = count($shareRows) - 1;
foreach ($shareRows as $index => $shareRow) {
$shareRate = self::normalizeAmountScale((string) ($shareRow['share_rate'] ?? '0'), 2);
$shareDec = bcdiv($shareRate, '100', 2);
$amount = $index === $lastIndex
? bcsub($commissionTotal, $sum, 2)
: bcmul($commissionTotal, $shareDec, 2);
if ($index !== $lastIndex) {
$sum = bcadd($sum, $amount, 2);
}
$adminId = (int) ($shareRow['admin_id'] ?? 0);
$out[] = [
'admin_id' => $adminId,
'admin_username' => (string) ($adminNames[$adminId] ?? ('#' . $adminId)),
'share_rate' => $shareRate,
'commission_amount' => $amount,
];
}
return $out;
}
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') {
$data['turnover_share_rate'] = null;
}
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 (string) __('Invalid settlement cycle');
}
$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 (string) __('Invalid settlement time format (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 (string) __('Weekly settlement must select Monday to Sunday');
}
$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 (string) __('Monthly settlement day must be between 1 and 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 (string) __('Turnover commission rate must be between 0 and 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 (string) __('Affiliate share/fee rates are required');
}
$num = (float) $data[$field];
if ($num < 0 || $num > 1) {
return (string) __('Affiliate share/fee rates must be between 0 and 1');
}
}
$ladderErr = $this->validateLadderRulesField($data);
if ($ladderErr !== null) {
return $ladderErr;
}
}
return null;
}
private static function normalizeAmountScale(string $amount, int $scale): string
{
$raw = trim(str_replace(',', '.', $amount));
if ($raw === '') {
return '0';
}
$negative = false;
if (str_starts_with($raw, '-')) {
$negative = true;
$raw = ltrim(substr($raw, 1));
}
if (!str_contains($raw, '.')) {
$v = ltrim($raw, '0');
$v = $v === '' ? '0' : $v;
return $negative ? ('-' . $v) : $v;
}
[$intPart, $fracPart] = explode('.', $raw, 2);
$intPart = ltrim($intPart, '0');
$intPart = $intPart === '' ? '0' : $intPart;
$fracPart = preg_replace('/\D+/', '', $fracPart) ?? '';
if (strlen($fracPart) > $scale) {
$fracPart = substr($fracPart, 0, $scale);
} else {
$fracPart = str_pad($fracPart, $scale, '0');
}
$v = $intPart . '.' . $fracPart;
return $negative ? ('-' . $v) : $v;
}
private function validateLadderRulesField(array &$data): ?string
{
$rulesRaw = $data['affiliate_ladder_rules'] ?? null;
if ($rulesRaw === null || $rulesRaw === '') {
return (string) __('Affiliate ladder rules are required');
}
if (is_string($rulesRaw)) {
$decoded = json_decode($rulesRaw, true);
if (!is_array($decoded)) {
return (string) __('Affiliate ladder rules must be a valid JSON array');
}
$rulesRaw = $decoded;
}
if (!is_array($rulesRaw) || $rulesRaw === []) {
return (string) __('Affiliate ladder rules must contain at least one row');
}
$normalized = [];
$prevMinLoss = null;
foreach ($rulesRaw as $idx => $rule) {
if (!is_array($rule)) {
return (string) __('Affiliate ladder rules row format error') . ' #' . ($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 (string) __('Affiliate ladder rules minLoss format error') . ' #' . ($idx + 1);
}
if ($shareRate === null || $shareRate === '' || !is_numeric((string) $shareRate)) {
return (string) __('Affiliate ladder rules shareRate format error') . ' #' . ($idx + 1);
}
$minLossNum = (float) $minLoss;
$shareRateNum = (float) $shareRate;
if ($minLossNum < 0) {
return (string) __('Affiliate ladder rules minLoss cannot be negative') . ' #' . ($idx + 1);
}
if ($shareRateNum < 0 || $shareRateNum > 1) {
return (string) __('Affiliate ladder rules shareRate must be between 0 and 1') . ' #' . ($idx + 1);
}
if ($prevMinLoss !== null && $minLossNum <= $prevMinLoss) {
return (string) __('Affiliate ladder rules must be strictly increasing by minLoss');
}
$prevMinLoss = $minLossNum;
$normalized[] = [
'minLoss' => number_format($minLossNum, 4, '.', ''),
'shareRate' => number_format($shareRateNum, 6, '.', ''),
];
}
$data['affiliate_ladder_rules'] = $normalized;
return null;
}
}