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 = $this->buildManualSettlePayload($row->toArray()); if (is_string($payload)) { return $this->error($payload); } return $this->success('', $payload); } /** * 手动结算(渠道维度):仅接收备注;周期与金额全部由服务端按注单汇总计算,并写入结算周期与佣金记录 */ public function manualSettle(WebmanRequest $request): Response { $response = $this->initializeBackend($request); if ($response !== null) { return $response; } $id = (int) ($request->post('id', $request->get('id', 0))); if ($id <= 0) { return $this->error(__('Parameter error')); } $row = $this->model->find($id); if (!$row) { return $this->error(__('Record not found')); } if (!$this->auth->isSuperAdmin() && !in_array((int) $row['id'], $this->currentChannelIds, true)) { return $this->error(__('You have no permission')); } $remark = (string) $request->post('remark', ''); $payload = $this->buildManualSettlePayload($row->toArray()); if (is_string($payload)) { return $this->error($payload); } $settlementNo = $payload['settlement_no']; if (Db::name('agent_settlement_period')->where('settlement_no', $settlementNo)->value('id')) { return $this->error('结算单号已存在,请稍后重试'); } $adminId = $this->resolveCommissionAdminIdForChannel((int) $row['id']); if ($adminId === null || $adminId <= 0) { return $this->error('渠道下无归属管理员账号(请为管理员设置所属渠道),无法生成佣金记录'); } $now = time(); Db::startTrans(); try { $periodId = (int) Db::name('agent_settlement_period')->insertGetId([ 'settlement_no' => $settlementNo, 'period_start_at' => $payload['period_start_ts'], 'period_end_at' => $payload['period_end_ts'], 'total_bet_amount' => $payload['total_bet_amount'], 'total_payout_amount' => $payload['total_payout_amount'], 'platform_profit_amount' => $payload['platform_profit_amount'], 'status' => 2, 'remark' => trim($remark) !== '' ? $remark : ('手动结算-渠道#' . $row['id'] . '-' . $row['name']), 'create_time' => $now, 'update_time' => $now, ]); Db::name('agent_commission_record')->insert([ 'settlement_period_id' => $periodId, 'channel_id' => (int) $row['id'], 'admin_id' => (int) $adminId, 'commission_rate' => $payload['commission_rate'], 'calc_base_amount' => $payload['calc_base_amount'], 'commission_amount' => $payload['commission_amount'], 'status' => 0, 'settled_at' => null, 'remark' => trim($remark) !== '' ? $remark : ('手动结算佣金-CH' . $row['id']), 'create_time' => $now, 'update_time' => $now, ]); Db::name('channel')->where('id', $row['id'])->update([ 'update_time' => $now, ]); Db::commit(); } catch (Throwable $e) { Db::rollback(); return $this->error($e->getMessage()); } return $this->success('手动结算已完成,已生成结算周期与佣金记录'); } /** * @return array|string 成功返回预览数据数组,失败返回错误文案 */ private function buildManualSettlePayload(array $row): array|string { $channelId = (int) ($row['id'] ?? 0); if ($channelId <= 0) { return '渠道数据异常'; } $endTs = time(); $lastEnd = $this->getLastSettlementEndForChannel($channelId); $channelCreateTs = (int) ($row['create_time'] ?? 0); if ($lastEnd === null) { $periodStartTs = $channelCreateTs > 0 ? $channelCreateTs : 0; } else { $periodStartTs = (int) $lastEnd; } if ($periodStartTs >= $endTs) { return '结算区间无效(开始时间不早于当前)'; } $stats = $this->aggregateBetOrderForChannel($channelId, $periodStartTs, $lastEnd !== null, $endTs); $totalBet = $stats['total_bet']; $totalPayout = $stats['total_payout']; $profit = bcsub($totalBet, $totalPayout, 4); $mode = (string) ($row['agent_mode'] ?? 'turnover'); $commission = $this->computeCommissionAmounts($row, $totalBet, $profit, $mode); if (is_string($commission)) { return $commission; } $settlementNo = $this->generateAgentSettlementNo('M', $channelId, $endTs); return [ 'settlement_no' => $settlementNo, 'period_start_ts' => $periodStartTs, 'period_end_ts' => $endTs, 'period_start_at' => date('Y-m-d H:i:s', $periodStartTs), 'period_end_at' => date('Y-m-d H:i:s', $endTs), 'total_bet_amount' => $totalBet, 'total_payout_amount' => $totalPayout, 'platform_profit_amount' => $profit, 'commission_rate' => $commission['commission_rate'], 'calc_base_amount' => $commission['calc_base_amount'], 'commission_amount' => $commission['commission_amount'], 'agent_mode' => $mode, ]; } /** * 生成代理结算周期单号:仅大写字母与数字、无分隔符;首字符 M=手动结算,A=自动结算(定时任务等复用) */ private function generateAgentSettlementNo(string $sourceFlag, int $channelId, int $endTs): string { $flag = strtoupper(trim($sourceFlag)); if ($flag !== 'M' && $flag !== 'A') { $flag = 'M'; } $channelPart = str_pad((string) max(0, $channelId), 6, '0', STR_PAD_LEFT); $timePart = str_pad((string) max(0, $endTs), 10, '0', STR_PAD_LEFT); $base = $flag . $channelPart . $timePart; for ($i = 0; $i < 8; $i++) { $randPart = strtoupper(substr(bin2hex(random_bytes(4)), 0, 8)); $no = $base . $randPart; if (!Db::name('agent_settlement_period')->where('settlement_no', $no)->value('id')) { return $no; } } return $base . strtoupper(substr(bin2hex(random_bytes(8)), 0, 16)); } private function getLastSettlementEndForChannel(int $channelId): ?int { $row = Db::name('agent_commission_record') ->alias('acr') ->join('agent_settlement_period asp', 'acr.settlement_period_id = asp.id') ->where('acr.channel_id', $channelId) ->field('MAX(asp.period_end_at) AS m') ->find(); if (!$row) { return null; } $m = $row['m'] ?? null; if ($m === null || $m === '') { return null; } return (int) $m; } /** * @return array{total_bet:string,total_payout:string} */ private function aggregateBetOrderForChannel(int $channelId, int $periodStartTs, bool $hasPriorSettlement, int $endTs): array { $query = Db::name('bet_order') ->where('channel_id', $channelId) ->where('status', 2) ->where('create_time', '<=', $endTs); if ($hasPriorSettlement) { $query->where('create_time', '>', $periodStartTs); } else { $query->where('create_time', '>=', $periodStartTs); } $row = $query->field('SUM(total_amount) AS tb, SUM(win_amount) AS tw, SUM(jackpot_extra_amount) AS tj')->find(); $tb = $row && $row['tb'] !== null && $row['tb'] !== '' ? (string) $row['tb'] : '0.0000'; $tw = $row && $row['tw'] !== null && $row['tw'] !== '' ? (string) $row['tw'] : '0.0000'; $tj = $row && $row['tj'] !== null && $row['tj'] !== '' ? (string) $row['tj'] : '0.0000'; $totalPayout = bcadd($tw, $tj, 4); return [ 'total_bet' => number_format((float) $tb, 4, '.', ''), 'total_payout' => number_format((float) $totalPayout, 4, '.', ''), ]; } /** * @return array{commission_rate:string,calc_base_amount:string,commission_amount:string}|string */ private function computeCommissionAmounts(array $row, string $totalBet, string $platformProfit, string $mode): array|string { if ($mode === 'turnover') { $ratePercent = $row['turnover_share_rate'] ?? null; if ($ratePercent === null || $ratePercent === '') { return '普通返水代理未配置返水分红比例'; } $rateDec = bcdiv((string) $ratePercent, '100', 6); $amount = bcmul($totalBet, $rateDec, 4); return [ 'commission_rate' => $rateDec, 'calc_base_amount' => $totalBet, 'commission_amount' => $amount, ]; } if ($mode === 'affiliate') { $fee = $row['affiliate_fee_rate'] ?? null; $rulesRaw = $row['affiliate_ladder_rules'] ?? null; if ($fee === null || $fee === '') { return '联营代理未配置成本扣除比例'; } $rules = $this->normalizeLadderRulesForSettlement($rulesRaw); if ($rules === []) { return '联营阶梯规则无效或为空'; } if (bccomp($platformProfit, '0', 4) <= 0) { return [ 'commission_rate' => '0.000000', 'calc_base_amount' => '0.0000', 'commission_amount' => '0.0000', ]; } $afterFee = bcmul($platformProfit, bcsub('1', (string) $fee, 8), 4); if (bccomp($afterFee, '0', 4) <= 0) { return [ 'commission_rate' => '0.000000', 'calc_base_amount' => '0.0000', 'commission_amount' => '0.0000', ]; } $playerLoss = $platformProfit; $share = $this->pickAffiliateShareRateFromLadder($rules, $playerLoss); $rateDec = number_format($share, 6, '.', ''); $amount = bcmul($afterFee, $rateDec, 4); return [ 'commission_rate' => $rateDec, 'calc_base_amount' => $afterFee, 'commission_amount' => $amount, ]; } return '未知的代理模式'; } /** * @return array */ private function normalizeLadderRulesForSettlement(mixed $rulesRaw): array { if ($rulesRaw === null || $rulesRaw === '') { return []; } if (is_string($rulesRaw)) { $decoded = json_decode($rulesRaw, true); $rulesRaw = is_array($decoded) ? $decoded : []; } if (!is_array($rulesRaw)) { return []; } $out = []; foreach ($rulesRaw as $rule) { if (!is_array($rule)) { continue; } $minLoss = $rule['minLoss'] ?? ($rule['min_loss'] ?? null); $shareRate = $rule['shareRate'] ?? ($rule['share_rate'] ?? null); if ($minLoss === null || $shareRate === null || !is_numeric((string) $minLoss) || !is_numeric((string) $shareRate)) { continue; } $out[] = [ 'minLoss' => number_format((float) $minLoss, 4, '.', ''), 'shareRate' => number_format((float) $shareRate, 6, '.', ''), ]; } usort($out, function ($a, $b) { return bccomp($a['minLoss'], $b['minLoss'], 4); }); return $out; } /** * @param array $rules */ private function pickAffiliateShareRateFromLadder(array $rules, string $playerLoss): float { $chosen = (float) $rules[0]['shareRate']; foreach ($rules as $rule) { if (bccomp($playerLoss, $rule['minLoss'], 4) >= 0) { $chosen = (float) $rule['shareRate']; } } return $chosen; } private function getCurrentChannelIds(): array { if ($this->auth->isSuperAdmin()) { 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; } 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 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; } }