优化分红方式

This commit is contained in:
2026-04-17 16:46:38 +08:00
parent 2e0bcd3f23
commit 9954ea741b
15 changed files with 677 additions and 373 deletions

View File

@@ -16,7 +16,7 @@ class Channel extends Backend
/**
* 预览接口与手动结算共用「手动结算」按钮权限(避免额外菜单节点)
*/
protected array $noNeedPermission = ['manualSettlePreview'];
protected array $noNeedPermission = ['manualSettlePreview', 'channelAdminShareList', 'saveChannelAdminShare'];
/**
* Channel模型对象
@@ -322,6 +322,273 @@ class Channel extends Backend
return $this->success('', $payload);
}
/**
* 渠道管理员分配比例列表(用于结算二次分配配置)
*/
public function channelAdminShareList(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if (!$this->auth->check('channel/edit')) {
return $this->error(__('You have no permission'));
}
$id = (int) ($request->get('id', 0));
if ($id <= 0) {
return $this->error(__('Parameter error'));
}
$row = $this->model->find($id);
if (!$row) {
return $this->error(__('Record not found'));
}
if (!$this->auth->isSuperAdmin() && !in_array((int) $row['id'], $this->currentChannelIds, true)) {
return $this->error(__('You have no permission'));
}
$adminRows = Db::name('admin')
->field(['id', 'username', 'status'])
->where('channel_id', (int) $row['id'])
->order('id', 'asc')
->select()
->toArray();
$shareRows = Db::name('channel_admin_share')
->where('channel_id', (int) $row['id'])
->column(['share_rate', 'status'], 'admin_id');
$adminIds = [];
foreach ($adminRows as $adminRow) {
$aid = (int) ($adminRow['id'] ?? 0);
if ($aid > 0) {
$adminIds[] = $aid;
}
}
$adminIds = array_values(array_unique($adminIds));
$roleMetaMap = $this->resolveAdminRoleMetaForChannel((int) $row['id'], $adminIds);
$list = [];
foreach ($adminRows as $adminRow) {
$aid = (int) ($adminRow['id'] ?? 0);
if ($aid <= 0) {
continue;
}
$saved = $shareRows[$aid] ?? null;
$roleMeta = $roleMetaMap[$aid] ?? null;
$list[] = [
'admin_id' => $aid,
'username' => (string) ($adminRow['username'] ?? ''),
'role_group_name' => is_array($roleMeta) ? (string) ($roleMeta['role_group_name'] ?? '') : '',
'role_level' => is_array($roleMeta) ? (int) ($roleMeta['role_level'] ?? 9999) : 9999,
'admin_status' => (string) ($adminRow['status'] ?? ''),
'share_rate' => $saved['share_rate'] ?? null,
'status' => isset($saved['status']) ? (int) $saved['status'] : 1,
];
}
usort($list, static function (array $a, array $b): int {
$levelA = (int) ($a['role_level'] ?? 9999);
$levelB = (int) ($b['role_level'] ?? 9999);
if ($levelA !== $levelB) {
return $levelA <=> $levelB;
}
$idA = (int) ($a['admin_id'] ?? 0);
$idB = (int) ($b['admin_id'] ?? 0);
return $idA <=> $idB;
});
return $this->success('', [
'channel_id' => (int) $row['id'],
'channel_name' => (string) ($row['name'] ?? ''),
'list' => $list,
]);
}
/**
* @param array<int, int> $adminIds
* @return array<int, array{role_group_name:string,role_level:int}>
*/
private function resolveAdminRoleMetaForChannel(int $channelId, array $adminIds): array
{
if ($channelId <= 0 || $adminIds === []) {
return [];
}
$groupRows = Db::name('admin_group')
->field(['id', 'pid', 'name'])
->where('channel_id', $channelId)
->order('id', 'asc')
->select()
->toArray();
if ($groupRows === []) {
return [];
}
$groupMap = [];
foreach ($groupRows as $groupRow) {
$gid = (int) ($groupRow['id'] ?? 0);
if ($gid <= 0) {
continue;
}
$groupMap[$gid] = [
'pid' => (int) ($groupRow['pid'] ?? 0),
'name' => trim((string) ($groupRow['name'] ?? '')),
];
}
if ($groupMap === []) {
return [];
}
$groupDepthById = [];
$calcDepth = function (int $groupId) use (&$calcDepth, &$groupDepthById, $groupMap): int {
if (isset($groupDepthById[$groupId])) {
return $groupDepthById[$groupId];
}
$current = $groupMap[$groupId] ?? null;
if (!$current) {
$groupDepthById[$groupId] = 9999;
return 9999;
}
$pid = (int) ($current['pid'] ?? 0);
if ($pid <= 0 || !isset($groupMap[$pid])) {
$groupDepthById[$groupId] = 1;
return 1;
}
$depth = $calcDepth($pid) + 1;
$groupDepthById[$groupId] = $depth;
return $depth;
};
$accessRows = Db::name('admin_group_access')
->field(['uid', 'group_id'])
->where('uid', 'in', $adminIds)
->order('uid', 'asc')
->order('group_id', 'asc')
->select()
->toArray();
$metaMap = [];
foreach ($accessRows as $accessRow) {
$uid = (int) ($accessRow['uid'] ?? 0);
$groupId = (int) ($accessRow['group_id'] ?? 0);
if ($uid <= 0 || $groupId <= 0 || !isset($groupMap[$groupId])) {
continue;
}
$roleName = trim((string) ($groupMap[$groupId]['name'] ?? ''));
if ($roleName === '') {
continue;
}
$depth = $calcDepth($groupId);
if (!isset($metaMap[$uid])) {
$metaMap[$uid] = [
'role_group_name' => $roleName,
'role_level' => $depth,
'group_id' => $groupId,
];
continue;
}
$currentDepth = (int) ($metaMap[$uid]['role_level'] ?? 9999);
$currentGroupId = (int) ($metaMap[$uid]['group_id'] ?? 0);
if ($depth < $currentDepth || ($depth === $currentDepth && $groupId < $currentGroupId)) {
$metaMap[$uid]['role_group_name'] = $roleName;
$metaMap[$uid]['role_level'] = $depth;
$metaMap[$uid]['group_id'] = $groupId;
}
}
$out = [];
foreach ($metaMap as $uid => $meta) {
$out[$uid] = [
'role_group_name' => (string) ($meta['role_group_name'] ?? ''),
'role_level' => (int) ($meta['role_level'] ?? 9999),
];
}
return $out;
}
/**
* 保存渠道管理员分配比例(启用项总和必须=100
*/
public function saveChannelAdminShare(WebmanRequest $request): Response
{
$response = $this->initializeBackend($request);
if ($response !== null) {
return $response;
}
if (!$this->auth->check('channel/edit')) {
return $this->error(__('You have no permission'));
}
if ($request->method() !== 'POST') {
return $this->error(__('Parameter error'));
}
$id = (int) ($request->post('id', 0));
if ($id <= 0) {
return $this->error(__('Parameter error'));
}
$row = $this->model->find($id);
if (!$row) {
return $this->error(__('Record not found'));
}
if (!$this->auth->isSuperAdmin() && !in_array((int) $row['id'], $this->currentChannelIds, true)) {
return $this->error(__('You have no permission'));
}
$rowsRaw = $request->post('list', []);
if (!is_array($rowsRaw) || $rowsRaw === []) {
return $this->error('请至少配置一条分配记录');
}
$adminIds = Db::name('admin')
->where('channel_id', (int) $row['id'])
->column('id');
$adminIdSet = [];
foreach ($adminIds as $adminId) {
$adminIdSet[(int) $adminId] = true;
}
if ($adminIdSet === []) {
return $this->error('该渠道下暂无管理员,无法配置分配比例');
}
$enabledSum = '0.0000';
$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, 4);
if (bccomp($shareRate, '0', 4) < 0 || bccomp($shareRate, '100', 4) > 0) {
return $this->error('分配比例必须在0到100之间');
}
if ($status === 1) {
$enabledSum = bcadd($enabledSum, $shareRate, 4);
}
$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.0000', 4) !== 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('分配比例保存成功');
}
/**
* 手动结算(渠道维度):仅接收备注;周期与金额全部由服务端按注单汇总计算,并写入结算周期与佣金记录
*/
@@ -356,9 +623,9 @@ class Channel extends Backend
return $this->error('结算单号已存在,请稍后重试');
}
$adminId = $this->resolveCommissionAdminIdForChannel((int) $row['id']);
if ($adminId === null || $adminId <= 0) {
return $this->error('渠道下无归属管理员账号(请为管理员设置所属渠道),无法生成佣金记录');
$shareRows = $this->resolveCommissionSharesForChannel((int) $row['id']);
if ($shareRows === []) {
return $this->error('渠道下无可用管理员分配比例,无法生成佣金记录');
}
$now = time();
@@ -377,19 +644,19 @@ class Channel extends Backend
'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,
]);
$commissionRows = $this->buildCommissionRowsForSplit(
$shareRows,
(int) $row['id'],
$periodId,
(string) $payload['calc_base_amount'],
(string) $payload['commission_amount'],
trim($remark) !== '' ? $remark : ('手动结算佣金-CH' . $row['id']),
$now
);
if ($commissionRows === []) {
throw new \RuntimeException('分配比例拆分失败,未生成佣金记录');
}
Db::name('agent_commission_record')->insertAll($commissionRows);
Db::name('channel')->where('id', $row['id'])->update([
'update_time' => $now,
@@ -439,6 +706,9 @@ class Channel extends Backend
$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,
@@ -452,6 +722,7 @@ class Channel extends Backend
'calc_base_amount' => $commission['calc_base_amount'],
'commission_amount' => $commission['commission_amount'],
'agent_mode' => $mode,
'commission_split' => $splitPreview,
];
}
@@ -670,6 +941,140 @@ class Channel extends Backend
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.0000';
$out = [];
foreach ($rows as $row) {
$adminId = (int) ($row['admin_id'] ?? 0);
if ($adminId <= 0) {
continue;
}
$shareRate = self::normalizeAmountScale((string) ($row['share_rate'] ?? '0'), 4);
if (bccomp($shareRate, '0', 4) <= 0) {
continue;
}
$sum = bcadd($sum, $shareRate, 4);
$out[] = [
'admin_id' => $adminId,
'share_rate' => $shareRate,
];
}
if ($out !== [] && bccomp($sum, '100.0000', 4) === 0) {
return $out;
}
}
$fallbackAdminId = $this->resolveCommissionAdminIdForChannel($channelId);
if ($fallbackAdminId === null || $fallbackAdminId <= 0) {
return [];
}
return [[
'admin_id' => (int) $fallbackAdminId,
'share_rate' => '100.0000',
]];
}
/**
* @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.0000';
$rows = [];
$lastIndex = count($shareRows) - 1;
foreach ($shareRows as $index => $shareRow) {
$shareRate = self::normalizeAmountScale((string) ($shareRow['share_rate'] ?? '0'), 4);
$shareDec = bcdiv($shareRate, '100', 8);
$amount = $index === $lastIndex
? bcsub($commissionTotal, $sum, 4)
: bcmul($commissionTotal, $shareDec, 4);
if ($index !== $lastIndex) {
$sum = bcadd($sum, $amount, 4);
}
$effectiveRate = bccomp($calcBaseAmount, '0', 4) <= 0 ? '0.000000' : bcdiv($amount, $calcBaseAmount, 6);
$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.0000';
$out = [];
$lastIndex = count($shareRows) - 1;
foreach ($shareRows as $index => $shareRow) {
$shareRate = self::normalizeAmountScale((string) ($shareRow['share_rate'] ?? '0'), 4);
$shareDec = bcdiv($shareRate, '100', 8);
$amount = $index === $lastIndex
? bcsub($commissionTotal, $sum, 4)
: bcmul($commissionTotal, $shareDec, 4);
if ($index !== $lastIndex) {
$sum = bcadd($sum, $amount, 4);
}
$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;
@@ -763,6 +1168,35 @@ class Channel extends Backend
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;