Files
webman-buildadmin/app/admin/controller/Channel.php
zhenhui 1cdd597879 1.优化充值跳转链接的问题
2.优化后台渠道管理页面的显示样式
2026-05-30 14:37:46 +08:00

1798 lines
65 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\AdminChannelScopeService;
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',
'settleStats',
'dividendRecordList',
'directBetRecordList',
'settlementBetRecordList',
];
/**
* 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;
protected function initController(WebmanRequest $request): ?Response
{
$this->model = new \app\common\model\Channel();
return null;
}
/**
* 列表/统计按可读渠道收窄null 表示不限制
*
* @param array<int, array<int|string, mixed>> $where
*/
private function appendReadableChannelWhere(array &$where, array $alias, string $tableKey = 'channel'): void
{
$scope = $this->readableChannelIds();
if ($scope !== null) {
$where[] = [$alias[$tableKey] . '.id', 'in', $scope];
}
}
/**
* @param \think\db\BaseQuery|\think\db\Query $query
*/
private function applyReadableChannelScope($query, string $column): void
{
$scope = $this->readableChannelIds();
if ($scope !== null) {
$query->where($column, 'in', $scope);
}
}
/**
* 渠道-管理员树(父级=渠道,子级=管理员,仅可选择子级)
*/
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');
$this->applyReadableChannelScope($query, 'id');
$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->assertChannelVisible((int) $row['id'])) {
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') {
if (!$this->assertChannelWritable((int) $row['id'])) {
return $this->error(__('You have no permission'));
}
$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
]);
}
/**
* 删除:须在可写渠道范围内
*/
protected function _del(): Response
{
$ids = $this->request ? ($this->request->post('ids') ?? $this->request->get('ids') ?? []) : [];
$ids = is_array($ids) ? $ids : [];
foreach ($ids as $id) {
if (!$this->assertChannelWritable((int) $id)) {
return $this->error(__('You have no permission'));
}
}
return parent::_del();
}
/**
* 查看
* @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();
$this->appendReadableChannelWhere($where, $alias);
$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');
$directBetMap = Db::name('game_play_record')
->where('channel_id', 'in', $channelIds)
->group('channel_id')
->column('SUM(total_amount) AS s', 'channel_id');
foreach ($items as $k => $item) {
$cid = intval($item['id'] ?? 0);
if ($cid <= 0) {
continue;
}
$items[$k]['user_count'] = intval($userCountMap[$cid] ?? 0);
$directBet = strval($directBetMap[$cid] ?? '0.00');
$items[$k]['direct_bet_amount'] = bcadd($directBet, '0', 2);
$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->canManualSettle()) {
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->assertChannelVisible((int) $row['id'])) {
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->assertChannelVisible((int) $row['id'])) {
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->assertChannelWritable((int) $row['id'])) {
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->canManualSettle()) {
return $this->error(__('You have no permission'));
}
$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->assertChannelVisible((int) $row['id'])) {
return $this->error(__('You have no permission'));
}
$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, $handlingFeeByAdmin);
if (($res['ok'] ?? false) !== true) {
return $this->error((string) ($res['msg'] ?? __('Settlement failed')));
}
return $this->success(__('Settlement completed; commissions paid automatically by share ratios'));
}
/**
* 批量结算待结算渠道(需 channel/batchSettlePending范围=当前账号可写渠道)
*/
public function batchSettlePending(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
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, $scope);
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');
$this->applyReadableChannelScope($query, 'id');
$rows = $query->field(['id', 'status', 'carryover_balance'])->select()->toArray();
$total = count($rows);
$enabled = 0;
$disabled = 0;
$carryoverPositiveCount = 0;
$carryoverTotal = '0.00';
$carryoverPositiveTotal = '0.00';
$channelIdList = [];
foreach ($rows as $row) {
$cid = intval($row['id'] ?? 0);
if ($cid > 0) {
$channelIdList[] = $cid;
}
$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);
}
}
$paidDividendTotal = '0.00';
if ($channelIdList !== []) {
$paidRow = Db::name('agent_commission_record')
->where('channel_id', 'in', $channelIdList)
->where('status', 1)
->field('SUM(commission_amount) AS s')
->find();
$paidDividendTotal = bcadd(strval(is_array($paidRow) ? ($paidRow['s'] ?? '0') : '0'), '0', 2);
}
return $this->success('', [
'channel_total' => $total,
'enabled_count' => $enabled,
'disabled_count' => $disabled,
'carryover_positive_count' => $carryoverPositiveCount,
'carryover_total' => $carryoverTotal,
'carryover_positive_total' => $carryoverPositiveTotal,
'paid_dividend_total' => $paidDividendTotal,
]);
}
/**
* 已分红记录列表(顶部统计卡片点击)
*/
public function dividendRecordList(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if (!$this->auth->check('channel/viewDividendRecords')) {
return $this->error(__('You have no permission'));
}
[$page, $limit] = $this->parseListPageParams($request);
$channelId = (int) ($request->get('channel_id', 0));
$query = Db::name('agent_commission_record')->alias('acr')
->leftJoin('agent_settlement_period asp', 'acr.settlement_period_id = asp.id')
->leftJoin('channel c', 'acr.channel_id = c.id')
->leftJoin('admin a', 'acr.admin_id = a.id')
->where('acr.status', 1);
$this->applyReadableChannelScope($query, 'acr.channel_id');
if ($channelId > 0) {
if (!$this->assertChannelAccessible($channelId)) {
return $this->error(__('You have no permission'));
}
$query->where('acr.channel_id', $channelId);
}
$total = (int) $query->count('acr.id');
$list = $query
->field([
'acr.id',
'acr.channel_id',
'acr.admin_id',
'acr.commission_amount',
'acr.settled_at',
'acr.remark',
'asp.settlement_no',
'asp.period_start_at',
'asp.period_end_at',
'c.name as channel_name',
'a.username as admin_username',
])
->order('acr.settled_at', 'desc')
->order('acr.id', 'desc')
->page($page, $limit)
->select()
->toArray();
foreach ($list as $idx => $row) {
if (!is_array($row)) {
continue;
}
$startTs = intval($row['period_start_at'] ?? 0);
$endTs = intval($row['period_end_at'] ?? 0);
$list[$idx]['period_start_at'] = $startTs > 0 ? date('Y-m-d H:i:s', $startTs) : '';
$list[$idx]['period_end_at'] = $endTs > 0 ? date('Y-m-d H:i:s', $endTs) : '';
$settledTs = intval($row['settled_at'] ?? 0);
$list[$idx]['settled_at'] = $settledTs > 0 ? date('Y-m-d H:i:s', $settledTs) : '';
$list[$idx]['commission_amount'] = bcadd(strval($row['commission_amount'] ?? '0'), '0', 2);
}
return $this->success('', [
'list' => $list,
'total' => $total,
]);
}
/**
* 渠道直属玩家下注记录(直属投注额列点击)
*/
public function directBetRecordList(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if (!$this->auth->check('channel/viewDirectBetRecords')) {
return $this->error(__('You have no permission'));
}
$channelId = (int) ($request->get('channel_id', 0));
if ($channelId <= 0 || !$this->assertChannelAccessible($channelId)) {
return $this->error(__('You have no permission'));
}
return $this->success('', $this->fetchChannelPlayRecordListPayload($request, $channelId, false));
}
/**
* 参与分红口径的下注记录(操作列「查看总投注金额」)
*/
public function settlementBetRecordList(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if (!$this->auth->check('channel/viewSettlementBetRecords')) {
return $this->error(__('You have no permission'));
}
$channelId = (int) ($request->get('channel_id', 0));
if ($channelId <= 0 || !$this->assertChannelAccessible($channelId)) {
return $this->error(__('You have no permission'));
}
return $this->success('', $this->fetchChannelPlayRecordListPayload($request, $channelId, true));
}
/**
* @return array{list: array<int, array<string, mixed>>, total: int, summary: array{record_count:int,total_bet_amount:string,total_win_amount:string}}
*/
private function fetchChannelPlayRecordListPayload(WebmanRequest $request, int $channelId, bool $settledOnly): array
{
[$page, $limit] = $this->parseListPageParams($request);
$filters = $this->parsePlayRecordListFilters($request);
$listQuery = $this->buildChannelPlayRecordQuery($channelId, $settledOnly);
$this->applyPlayRecordListFilters($listQuery, $filters);
$total = (int) $listQuery->count('pr.id');
$list = $listQuery
->field($this->channelPlayRecordListFields())
->order('pr.id', 'desc')
->page($page, $limit)
->select()
->toArray();
$summaryQuery = $this->buildChannelPlayRecordQuery($channelId, $settledOnly);
$this->applyPlayRecordListFilters($summaryQuery, $filters);
$summary = $this->summarizePlayRecordQuery($summaryQuery);
return [
'list' => $this->normalizePlayRecordListRows($list),
'total' => $total,
'summary' => $summary,
];
}
/**
* @return array{period_no: string, user_keyword: string, result_number: string, pick_number: string, win_hit: string}
*/
private function parsePlayRecordListFilters(WebmanRequest $request): array
{
$winHit = trim((string) $request->get('win_hit', ''));
if (!in_array($winHit, ['won', 'lost', 'pending'], true)) {
$winHit = '';
}
return [
'period_no' => trim((string) $request->get('period_no', '')),
'user_keyword' => trim((string) $request->get('user_keyword', '')),
'result_number' => trim((string) $request->get('result_number', '')),
'pick_number' => trim((string) $request->get('pick_number', '')),
'win_hit' => $winHit,
];
}
/**
* @param \think\db\Query $query
* @param array{period_no: string, user_keyword: string, result_number: string, pick_number: string, win_hit: string} $filters
*/
private function applyPlayRecordListFilters($query, array $filters): void
{
if ($filters['period_no'] !== '') {
$like = '%' . $this->escapeLikeKeyword($filters['period_no']) . '%';
$query->where(function ($sub) use ($like) {
$sub->where('pr.period_no', 'like', $like)->whereOr('gr.period_no', 'like', $like);
});
}
if ($filters['user_keyword'] !== '') {
$like = '%' . $this->escapeLikeKeyword($filters['user_keyword']) . '%';
$query->where(function ($sub) use ($like) {
$sub->where('u.username', 'like', $like)->whereOr('u.phone', 'like', $like);
});
}
if ($filters['result_number'] !== '') {
$query->where('gr.result_number', $filters['result_number']);
}
if ($filters['pick_number'] !== '') {
$like = '%' . $this->escapeLikeKeyword($filters['pick_number']) . '%';
$query->where('pr.pick_numbers', 'like', $like);
}
if ($filters['win_hit'] === 'won') {
$query->where('pr.status', 2)
->whereRaw('(pr.win_amount + IFNULL(pr.jackpot_extra_amount, 0)) > 0');
} elseif ($filters['win_hit'] === 'lost') {
$query->where('pr.status', 2)
->whereRaw('(pr.win_amount + IFNULL(pr.jackpot_extra_amount, 0)) <= 0');
} elseif ($filters['win_hit'] === 'pending') {
$query->where('pr.status', '<>', 2);
}
}
private function escapeLikeKeyword(string $keyword): string
{
return str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $keyword);
}
/**
* @return array{0:int,1:int}
*/
private function parseListPageParams(WebmanRequest $request): array
{
$pageRaw = $request->get('page', 1);
$page = is_numeric((string) $pageRaw) ? (int) $pageRaw : 1;
if ($page < 1) {
$page = 1;
}
$limitRaw = $request->get('limit', 20);
$limit = is_numeric((string) $limitRaw) ? (int) $limitRaw : 20;
if ($limit < 1) {
$limit = 1;
}
if ($limit > 200) {
$limit = 200;
}
return [$page, $limit];
}
private function assertChannelVisible(int $channelId): bool
{
if ($channelId <= 0) {
return false;
}
$scope = $this->readableChannelIds();
if ($scope === null) {
return Db::name('channel')->where('id', $channelId)->value('id') !== null;
}
return in_array($channelId, $scope, true);
}
private function assertChannelWritable(int $channelId): bool
{
if ($channelId <= 0 || $this->auth === null) {
return false;
}
$scope = AdminChannelScopeService::writableChannelIds($this->auth);
if ($scope === null) {
return Db::name('channel')->where('id', $channelId)->value('id') !== null;
}
return in_array($channelId, $scope, true);
}
private function assertChannelAccessible(int $channelId): bool
{
return $this->assertChannelVisible($channelId);
}
/**
* @return \think\db\Query
*/
private function canManualSettle(): bool
{
if ($this->auth === null) {
return false;
}
if ($this->auth->isSuperAdmin()) {
return true;
}
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')
->leftJoin('user u', 'u.id = pr.user_id')
->leftJoin('game_record gr', 'gr.id = pr.period_id')
->leftJoin('channel c', 'c.id = pr.channel_id')
->where('pr.channel_id', $channelId);
if ($settledOnly) {
$query->where('pr.status', 2);
}
return $query;
}
/**
* @return array<int, string>
*/
private function channelPlayRecordListFields(): array
{
return [
'pr.id',
'pr.period_no',
'pr.period_id',
'pr.user_id',
'pr.pick_numbers',
'pr.total_amount',
'pr.win_amount',
'pr.jackpot_extra_amount',
'pr.status',
'pr.create_time',
'u.username as user_username',
'c.name as channel_name',
'gr.period_no as game_record_period_no',
'gr.result_number',
'gr.status as game_record_status',
];
}
/**
* @param array<int, array<string, mixed>> $list
* @return array<int, array<string, mixed>>
*/
private function normalizePlayRecordListRows(array $list): array
{
$out = [];
foreach ($list as $row) {
if (!is_array($row)) {
continue;
}
$periodNo = trim((string) ($row['period_no'] ?? ''));
if ($periodNo === '') {
$periodNo = trim((string) ($row['game_record_period_no'] ?? ''));
}
$win = bcadd(strval($row['win_amount'] ?? '0'), strval($row['jackpot_extra_amount'] ?? '0'), 2);
$playStatus = intval($row['status'] ?? 0);
$out[] = [
'id' => intval($row['id'] ?? 0),
'period_no' => $periodNo,
'user_username' => (string) ($row['user_username'] ?? ''),
'channel_name' => (string) ($row['channel_name'] ?? ''),
'pick_numbers' => $this->formatPickNumbersForList($row['pick_numbers'] ?? null),
'result_number' => $this->formatResultNumberForList($row['result_number'] ?? null),
'win_hit' => $this->resolveWinHitCode($win, $playStatus),
'play_status' => $playStatus,
'total_amount' => bcadd(strval($row['total_amount'] ?? '0'), '0', 2),
'win_amount' => bcadd($win, '0', 2),
'create_time' => intval($row['create_time'] ?? 0),
];
}
return $out;
}
private function formatPickNumbersForList(mixed $pickNumbers): string
{
if ($pickNumbers === null || $pickNumbers === '') {
return '';
}
if (is_string($pickNumbers)) {
$trimmed = trim($pickNumbers);
if ($trimmed === '') {
return '';
}
$decoded = json_decode($trimmed, true);
if (is_array($decoded)) {
$pickNumbers = $decoded;
} else {
return $trimmed;
}
}
if (!is_array($pickNumbers)) {
return '';
}
$parts = [];
foreach ($pickNumbers as $num) {
if ($num === null || $num === '') {
continue;
}
$parts[] = (string) $num;
}
return implode(',', $parts);
}
private function formatResultNumberForList(mixed $resultNumber): string
{
if ($resultNumber === null || $resultNumber === '') {
return '';
}
return (string) $resultNumber;
}
private function resolveWinHitCode(string $winAmount, int $playStatus): string
{
if ($playStatus > 0 && $playStatus !== 2) {
return 'pending';
}
if (bccomp($winAmount, '0', 2) > 0) {
return 'won';
}
return 'lost';
}
/**
* @param \think\db\Query $query
* @return array{record_count:int,total_bet_amount:string,total_win_amount:string}
*/
private function summarizePlayRecordQuery($query): array
{
$row = $query
->field('COUNT(pr.id) AS c, SUM(pr.total_amount) AS tb, SUM(pr.win_amount) AS tw, SUM(pr.jackpot_extra_amount) AS tj')
->find();
$count = is_array($row) ? intval($row['c'] ?? 0) : 0;
$tb = is_array($row) ? strval($row['tb'] ?? '0') : '0';
$tw = is_array($row) ? strval($row['tw'] ?? '0') : '0';
$tj = is_array($row) ? strval($row['tj'] ?? '0') : '0';
return [
'record_count' => $count,
'total_bet_amount' => bcadd($tb, '0', 2),
'total_win_amount' => bcadd(bcadd($tw, $tj, 2), '0', 2),
];
}
/**
* @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;
}
/**
* 佣金归属管理员:取该渠道下 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 (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'];
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;
}
/**
* @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;
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;
}
}