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 $adminIds * @return array */ 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; } $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', '')); if ($this->auth->isSuperAdmin()) { $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('超管结算完成,渠道分红余额已入账'); } $res = ChannelSettlementService::settleDividendByChannelAdmin((int) $row['id'], intval($this->auth->id), $remark); 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 */ 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 $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 */ 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 $shareRows * @return array */ 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 $shareRows * @return array */ 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; } }