Files
webman-buildadmin/app/admin/controller/Channel.php
zhenhui f2b4dab54f 1.压注记录修改为游玩记录
2.测试结算并测试
3.备份数据库
2026-04-23 17:21:02 +08:00

1269 lines
46 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();
$groupChildrenCache = [];
$getGroupChildren = function ($groupId) use (&$getGroupChildren, &$groupChildrenCache) {
if ($groupId === null || $groupId === '') return [];
if (array_key_exists($groupId, $groupChildrenCache)) return $groupChildrenCache[$groupId];
$children = Db::name('admin_group')
->where('pid', $groupId)
->where('status', 1)
->column('id');
$all = [];
foreach ($children as $cid) {
$all[] = $cid;
foreach ($getGroupChildren($cid) as $cc) {
$all[] = $cc;
}
}
$groupChildrenCache[$groupId] = $all;
return $all;
};
$tree = [];
foreach ($channels as $ch) {
$channelId = (int) ($ch['id'] ?? 0);
$rootGroupIds = Db::name('admin_group')
->where('channel_id', $channelId)
->where('pid', 0)
->where('status', 1)
->column('id');
$groupIds = [];
foreach ($rootGroupIds as $rootId) {
$groupIds[] = $rootId;
foreach ($getGroupChildren($rootId) as $gid) {
$groupIds[] = $gid;
}
}
$adminIds = [];
if ($groupIds) {
$adminIds = Db::name('admin_group_access')
->where('group_id', 'in', array_unique($groupIds))
->column('uid');
}
$adminIds = array_values(array_unique($adminIds));
$admins = [];
if ($adminIds) {
$admins = Db::name('admin')
->field(['id', 'username'])
->where('id', 'in', $adminIds)
->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);
return $this->success('', [
'list' => $res->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 [];
}
$groupRows = Db::name('admin_group')
->field(['id', 'pid', 'name'])
->where('channel_id', $channelId)
->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;
};
$accessRows = Db::name('admin_group_access')
->field(['uid', 'group_id'])
->where('uid', 'in', $adminIds)
->order('uid', 'asc')
->order('group_id', 'asc')
->select()
->toArray();
$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('请至少配置一条分配记录');
}
$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('该渠道下暂无管理员,无法配置分配比例');
}
$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('分配比例必须在0到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('请至少配置一条有效分配记录');
}
if (bccomp($enabledSum, '100.00', 2) !== 0) {
return $this->error('启用的分配比例总和必须等于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('分配比例保存成功');
}
/**
* 手动结算(渠道维度):仅接收备注;周期与金额全部由服务端按注单汇总计算,并写入结算周期与佣金记录
*/
public function manualSettle(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if (!$this->auth->isSuperAdmin()) {
return $this->error('仅超管可执行结算,结算后系统会自动发放至管理员钱包');
}
$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'] ?? '结算失败'));
}
return $this->success('超管结算完成,已按分配比例自动发放给管理员');
}
/**
* 超管批量结算全部待结算渠道(可作为“提前结算”入口)
*/
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));
return $this->success('批量结算完成', $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 '渠道数据异常';
}
$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, 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 '普通返水代理未配置返水分红比例';
}
$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 '联营代理未配置成本扣除比例';
}
$rules = $this->normalizeLadderRulesForSettlement($rulesRaw);
if ($rules === []) {
return '联营阶梯规则无效或为空';
}
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 '未知的代理模式';
}
/**
* @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 '结算周期不合法';
}
$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 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 '联营阶梯规则不能为空';
}
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;
}
}