*/ public function closePeriod(int $periodId): array { AgentSettlementProductionGuard::assertProductionCloseAllowed(); return DB::transaction(function () use ($periodId): array { $period = DB::table('settlement_periods')->where('id', $periodId)->lockForUpdate()->first(); if ($period === null) { throw ValidationException::withMessages([ 'period' => ['period_not_found'], ]); } if ((string) $period->status === 'closed' || (string) $period->status === 'completed') { throw ValidationException::withMessages([ 'period' => ['period_already_closed'], ]); } $adminSiteId = (int) $period->admin_site_id; [$periodStart, $periodEnd] = AgentSettlementPeriodWindow::boundStrings( (string) $period->period_start, (string) $period->period_end, ); $unsettled = $this->unsettledWarning->countForSite($adminSiteId, $periodStart, $periodEnd); if ($unsettled['count'] > 0) { throw ValidationException::withMessages([ 'period' => ['period_has_unsettled_tickets'], ]); } try { $aggregate = $this->aggregator->aggregate($adminSiteId, $periodStart, $periodEnd); } catch (\InvalidArgumentException $e) { if (str_starts_with($e->getMessage(), 'share_snapshot_missing')) { throw ValidationException::withMessages([ 'period' => ['share_snapshot_missing'], ]); } throw $e; } $billIds = $this->billGenerator->generate($periodId, $adminSiteId, $aggregate); $roundingDiff = $this->platformRounding->apply($periodId, $aggregate); $rebateStats = $this->periodCloseRebate->dispatchAndAllocate($periodId, $adminSiteId, $periodStart, $periodEnd); $this->releasePlayerBillRebatesFromCredit($periodId); DB::table('settlement_periods')->where('id', $periodId)->update([ 'status' => 'closed', 'updated_at' => now(), ]); $siteCode = (string) DB::table('admin_sites')->where('id', $adminSiteId)->value('code'); DB::table('share_ledger') ->whereIn('id', function ($query) use ($siteCode, $periodStart, $periodEnd): void { $query->select('sl.id') ->from('share_ledger as sl') ->join('players as p', 'p.id', '=', 'sl.player_id') ->where('p.site_code', $siteCode) ->whereNull('sl.settlement_period_id') ->whereBetween('sl.settled_at', [$periodStart, $periodEnd]); }) ->update(['settlement_period_id' => $periodId]); $this->reconcileAllocatedCreditForSite($adminSiteId); return [ 'period_id' => $periodId, 'bill_ids' => $billIds, 'player_count' => count($aggregate['players']), 'agent_edges' => $aggregate['agent_edges'], 'rebate_dispatched' => $rebateStats['dispatched'], 'rebate_allocations' => $rebateStats['allocations'], 'unsettled_ticket_count' => $unsettled['count'], 'unsettled_ticket_sample' => $unsettled['ticket_item_ids'], 'platform_rounding_adjustment' => $roundingDiff, ]; }); } /** 玩家亏损账单里的回水已抵扣应付额,应同步释放同等已用信用。 */ private function releasePlayerBillRebatesFromCredit(int $periodId): void { $bills = DB::table('settlement_bills') ->where('settlement_period_id', $periodId) ->where('bill_type', 'player') ->where('owner_type', 'player') ->where('gross_win_loss', '>', 0) ->where('rebate_amount', '>', 0) ->get(['id', 'owner_id', 'gross_win_loss', 'rebate_amount']); foreach ($bills as $bill) { $player = Player::query()->find((int) $bill->owner_id); if ($player === null) { continue; } $rebateMinor = min((int) $bill->gross_win_loss, (int) $bill->rebate_amount); $this->playerCreditService->releaseRebateInBill($player, $rebateMinor, (int) $bill->id); } } /** 关账后按真理源重算各代理「已下发额度」,避免与直属玩家/下级代理授信脱节。 */ private function reconcileAllocatedCreditForSite(int $adminSiteId): void { $nodes = AgentNode::query()->where('admin_site_id', $adminSiteId)->get(); foreach ($nodes as $node) { $this->allocatedSync->syncForAgent($node); } } }