| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174 |
- <?php
- namespace App\Services\Agent;
- use App\Models\Agent\Agent;
- use App\Models\Agent\AgentProfitCommissionProfile;
- use App\Models\Agent\AgentProfitEntry;
- use App\Services\Sharing\EffectiveBet;
- use Carbon\Carbon;
- use Illuminate\Support\Facades\DB;
- use Illuminate\Support\Facades\Log;
- use Throwable;
- /**
- * 合营实时分成:逐笔读取已结算注单(share_bet_facts),按代理当前方案比例计算分成并实时计入 realtime_balance。
- *
- * - 会员输 100、比例 10%:代理 +10;会员赢 100:代理 -10;
- * - 代理分成关闭(实时保证金不足)时注单记为 skipped_inactive,不再分成;
- * - 亏损最多扣到实时余额为 0;
- * - 注单改派彩或作废:未入账的原地修正,已入账的追加 revision 冲正。
- * share_bet_facts 由 share:sync-facts 维护(含 melbet 结算补偿),这里按 updated_at 游标增量读取。
- */
- class AgentProfitLedgerService
- {
- private const CURSOR = 'agent_profit_cursor';
- public function __construct(
- private AgentWalletService $wallets,
- private AgentProfitCommissionAssignmentService $assignments
- ) {
- }
- public function sync(): array
- {
- $startedAt = now()->format('Y-m-d H:i:s');
- $cursor = DB::table('share_system_state')->where('key', self::CURSOR)->value('value');
- $since = $cursor
- ? Carbon::parse(json_decode((string) $cursor, true, 512, JSON_THROW_ON_ERROR)['updated_at'])->subMinutes(5)
- : now()->subDays(2);
- $result = ['facts' => 0, 'counted' => 0, 'skipped' => 0, 'corrected' => 0, 'unchanged' => 0, 'failed' => 0];
- DB::table('share_bet_facts')->where('updated_at', '>=', $since->format('Y-m-d H:i:s'))
- ->orderBy('id')->chunkById(500, function ($facts) use (&$result) {
- $agentByUser = DB::table('users')->join('agents', 'agents.id', '=', 'users.agent_id')
- ->whereIn('users.id', $facts->pluck('user_id')->unique()->all())
- ->where('agents.type', Agent::TYPE_JOINT)
- ->pluck('agents.id', 'users.id');
- foreach ($facts as $fact) {
- $agentId = $agentByUser[$fact->user_id] ?? null;
- if (!$agentId) continue;
- ++$result['facts'];
- try {
- ++$result[$this->process($fact, (int) $agentId)];
- } catch (Throwable $e) {
- ++$result['failed'];
- Log::error('agent_profit_entry_failed', ['fact_id' => $fact->id, 'agent_id' => $agentId, 'exception' => $e]);
- }
- }
- });
- if ($result['failed'] === 0) {
- DB::table('share_system_state')->updateOrInsert(['key' => self::CURSOR],
- ['value' => json_encode(['updated_at' => $startedAt]), 'updated_at' => $startedAt, 'created_at' => $startedAt]);
- }
- return $result;
- }
- /** @return string counted|skipped|corrected|unchanged */
- public function process(object $fact, int $agentId): string
- {
- return DB::transaction(function () use ($fact, $agentId) {
- $agent = Agent::query()->lockForUpdate()->find($agentId);
- if (!$agent || !$agent->isJoint()) return 'unchanged';
- $entries = AgentProfitEntry::query()->where('fact_id', $fact->id)->orderBy('revision')->lockForUpdate()->get();
- $live = (int) $fact->settled === 1 && (int) $fact->voided === 0 && !empty($fact->settled_at);
- $net = $live ? $this->money($fact->net_profit) : '0.0000';
- if ($entries->isEmpty()) {
- return $live ? $this->create($agent, $fact, $net) : 'unchanged';
- }
- return $this->correct($agent, $fact, $entries, $net);
- }, 3);
- }
- private function create(Agent $agent, object $fact, string $net): string
- {
- $category = (string) $fact->category;
- $assignment = $this->assignments->at($agent, (string) $fact->settled_at);
- $status = AgentProfitEntry::STATUS_COUNTED;
- $rate = '0.0000';
- if (!isset(AgentProfitCommissionProfile::CATEGORY_RATE_FIELDS[$category])) {
- $status = AgentProfitEntry::STATUS_SKIPPED_CATEGORY;
- } elseif (!$assignment || !$agent->profit_commission_profile_id) {
- $status = AgentProfitEntry::STATUS_SKIPPED_NO_PLAN;
- } elseif (!(int) $agent->profit_active) {
- $status = AgentProfitEntry::STATUS_SKIPPED_INACTIVE;
- $rate = $assignment->rateForCategory($category);
- } else {
- $rate = $assignment->rateForCategory($category);
- }
- $share = $status === AgentProfitEntry::STATUS_COUNTED ? $this->capped($agent, $this->share($net, $rate)) : '0.0000';
- if (bccomp($share, '0', 4) !== 0) {
- $agent->realtime_balance = bcadd((string) $agent->realtime_balance, $share, 4);
- $agent->save();
- }
- AgentProfitEntry::query()->create([
- 'agent_id' => $agent->id, 'fact_id' => $fact->id, 'revision' => 0, 'user_id' => $fact->user_id,
- 'category' => $category, 'settled_at' => $fact->settled_at,
- 'settle_date' => Carbon::parse($fact->settled_at)->toDateString(),
- 'bet_amount' => $this->money($fact->bet_amount), 'effective_amount' => $this->money($fact->effective_amount),
- 'net_profit' => $net, 'rate' => $rate, 'share_amount' => $share, 'status' => $status,
- 'realtime_balance_after' => $agent->realtime_balance,
- ]);
- if ($status === AgentProfitEntry::STATUS_COUNTED) {
- $this->wallets->evaluateActivation($agent);
- return 'counted';
- }
- return 'skipped';
- }
- private function correct(Agent $agent, object $fact, $entries, string $net): string
- {
- $latest = $entries->last();
- $first = $entries->first();
- if (bccomp((string) $latest->net_profit, $net, 4) === 0) return 'unchanged';
- if ($first->status !== AgentProfitEntry::STATUS_COUNTED) {
- // 当时未参与分成的注单,改派彩后仍不分成,只更新记录。
- $latest->net_profit = $net;
- $latest->save();
- return 'unchanged';
- }
- $target = $this->share($net, (string) $first->rate);
- $applied = '0.0000';
- foreach ($entries as $entry) $applied = bcadd($applied, (string) $entry->share_amount, 4);
- $delta = bccomp($target, $applied, 4) < 0 ? $this->capped($agent, bcsub($target, $applied, 4)) : bcsub($target, $applied, 4);
- $agent->realtime_balance = bcadd((string) $agent->realtime_balance, $delta, 4);
- $agent->save();
- if ($latest->rebate_record_id === null) {
- $latest->net_profit = $net;
- $latest->share_amount = bcadd((string) $latest->share_amount, $delta, 4);
- $latest->realtime_balance_after = $agent->realtime_balance;
- $latest->save();
- } else {
- $now = now();
- AgentProfitEntry::query()->create([
- 'agent_id' => $agent->id, 'fact_id' => $fact->id, 'revision' => (int) $latest->revision + 1,
- 'user_id' => $fact->user_id, 'category' => $first->category,
- 'settled_at' => $now->format('Y-m-d H:i:s'), 'settle_date' => $now->toDateString(),
- 'bet_amount' => 0, 'effective_amount' => 0, 'net_profit' => $net, 'rate' => $first->rate,
- 'share_amount' => $delta, 'status' => AgentProfitEntry::STATUS_COUNTED,
- 'realtime_balance_after' => $agent->realtime_balance,
- ]);
- }
- $this->wallets->evaluateActivation($agent);
- return 'corrected';
- }
- /** 代理视角的分成:会员盈亏取反 × 比例。 */
- public function share(string $net, string $rate): string
- {
- return bcdiv(bcmul(bcsub('0', $net, 4), $rate, 8), '100', 4);
- }
- /** 亏损最多扣到实时余额为 0。 */
- private function capped(Agent $agent, string $share): string
- {
- if (bccomp($share, '0', 4) >= 0) return $share;
- $available = bcadd((string) $agent->realtime_balance, '0', 4);
- if (bccomp($available, '0', 4) <= 0) return '0.0000';
- return bccomp(bcadd($available, $share, 4), '0', 4) < 0 ? bcsub('0', $available, 4) : $share;
- }
- private function money($value): string
- {
- return bcadd(EffectiveBet::decimal($value ?? '0'), '0', 4);
- }
- }
|