| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239 |
- <?php
- namespace App\Services\Agent;
- use App\Models\Agent\Agent;
- use App\Models\Agent\AgentBalanceLog;
- use App\Models\Agent\AgentProfitCommissionProfile;
- use App\Models\Agent\AgentProfitEntry;
- use App\Models\Agent\AgentSetting;
- use Illuminate\Support\Facades\DB;
- /**
- * 代理资金唯一入口。
- *
- * 合营代理:总余额 balance = 保证金本金 margin_amount + 盈亏(可为负)。
- * - 充值全部计入保证金本金,不可提现;
- * - 盈亏为负时等于从保证金扣除,之后的盈利先补足保证金;
- * - 只有超出保证金本金的部分可以提现。
- * realtime_balance = balance + 尚未入账的实时分成,是判断「实时保证金」的内部字段,不对代理展示。
- */
- class AgentWalletService
- {
- /** 类型 => 方向(1 加,-1 减)。profit_share 单独走 creditProfitShare。 */
- private const DIRECTIONS = [
- 'credit' => 1, 'debit' => -1, 'recharge' => 1, 'withdraw_refund' => 1,
- 'equity_purchase' => -1, 'margin_credit' => 1, 'margin_refund' => -1,
- ];
- private array $lookups = [];
- public const TYPE_NAMES = [
- 'credit' => '人工加款', 'debit' => '人工扣款', 'recharge' => '充值', 'withdraw_refund' => '提现退回',
- 'withdraw_freeze' => '提现冻结', 'equity_purchase' => '购买股份', 'margin_credit' => '人工增加保证金',
- 'margin_refund' => '退还保证金', 'profit_share' => '合营分成', 'commission' => '佣金(旧)',
- ];
- public function adjust(
- int $agentId,
- string $amount,
- string $type,
- string $remark,
- string $idempotencyKey,
- string $operatorType,
- ?int $operatorId,
- string $referenceType = '',
- ?int $referenceId = null
- ): AgentBalanceLog {
- if (!isset(self::DIRECTIONS[$type])) {
- throw new \InvalidArgumentException('资金变动类型错误');
- }
- if (bccomp($amount, '0', 4) <= 0) {
- throw new \InvalidArgumentException('金额必须大于0');
- }
- return DB::transaction(function () use ($agentId, $amount, $type, $remark, $idempotencyKey, $operatorType, $operatorId, $referenceType, $referenceId) {
- $agent = Agent::query()->lockForUpdate()->findOrFail($agentId);
- $existing = AgentBalanceLog::query()->where('idempotency_key', $idempotencyKey)->lockForUpdate()->first();
- if ($existing) {
- $this->assertSameAdjustment($existing, $agentId, $type, $amount);
- return $existing;
- }
- $signed = self::DIRECTIONS[$type] > 0 ? bcadd($amount, '0', 4) : bcsub('0', $amount, 4);
- $before = bcadd((string) $agent->balance, '0', 4);
- $after = bcadd($before, $signed, 4);
- $realtime = bcadd((string) $agent->realtime_balance, $signed, 4);
- // 扣款后,待入账分成按结算日依次入账时余额也不能为负(同时保证实时余额不为负)。
- if (self::DIRECTIONS[$type] < 0 && bccomp(bcadd($after, $this->pendingFloor($agent), 4), '0', 4) < 0) {
- throw new \RuntimeException('代理余额不足');
- }
- $agent->balance = $after;
- $agent->realtime_balance = $realtime;
- if (($type === 'recharge' && $agent->isJoint()) || $type === 'margin_credit') {
- $agent->margin_amount = bcadd((string) $agent->margin_amount, $amount, 4);
- } elseif ($type === 'margin_refund') {
- if (bccomp((string) $agent->margin_amount, $amount, 4) < 0) {
- throw new \RuntimeException('退还金额不能超过保证金');
- }
- $agent->margin_amount = bcsub((string) $agent->margin_amount, $amount, 4);
- }
- $agent->save();
- $log = AgentBalanceLog::query()->create([
- 'agent_id' => $agentId, 'type' => $type, 'amount' => $signed,
- 'before_balance' => $before, 'after_balance' => $after,
- 'operator_type' => $operatorType, 'operator_id' => $operatorId,
- 'idempotency_key' => $idempotencyKey, 'remark' => $remark,
- 'reference_type' => $referenceType, 'reference_id' => $referenceId,
- ]);
- $this->evaluateActivation($agent);
- return $log;
- });
- }
- /** 每日入账合营分成:实时余额在注单结算时已计入,这里只改 balance。调用方需已在事务内锁住代理。 */
- public function creditProfitShare(Agent $agent, string $signed, string $idempotencyKey, int $recordId, string $remark): ?AgentBalanceLog
- {
- if (bccomp($signed, '0', 4) === 0) return null;
- $existing = AgentBalanceLog::query()->where('idempotency_key', $idempotencyKey)->lockForUpdate()->first();
- if ($existing) return $existing;
- $before = bcadd((string) $agent->balance, '0', 4);
- $agent->balance = bcadd($before, $signed, 4);
- $agent->save();
- return AgentBalanceLog::query()->create([
- 'agent_id' => $agent->id, 'type' => 'profit_share', 'amount' => bcadd($signed, '0', 4),
- 'before_balance' => $before, 'after_balance' => $agent->balance,
- 'operator_type' => 'system', 'operator_id' => null,
- 'idempotency_key' => $idempotencyKey, 'remark' => $remark,
- 'reference_type' => 'agent_rebate_record', 'reference_id' => $recordId,
- ]);
- }
- /**
- * 合营分成开关。实时有效保证金 = min(保证金本金, 实时余额):
- * - 达到方案保证金 100% 才开启;
- * - 开启后低于方案保证金 × 停止比例(默认 10%)即关闭,之后的注单不再分成。
- */
- public function evaluateActivation(Agent $agent): bool
- {
- if (!$agent->isJoint()) {
- return $this->setActive($agent, false);
- }
- $required = $agent->profit_commission_profile_id ? $this->requiredMargin((int) $agent->profit_commission_profile_id) : null;
- if ($required === null) {
- return $this->setActive($agent, false);
- }
- $effective = $this->effectiveMargin($agent);
- if (!(int) $agent->profit_active) {
- return $this->setActive($agent, bccomp($effective, $required, 4) >= 0);
- }
- $stop = bcdiv(bcmul($required, $this->lookup('stop', fn () => (string) AgentSetting::current()->margin_stop_percent), 8), '100', 4);
- return $this->setActive($agent, bccomp($effective, $stop, 4) >= 0);
- }
- /** 方案要求的保证金;方案不存在返回 null。 */
- private function requiredMargin(int $profileId): ?string
- {
- $margin = $this->lookup('profile:' . $profileId, function () use ($profileId) {
- $value = AgentProfitCommissionProfile::query()->whereKey($profileId)->value('margin_amount');
- return $value === null ? false : bcadd((string) $value, '0', 4);
- });
- return $margin === false ? null : $margin;
- }
- /**
- * 方案保证金和停止比例的短时缓存(同一实例内 30 秒),避免实时分成逐笔查询时在代理行锁内反复查库。
- * 总后台修改方案或配置在其他请求中完成,最多 30 秒后生效到分成任务。
- */
- private function lookup(string $key, callable $resolve)
- {
- $now = time();
- if (!isset($this->lookups[$key]) || $this->lookups[$key][1] < $now) {
- $this->lookups[$key] = [$resolve(), $now + 30];
- }
- return $this->lookups[$key][0];
- }
- public function effectiveMargin(Agent $agent): string
- {
- $margin = bcadd((string) $agent->margin_amount, '0', 4);
- $realtime = bcadd((string) $agent->realtime_balance, '0', 4);
- $value = bccomp($realtime, $margin, 4) < 0 ? $realtime : $margin;
- return bccomp($value, '0', 4) < 0 ? '0.0000' : $value;
- }
- /** 代理端可见的资金构成(不含实时余额)。 */
- public function funds(Agent $agent): array
- {
- $balance = bcadd((string) $agent->balance, '0', 4);
- $margin = bcadd((string) $agent->margin_amount, '0', 4);
- $data = [
- 'balance' => $balance,
- 'frozen_balance' => bcadd((string) $agent->frozen_balance, '0', 4),
- 'withdrawable_amount' => $this->withdrawable($agent),
- ];
- if ($agent->isJoint()) {
- $marginBalance = bccomp($balance, $margin, 4) < 0 ? $balance : $margin;
- $data += [
- 'margin_amount' => $margin,
- 'margin_balance' => bccomp($marginBalance, '0', 4) < 0 ? '0.0000' : $marginBalance,
- 'profit_amount' => bcsub($balance, $margin, 4),
- ];
- } else {
- $data['equity_percent'] = bcadd((string) $agent->equity_percent, '0', 4);
- }
- return $data;
- }
- /**
- * 可提现金额。合营:总余额加上「待入账亏损的最坏情况」后,超出保证金本金的部分;入股:全部余额。
- */
- public function withdrawable(Agent $agent): string
- {
- $balance = bcadd((string) $agent->balance, '0', 4);
- if (!$agent->isJoint()) {
- return bccomp($balance, '0', 4) > 0 ? $balance : '0.0000';
- }
- $available = bcsub(bcadd($balance, $this->pendingFloor($agent), 4), (string) $agent->margin_amount, 4);
- return bccomp($available, '0', 4) > 0 ? $available : '0.0000';
- }
- /**
- * 待入账分成按结算日依次入账时,余额最多会下降多少(≤0)。
- * 取未入账分成按结算日的前缀和的最小值;传 $fromDate 时只看该日及之后的入账点
- * (新增或修改该日分成会影响这些点),用于限制亏损扣减。
- */
- public function pendingFloor(Agent $agent, ?string $fromDate = null): string
- {
- if (!$agent->isJoint()) return '0.0000';
- $sums = AgentProfitEntry::query()->toBase()->where('agent_id', $agent->id)
- ->where('status', AgentProfitEntry::STATUS_COUNTED)->whereNull('rebate_record_id')
- ->groupBy('settle_date')->orderBy('settle_date')
- ->selectRaw('settle_date, COALESCE(SUM(share_amount),0) total')->get();
- $running = '0.0000';
- $floor = $fromDate === null ? '0.0000' : null;
- foreach ($sums as $row) {
- if ($floor === null && substr((string) $row->settle_date, 0, 10) >= $fromDate) $floor = $running;
- $running = bcadd($running, (string) $row->total, 4);
- if ($floor !== null && bccomp($running, $floor, 4) < 0) $floor = $running;
- }
- return $floor ?? $running;
- }
- private function setActive(Agent $agent, bool $active): bool
- {
- if ((int) $agent->profit_active !== ($active ? 1 : 0)) {
- $agent->profit_active = $active ? 1 : 0;
- $agent->profit_active_changed_at = now();
- $agent->save();
- }
- return $active;
- }
- private function assertSameAdjustment(AgentBalanceLog $log, int $agentId, string $type, string $amount): void
- {
- $signed = self::DIRECTIONS[$type] > 0 ? bcadd($amount, '0', 4) : bcsub('0', $amount, 4);
- if ((int) $log->agent_id !== $agentId || $log->type !== $type || bccomp((string) $log->amount, $signed, 4) !== 0) {
- throw new \RuntimeException('request_id 已用于其他资金调整');
- }
- }
- }
|