474 lines
18 KiB
PHP
474 lines
18 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace app\common\service;
|
||
|
||
use support\think\Db;
|
||
|
||
/**
|
||
* 代理管理员树形分红:子代理从上级分红中按 commission_share_rate 抽取,上级保留剩余部分
|
||
*/
|
||
class AdminCommissionDistributionService
|
||
{
|
||
/**
|
||
* @return int[]
|
||
*/
|
||
public static function getDescendantAdminIds(int $adminId): array
|
||
{
|
||
if ($adminId <= 0) {
|
||
return [];
|
||
}
|
||
$childIds = Db::name('admin')
|
||
->where('parent_admin_id', $adminId)
|
||
->where('status', 'enable')
|
||
->column('id');
|
||
$all = [];
|
||
foreach ($childIds as $cid) {
|
||
$cid = intval($cid);
|
||
if ($cid <= 0) {
|
||
continue;
|
||
}
|
||
$all[] = $cid;
|
||
foreach (self::getDescendantAdminIds($cid) as $descId) {
|
||
$all[] = $descId;
|
||
}
|
||
}
|
||
return $all;
|
||
}
|
||
|
||
/**
|
||
* 非超管可见管理员 ID;超管返回空数组表示不限制
|
||
*
|
||
* @return int[]
|
||
*/
|
||
public static function getVisibleAdminIdsForOperator(int $operatorAdminId, bool $isSuperAdmin): array
|
||
{
|
||
if ($isSuperAdmin || $operatorAdminId <= 0) {
|
||
return [];
|
||
}
|
||
$ids = self::getDescendantAdminIds($operatorAdminId);
|
||
$ids[] = $operatorAdminId;
|
||
return array_values(array_unique($ids));
|
||
}
|
||
|
||
/**
|
||
* @return array{used_rate:string,remaining_rate:string}
|
||
*/
|
||
public static function getShareRemainder(int $parentAdminId, ?int $excludeAdminId = null): array
|
||
{
|
||
$used = '0.00';
|
||
if ($parentAdminId <= 0) {
|
||
return ['used_rate' => $used, 'remaining_rate' => '100.00'];
|
||
}
|
||
$query = Db::name('admin')
|
||
->where('parent_admin_id', $parentAdminId)
|
||
->where('status', 'enable');
|
||
if ($excludeAdminId !== null && $excludeAdminId > 0) {
|
||
$query->where('id', '<>', $excludeAdminId);
|
||
}
|
||
$rows = $query->column('commission_share_rate');
|
||
foreach ($rows as $rate) {
|
||
if ($rate === null || $rate === '') {
|
||
continue;
|
||
}
|
||
$used = bcadd($used, bcadd(strval($rate), '0', 2), 2);
|
||
}
|
||
$remaining = bcsub('100.00', $used, 2);
|
||
if (bccomp($remaining, '0', 2) < 0) {
|
||
$remaining = '0.00';
|
||
}
|
||
return ['used_rate' => $used, 'remaining_rate' => $remaining];
|
||
}
|
||
|
||
/**
|
||
* 同渠道下顶级代理(无上级)已占用的渠道分红比例
|
||
*
|
||
* @return array{used_rate:string,remaining_rate:string}
|
||
*/
|
||
public static function getChannelRootShareRemainder(int $channelId, ?int $excludeAdminId = null): array
|
||
{
|
||
$used = '0.00';
|
||
if ($channelId <= 0) {
|
||
return ['used_rate' => $used, 'remaining_rate' => '100.00'];
|
||
}
|
||
$query = Db::name('admin')
|
||
->where('channel_id', $channelId)
|
||
->where('status', 'enable')
|
||
->whereRaw('(parent_admin_id IS NULL OR parent_admin_id = 0)');
|
||
if ($excludeAdminId !== null && $excludeAdminId > 0) {
|
||
$query->where('id', '<>', $excludeAdminId);
|
||
}
|
||
$rows = $query->column('commission_share_rate');
|
||
foreach ($rows as $rate) {
|
||
if ($rate === null || $rate === '') {
|
||
continue;
|
||
}
|
||
$used = bcadd($used, bcadd(strval($rate), '0', 2), 2);
|
||
}
|
||
$remaining = bcsub('100.00', $used, 2);
|
||
if (bccomp($remaining, '0', 2) < 0) {
|
||
$remaining = '0.00';
|
||
}
|
||
return ['used_rate' => $used, 'remaining_rate' => $remaining];
|
||
}
|
||
|
||
public static function validateChannelRootCommissionShareRate(int $channelId, mixed $rateRaw, ?int $excludeAdminId = null): ?string
|
||
{
|
||
if ($channelId <= 0) {
|
||
return (string) __('Channel is required for top-level agent commission share');
|
||
}
|
||
if ($rateRaw === null || $rateRaw === '') {
|
||
return (string) __('Top-level agent commission share rate is required');
|
||
}
|
||
$rate = bcadd(strval($rateRaw), '0', 2);
|
||
if (bccomp($rate, '0', 2) <= 0 || bccomp($rate, '100', 2) > 0) {
|
||
return (string) __('Commission share rate must be between 0 and 100');
|
||
}
|
||
$remainder = self::getChannelRootShareRemainder($channelId, $excludeAdminId);
|
||
if (bccomp(bcadd($remainder['used_rate'], $rate, 2), '100.00', 2) > 0) {
|
||
return (string) __('Sum of channel top-level commission share rates cannot exceed 100%');
|
||
}
|
||
return null;
|
||
}
|
||
|
||
public static function validateCommissionShareRate(?int $parentAdminId, mixed $rateRaw, ?int $excludeAdminId = null): ?string
|
||
{
|
||
if ($parentAdminId === null || $parentAdminId <= 0) {
|
||
return null;
|
||
}
|
||
if ($rateRaw === null || $rateRaw === '') {
|
||
return (string) __('Sub-agent commission share rate is required');
|
||
}
|
||
$rate = bcadd(strval($rateRaw), '0', 2);
|
||
if (bccomp($rate, '0', 2) < 0 || bccomp($rate, '100', 2) > 0) {
|
||
return (string) __('Commission share rate must be between 0 and 100');
|
||
}
|
||
$remainder = self::getShareRemainder($parentAdminId, $excludeAdminId);
|
||
if (bccomp(bcadd($remainder['used_rate'], $rate, 2), '100.00', 2) > 0) {
|
||
return (string) __('Sum of sibling commission share rates cannot exceed 100%');
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 代理费前佣金占渠道本期总佣金的比例(%)
|
||
*/
|
||
public static function calcCommissionSharePercent(string $gross, string $totalCommission): string
|
||
{
|
||
if (bccomp($totalCommission, '0', 2) <= 0 || bccomp($gross, '0', 2) <= 0) {
|
||
return '0.00';
|
||
}
|
||
return bcdiv(bcmul($gross, '100', 4), $totalCommission, 2);
|
||
}
|
||
|
||
/**
|
||
* 按百分比计算手续费金额:手续费 = 费前佣金 × (费率% / 100)
|
||
*/
|
||
public static function calcHandlingFeeAmount(string $gross, string $ratePercent): string
|
||
{
|
||
$rate = bcadd($ratePercent, '0', 2);
|
||
if (bccomp($gross, '0', 2) <= 0 || bccomp($rate, '0', 2) <= 0) {
|
||
return '0.00';
|
||
}
|
||
if (bccomp($rate, '100', 2) > 0) {
|
||
$rate = '100.00';
|
||
}
|
||
return bcmul($gross, bcdiv($rate, '100', 4), 2);
|
||
}
|
||
|
||
/**
|
||
* 将渠道本期总佣金按管理员树分配,返回各管理员实得金额(费前)
|
||
*
|
||
* @param array<int, string>|null $handlingFeeRateByAdmin admin_id => 手续费比例(%)
|
||
* @return array<int, array{admin_id:int,commission_amount:string,commission_rate:string,calc_base_amount:string,handling_fee_rate:string,handling_fee:string,net_commission_amount:string}>
|
||
*/
|
||
public static function distributeChannelCommission(
|
||
int $channelId,
|
||
string $totalCommission,
|
||
string $calcBaseAmount,
|
||
string $defaultHandlingFeeRate = '0.00',
|
||
?array $handlingFeeRateByAdmin = null
|
||
): array {
|
||
$nodes = self::collectHierarchicalNodes($channelId, $totalCommission);
|
||
if ($nodes === []) {
|
||
return [];
|
||
}
|
||
$defaultRate = self::normalizeHandlingFeeRatePercent($defaultHandlingFeeRate);
|
||
$merged = [];
|
||
foreach ($nodes as $node) {
|
||
$adminId = intval($node['admin_id'] ?? 0);
|
||
if ($adminId <= 0) {
|
||
continue;
|
||
}
|
||
$gross = strval($node['commission_amount'] ?? '0.00');
|
||
if (bccomp($gross, '0', 2) <= 0) {
|
||
continue;
|
||
}
|
||
$settlementBase = strval($node['settlement_base_amount'] ?? '0.00');
|
||
$feeRate = $defaultRate;
|
||
if ($handlingFeeRateByAdmin !== null && isset($handlingFeeRateByAdmin[$adminId])) {
|
||
$feeRate = self::normalizeHandlingFeeRatePercent(strval($handlingFeeRateByAdmin[$adminId]));
|
||
}
|
||
$feeAmount = self::calcHandlingFeeAmount($gross, $feeRate);
|
||
$net = bcsub($gross, $feeAmount, 2);
|
||
if (bccomp($net, '0', 2) < 0) {
|
||
$net = '0.00';
|
||
}
|
||
$effectiveRate = bccomp($settlementBase, '0', 2) <= 0
|
||
? '0.0000'
|
||
: bcdiv($gross, $settlementBase, 6);
|
||
$merged[$adminId] = [
|
||
'admin_id' => $adminId,
|
||
'commission_amount' => $gross,
|
||
'commission_rate' => $effectiveRate,
|
||
'calc_base_amount' => $settlementBase,
|
||
'commission_share_percent' => self::calcCommissionSharePercent($gross, $totalCommission),
|
||
'handling_fee_rate' => $feeRate,
|
||
'handling_fee' => $feeAmount,
|
||
'net_commission_amount' => $net,
|
||
];
|
||
}
|
||
return array_values($merged);
|
||
}
|
||
|
||
/**
|
||
* 层级分配预览(先序遍历),含结算基数与手续费
|
||
*
|
||
* @param array<int, string>|null $handlingFeeRateByAdmin admin_id => 手续费比例(%)
|
||
* @return array<int, array<string, mixed>>
|
||
*/
|
||
public static function buildSplitPreview(
|
||
int $channelId,
|
||
string $commissionTotal,
|
||
string $calcBaseAmount,
|
||
string $defaultHandlingFeeRate = '0.00',
|
||
?array $handlingFeeRateByAdmin = null
|
||
): array {
|
||
unset($calcBaseAmount);
|
||
$nodes = self::collectHierarchicalNodes($channelId, $commissionTotal);
|
||
if ($nodes === []) {
|
||
return [];
|
||
}
|
||
$defaultRate = self::normalizeHandlingFeeRatePercent($defaultHandlingFeeRate);
|
||
$out = [];
|
||
foreach ($nodes as $node) {
|
||
$adminId = intval($node['admin_id'] ?? 0);
|
||
if ($adminId <= 0) {
|
||
continue;
|
||
}
|
||
$gross = strval($node['commission_amount'] ?? '0.00');
|
||
$feeRate = $defaultRate;
|
||
if ($handlingFeeRateByAdmin !== null && isset($handlingFeeRateByAdmin[$adminId])) {
|
||
$feeRate = self::normalizeHandlingFeeRatePercent(strval($handlingFeeRateByAdmin[$adminId]));
|
||
}
|
||
$feeAmount = self::calcHandlingFeeAmount($gross, $feeRate);
|
||
$net = bcsub($gross, $feeAmount, 2);
|
||
if (bccomp($net, '0', 2) < 0) {
|
||
$net = '0.00';
|
||
}
|
||
$out[] = [
|
||
'admin_id' => $adminId,
|
||
'admin_username' => strval($node['admin_username'] ?? ('#' . $adminId)),
|
||
'parent_admin_id' => intval($node['parent_admin_id'] ?? 0),
|
||
'level' => intval($node['level'] ?? 0),
|
||
'settlement_base_amount' => strval($node['settlement_base_amount'] ?? '0.00'),
|
||
'share_rate' => strval($node['share_rate'] ?? '0.00'),
|
||
'commission_amount' => $gross,
|
||
'commission_share_percent' => self::calcCommissionSharePercent($gross, $commissionTotal),
|
||
'handling_fee_rate' => $feeRate,
|
||
'handling_fee' => $feeAmount,
|
||
'net_commission_amount' => $net,
|
||
];
|
||
}
|
||
return $out;
|
||
}
|
||
|
||
private static function normalizeHandlingFeeRatePercent(string $rateRaw): string
|
||
{
|
||
$rate = bcadd($rateRaw, '0', 2);
|
||
if (bccomp($rate, '0', 2) < 0) {
|
||
return '0.00';
|
||
}
|
||
if (bccomp($rate, '100', 2) > 0) {
|
||
return '100.00';
|
||
}
|
||
return $rate;
|
||
}
|
||
|
||
/**
|
||
* @return array<int, array{admin_id:int,admin_username:string,parent_admin_id:int,level:int,settlement_base_amount:string,share_rate:string,commission_amount:string}>
|
||
*/
|
||
private static function collectHierarchicalNodes(int $channelId, string $totalCommission): array
|
||
{
|
||
if ($channelId <= 0 || bccomp($totalCommission, '0', 2) <= 0) {
|
||
return [];
|
||
}
|
||
$rootRows = Db::name('admin')
|
||
->where('channel_id', $channelId)
|
||
->where('status', 'enable')
|
||
->whereRaw('(parent_admin_id IS NULL OR parent_admin_id = 0)')
|
||
->order('id', 'asc')
|
||
->field(['id', 'commission_share_rate', 'username'])
|
||
->select()
|
||
->toArray();
|
||
if ($rootRows === []) {
|
||
return [];
|
||
}
|
||
$useRateSplit = true;
|
||
foreach ($rootRows as $rootRow) {
|
||
$rate = bcadd(strval($rootRow['commission_share_rate'] ?? '0'), '0', 2);
|
||
if (bccomp($rate, '0', 2) <= 0) {
|
||
$useRateSplit = false;
|
||
break;
|
||
}
|
||
}
|
||
$nodes = [];
|
||
if ($useRateSplit) {
|
||
foreach ($rootRows as $rootRow) {
|
||
$rootId = intval($rootRow['id'] ?? 0);
|
||
if ($rootId <= 0) {
|
||
continue;
|
||
}
|
||
$rate = bcadd(strval($rootRow['commission_share_rate'] ?? '0'), '0', 2);
|
||
$rootAmount = bcmul($totalCommission, bcdiv($rate, '100', 4), 2);
|
||
if (bccomp($rootAmount, '0', 2) <= 0) {
|
||
continue;
|
||
}
|
||
self::appendNodeTree($rootId, $rootAmount, 0, 0, $rate, $nodes);
|
||
}
|
||
} else {
|
||
$rootCount = count($rootRows);
|
||
$perRoot = bcdiv($totalCommission, strval($rootCount), 2);
|
||
$assigned = '0.00';
|
||
foreach ($rootRows as $index => $rootRow) {
|
||
$rootId = intval($rootRow['id'] ?? 0);
|
||
if ($rootId <= 0) {
|
||
continue;
|
||
}
|
||
$isLast = $index === $rootCount - 1;
|
||
$rootAmount = $isLast ? bcsub($totalCommission, $assigned, 2) : $perRoot;
|
||
if (!$isLast) {
|
||
$assigned = bcadd($assigned, $rootAmount, 2);
|
||
}
|
||
if (bccomp($rootAmount, '0', 2) <= 0) {
|
||
continue;
|
||
}
|
||
self::appendNodeTree($rootId, $rootAmount, 0, 0, '100.00', $nodes);
|
||
}
|
||
}
|
||
return $nodes;
|
||
}
|
||
|
||
/**
|
||
* @param array<int, array{admin_id:int,admin_username:string,parent_admin_id:int,level:int,settlement_base_amount:string,share_rate:string,commission_amount:string}> $nodes
|
||
*/
|
||
private static function appendNodeTree(
|
||
int $adminId,
|
||
string $incomingAmount,
|
||
int $level,
|
||
int $parentAdminId,
|
||
string $shareRateFromParent,
|
||
array &$nodes
|
||
): void {
|
||
if ($adminId <= 0 || bccomp($incomingAmount, '0', 2) <= 0) {
|
||
return;
|
||
}
|
||
$adminRow = Db::name('admin')
|
||
->where('id', $adminId)
|
||
->field(['id', 'username', 'commission_share_rate'])
|
||
->find();
|
||
if (!is_array($adminRow)) {
|
||
return;
|
||
}
|
||
$children = Db::name('admin')
|
||
->where('parent_admin_id', $adminId)
|
||
->where('status', 'enable')
|
||
->order('id', 'asc')
|
||
->field(['id', 'commission_share_rate'])
|
||
->select()
|
||
->toArray();
|
||
$givenToChildren = '0.00';
|
||
$childPlans = [];
|
||
foreach ($children as $child) {
|
||
$childId = intval($child['id'] ?? 0);
|
||
if ($childId <= 0) {
|
||
continue;
|
||
}
|
||
$rate = bcadd(strval($child['commission_share_rate'] ?? '0'), '0', 2);
|
||
if (bccomp($rate, '0', 2) <= 0) {
|
||
continue;
|
||
}
|
||
$childAmount = bcmul($incomingAmount, bcdiv($rate, '100', 4), 2);
|
||
if (bccomp($childAmount, '0', 2) <= 0) {
|
||
continue;
|
||
}
|
||
$givenToChildren = bcadd($givenToChildren, $childAmount, 2);
|
||
$childPlans[] = ['id' => $childId, 'amount' => $childAmount, 'rate' => $rate];
|
||
}
|
||
$selfKeep = bcsub($incomingAmount, $givenToChildren, 2);
|
||
$nodes[] = [
|
||
'admin_id' => $adminId,
|
||
'admin_username' => strval($adminRow['username'] ?? ('#' . $adminId)),
|
||
'parent_admin_id' => $parentAdminId,
|
||
'level' => $level,
|
||
'settlement_base_amount' => $incomingAmount,
|
||
'share_rate' => $shareRateFromParent,
|
||
'commission_amount' => bccomp($selfKeep, '0', 2) > 0 ? $selfKeep : '0.00',
|
||
];
|
||
foreach ($childPlans as $plan) {
|
||
self::appendNodeTree(intval($plan['id']), strval($plan['amount']), $level + 1, $adminId, strval($plan['rate']), $nodes);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @return array<int, string> admin_id => amount
|
||
*/
|
||
private static function distributeFromAdmin(int $adminId, string $amount, string $calcBaseAmount): array
|
||
{
|
||
unset($calcBaseAmount);
|
||
if ($adminId <= 0 || bccomp($amount, '0', 2) <= 0) {
|
||
return [];
|
||
}
|
||
$children = Db::name('admin')
|
||
->where('parent_admin_id', $adminId)
|
||
->where('status', 'enable')
|
||
->order('id', 'asc')
|
||
->field(['id', 'commission_share_rate'])
|
||
->select()
|
||
->toArray();
|
||
$givenToChildren = '0.00';
|
||
$result = [];
|
||
foreach ($children as $child) {
|
||
$childId = intval($child['id'] ?? 0);
|
||
if ($childId <= 0) {
|
||
continue;
|
||
}
|
||
$rate = bcadd(strval($child['commission_share_rate'] ?? '0'), '0', 2);
|
||
if (bccomp($rate, '0', 2) <= 0) {
|
||
continue;
|
||
}
|
||
$childAmount = bcmul($amount, bcdiv($rate, '100', 4), 2);
|
||
if (bccomp($childAmount, '0', 2) <= 0) {
|
||
continue;
|
||
}
|
||
$givenToChildren = bcadd($givenToChildren, $childAmount, 2);
|
||
$childParts = self::distributeFromAdmin($childId, $childAmount, '0.00');
|
||
foreach ($childParts as $aid => $part) {
|
||
if (!isset($result[$aid])) {
|
||
$result[$aid] = '0.00';
|
||
}
|
||
$result[$aid] = bcadd($result[$aid], $part, 2);
|
||
}
|
||
}
|
||
$selfKeep = bcsub($amount, $givenToChildren, 2);
|
||
if (bccomp($selfKeep, '0', 2) > 0) {
|
||
if (!isset($result[$adminId])) {
|
||
$result[$adminId] = '0.00';
|
||
}
|
||
$result[$adminId] = bcadd($result[$adminId], $selfKeep, 2);
|
||
}
|
||
return $result;
|
||
}
|
||
}
|