From 200269196ea7441ae1a032965c4fad0a11be54ff Mon Sep 17 00:00:00 2001 From: kang Date: Tue, 30 Jun 2026 11:41:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(tests):=20=E6=B7=BB=E5=8A=A0=E7=BB=93?= =?UTF-8?q?=E7=AE=97=E7=9B=B8=E5=85=B3=E5=8A=9F=E8=83=BD=E7=9A=84=E6=B5=8B?= =?UTF-8?q?=E8=AF=95=E7=94=A8=E4=BE=8B=EF=BC=8C=E5=A2=9E=E5=BC=BA=E4=BB=A3?= =?UTF-8?q?=E7=A0=81=E8=A6=86=E7=9B=96=E7=8E=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AgentSettlementReportShowController.php | 4 + .../AgentGameSettlementRecorder.php | 18 + .../AgentSettlementPeriodCloseService.php | 35 +- .../Draw/DrawCancelBetRefundService.php | 31 +- .../SettlementBatchWorkflowService.php | 12 + .../Settlement/SettlementTickFinalizer.php | 40 +- .../SettlementCreditAndReportFixesTest.php | 399 ++++++++++++++++++ 7 files changed, 508 insertions(+), 31 deletions(-) create mode 100644 tests/Feature/SettlementCreditAndReportFixesTest.php diff --git a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementReportShowController.php b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementReportShowController.php index b435375..a3f265b 100644 --- a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementReportShowController.php +++ b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementReportShowController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement; use App\Http\Controllers\Controller; use App\Services\AgentSettlement\AgentSettlementReportQueryService; use App\Support\AdminAgentScope; +use App\Support\AdminAgentSettlementScope; use App\Support\AgentSettlementPeriodWindow; use App\Support\ApiResponse; use Illuminate\Http\JsonResponse; @@ -37,6 +38,9 @@ final class AgentSettlementReportShowController extends Controller } $periodId = (int) $request->query('settlement_period_id', 0); + if ($periodId > 0) { + abort_if(! AdminAgentSettlementScope::periodAccessible($admin, $periodId), 403); + } $period = $this->resolvePeriod($periodId, $request); $data = match ($type) { diff --git a/app/Services/AgentSettlement/AgentGameSettlementRecorder.php b/app/Services/AgentSettlement/AgentGameSettlementRecorder.php index eb8c7e8..31e46df 100644 --- a/app/Services/AgentSettlement/AgentGameSettlementRecorder.php +++ b/app/Services/AgentSettlement/AgentGameSettlementRecorder.php @@ -36,6 +36,10 @@ final class AgentGameSettlementRecorder return; } + if ($this->hasActiveShareLedger($item->id)) { + return; + } + $player = $item->player; if ($player === null) { return; @@ -168,6 +172,20 @@ final class AgentGameSettlementRecorder return 0.0; } + /** 原分账行存在且尚未被冲正时视为活跃,禁止重复入账。 */ + private function hasActiveShareLedger(int $ticketItemId): bool + { + return DB::table('share_ledger as sl') + ->where('sl.ticket_item_id', $ticketItemId) + ->whereNull('sl.reversal_of_id') + ->whereNotExists(function ($query): void { + $query->selectRaw('1') + ->from('share_ledger as rev') + ->whereColumn('rev.reversal_of_id', 'sl.id'); + }) + ->exists(); + } + private function normalizeRate(float $rate): float { return max(0.0, min(1.0, $rate)); diff --git a/app/Services/AgentSettlement/AgentSettlementPeriodCloseService.php b/app/Services/AgentSettlement/AgentSettlementPeriodCloseService.php index 5ded4cf..2b6d0fb 100644 --- a/app/Services/AgentSettlement/AgentSettlementPeriodCloseService.php +++ b/app/Services/AgentSettlement/AgentSettlementPeriodCloseService.php @@ -27,26 +27,25 @@ final class AgentSettlementPeriodCloseService { AgentSettlementProductionGuard::assertProductionCloseAllowed(); - $period = DB::table('settlement_periods')->where('id', $periodId)->first(); - if ($period === null) { - throw ValidationException::withMessages([ - 'period' => ['period_not_found'], - ]); - } + 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'], - ]); - } + 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, - ); - - return DB::transaction(function () use ($periodId, $period, $adminSiteId, $periodStart, $periodEnd): array { + $adminSiteId = (int) $period->admin_site_id; + [$periodStart, $periodEnd] = AgentSettlementPeriodWindow::boundStrings( + (string) $period->period_start, + (string) $period->period_end, + ); try { $aggregate = $this->aggregator->aggregate($adminSiteId, $periodStart, $periodEnd); } catch (\InvalidArgumentException $e) { diff --git a/app/Services/Draw/DrawCancelBetRefundService.php b/app/Services/Draw/DrawCancelBetRefundService.php index f53ceda..a0fa1b6 100644 --- a/app/Services/Draw/DrawCancelBetRefundService.php +++ b/app/Services/Draw/DrawCancelBetRefundService.php @@ -3,11 +3,14 @@ namespace App\Services\Draw; use App\Models\Draw; +use App\Models\Player; use App\Models\TicketItem; use App\Models\TicketOrder; use App\Models\WalletTxn; +use App\Services\Player\PlayerCreditService; use App\Services\Ticket\RiskPoolService; use App\Services\Ticket\TicketWalletService; +use App\Support\PlayerFundingMode; /** * 取消期号前:退本、释池,避免已扣款注单悬空。 @@ -17,6 +20,7 @@ final class DrawCancelBetRefundService public function __construct( private readonly RiskPoolService $riskPool, private readonly TicketWalletService $ticketWallet, + private readonly PlayerCreditService $playerCreditService, ) {} public function refundOpenBetsForDraw(Draw $draw): void @@ -76,18 +80,27 @@ final class DrawCancelBetRefundService ])->save(); } - $hasPostedDeduct = WalletTxn::query() - ->where('biz_type', 'bet_deduct') - ->where('biz_no', $lockedOrder->order_no) - ->where('status', 'posted') - ->exists(); + $player = Player::query()->whereKey($lockedOrder->player_id)->first(); - if ($hasPostedDeduct) { - $this->ticketWallet->reverseBetDeduct($lockedOrder); + if ($player !== null && PlayerFundingMode::usesCredit($player)) { + $holdAmount = (int) $lockedOrder->total_actual_deduct; + if ($holdAmount > 0) { + $this->playerCreditService->reverseBetHold($player, $holdAmount, (int) $lockedOrder->id); + } + } else { + $hasPostedDeduct = WalletTxn::query() + ->where('biz_type', 'bet_deduct') + ->where('biz_no', $lockedOrder->order_no) + ->where('status', 'posted') + ->exists(); + + if ($hasPostedDeduct) { + $this->ticketWallet->reverseBetDeduct($lockedOrder); + } + + $this->ticketWallet->releaseReservedBetDeduct($lockedOrder, 'draw_cancelled_release'); } - $this->ticketWallet->releaseReservedBetDeduct($lockedOrder, 'draw_cancelled_release'); - $lockedOrder->forceFill(['status' => 'refunded'])->save(); } } diff --git a/app/Services/Settlement/SettlementBatchWorkflowService.php b/app/Services/Settlement/SettlementBatchWorkflowService.php index 40ac95f..0d5e712 100644 --- a/app/Services/Settlement/SettlementBatchWorkflowService.php +++ b/app/Services/Settlement/SettlementBatchWorkflowService.php @@ -12,12 +12,15 @@ use App\Models\TicketOrder; use App\Models\SettlementBatch; use Illuminate\Support\Facades\DB; use App\Lottery\SettlementBatchStatus; +use App\Services\AgentSettlement\GameSettlementReversalService; use App\Services\Ticket\TicketWalletService; +use App\Support\PlayerFundingMode; final class SettlementBatchWorkflowService { public function __construct( private readonly TicketWalletService $wallet, + private readonly GameSettlementReversalService $gameSettlementReversal, ) {} public function approve(SettlementBatch $batch, AdminUser $admin, ?string $remark = null): SettlementBatch @@ -62,6 +65,11 @@ final class SettlementBatchWorkflowService $itemIds = $locked->details()->pluck('ticket_item_id')->map(fn ($id) => (int) $id)->all(); if ($itemIds !== []) { + $items = TicketItem::query()->whereIn('id', $itemIds)->get(); + foreach ($items as $item) { + $this->gameSettlementReversal->reverseTicketItem($item); + } + TicketItem::query() ->whereIn('id', $itemIds) ->update([ @@ -69,6 +77,7 @@ final class SettlementBatchWorkflowService 'win_amount' => 0, 'jackpot_win_amount' => 0, 'settled_at' => null, + 'agent_settled_at' => null, ]); } @@ -153,6 +162,9 @@ final class SettlementBatchWorkflowService continue; } $player = Player::query()->whereKey((int) $entry['player_id'])->firstOrFail(); + if (PlayerFundingMode::usesCredit($player)) { + continue; + } $this->wallet->creditSettlementPayout( $player, (string) $entry['currency_code'], diff --git a/app/Services/Settlement/SettlementTickFinalizer.php b/app/Services/Settlement/SettlementTickFinalizer.php index 8057ac3..d7dcd36 100644 --- a/app/Services/Settlement/SettlementTickFinalizer.php +++ b/app/Services/Settlement/SettlementTickFinalizer.php @@ -6,6 +6,7 @@ use App\Models\SettlementBatch; use App\Lottery\SettlementBatchStatus; use App\Services\AuditLogger; use App\Services\LotterySettings; +use Illuminate\Support\Facades\DB; /** * draw tick 在自动结算后,按系统设置自动审核并派彩入账。 @@ -16,14 +17,15 @@ final class SettlementTickFinalizer private readonly SettlementBatchWorkflowService $workflow, ) {} - /** @return array{approved: int, paid: int} */ + /** @return array{approved: int, paid: int, payout_failed: int} */ public function finalizePendingBatches(): array { $approved = 0; $paid = 0; + $payoutFailed = 0; if (! (bool) LotterySettings::get('settlement.auto_approve_on_tick', true)) { - return ['approved' => 0, 'paid' => 0]; + return ['approved' => 0, 'paid' => 0, 'payout_failed' => 0]; } $pending = SettlementBatch::query() @@ -58,9 +60,39 @@ final class SettlementTickFinalizer ); } catch (\Throwable $e) { report($e); + $this->markAutoPayoutFailed($batch, $e); + $payoutFailed++; } } - return ['approved' => $approved, 'paid' => $paid]; + return ['approved' => $approved, 'paid' => $paid, 'payout_failed' => $payoutFailed]; } -} + + private function markAutoPayoutFailed(SettlementBatch $batch, \Throwable $e): void + { + $message = mb_substr($e->getMessage(), 0, 200); + + DB::transaction(function () use ($batch, $message): void { + $locked = SettlementBatch::query()->whereKey($batch->id)->lockForUpdate()->first(); + if ($locked === null || $locked->status !== SettlementBatchStatus::Approved->value) { + return; + } + + $locked->forceFill([ + 'status' => SettlementBatchStatus::Failed->value, + 'review_remark' => 'auto_payout_failed: '.$message, + ])->save(); + + AuditLogger::recordForSystem( + moduleCode: 'settlement', + actionCode: 'auto_payout_failed', + targetType: 'settlement_batch', + targetId: (string) $locked->id, + afterJson: [ + 'draw_id' => (int) $locked->draw_id, + 'error' => $message, + ], + ); + }); + } +} \ No newline at end of file diff --git a/tests/Feature/SettlementCreditAndReportFixesTest.php b/tests/Feature/SettlementCreditAndReportFixesTest.php new file mode 100644 index 0000000..414ea3e --- /dev/null +++ b/tests/Feature/SettlementCreditAndReportFixesTest.php @@ -0,0 +1,399 @@ +where('is_default', true)->first(); + + $player = Player::query()->create([ + 'site_code' => (string) $site->code, + 'agent_node_id' => (int) DB::table('agent_nodes')->where('depth', 0)->value('id'), + 'site_player_id' => 'fix-'.$suffix, + 'auth_source' => 'lottery_native', + 'funding_mode' => PlayerFundingMode::CREDIT, + 'username' => 'fix_'.$suffix, + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + DB::table('player_credit_accounts')->insert([ + 'player_id' => $player->id, + 'credit_limit' => 10000, + 'used_credit' => 0, + 'frozen_credit' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + return $player; +} + +function countActiveShareLedgerRows(int $ticketItemId): int +{ + return (int) DB::table('share_ledger as sl') + ->where('sl.ticket_item_id', $ticketItemId) + ->whereNull('sl.reversal_of_id') + ->whereNotExists(function ($query): void { + $query->selectRaw('1') + ->from('share_ledger as rev') + ->whereColumn('rev.reversal_of_id', 'sl.id'); + }) + ->count(); +} + +function createResultBatchForDraw(Draw $draw): DrawResultBatch +{ + return DrawResultBatch::query()->create([ + 'draw_id' => $draw->id, + 'result_version' => 1, + 'source_type' => 'rng', + 'rng_seed_hash' => 'test', + 'raw_seed_encrypted' => null, + 'status' => DrawResultBatchStatus::Published->value, + 'created_by' => null, + 'confirmed_by' => null, + 'confirmed_at' => now(), + ]); +} + +test('agent recorder allows re-settlement after share ledger reversal', function (): void { + $player = creditAgentPlayerForFixtures('re-settle'); + + $drawId = (int) Draw::query()->create([ + 'draw_no' => 'FIX-RE-DRAW', + 'business_date' => now()->toDateString(), + 'sequence_no' => 1, + 'status' => DrawStatus::Open->value, + 'current_result_version' => 0, + 'settle_version' => 0, + 'is_reopened' => false, + ])->id; + + $orderId = (int) DB::table('ticket_orders')->insertGetId([ + 'order_no' => 'ORD-FIX-RE-1', + 'player_id' => $player->id, + 'draw_id' => $drawId, + 'currency_code' => 'NPR', + 'total_bet_amount' => 100, + 'total_rebate_amount' => 0, + 'total_actual_deduct' => 100, + 'total_estimated_payout' => 0, + 'status' => 'placed', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $item = TicketItem::query()->create([ + 'ticket_no' => 'T-FIX-RE-1', + 'order_id' => $orderId, + 'player_id' => $player->id, + 'draw_id' => $drawId, + 'original_number' => '1234', + 'normalized_number' => '1234', + 'play_code' => 'direct', + 'dimension' => '4d', + 'digit_slot' => null, + 'bet_mode' => 'single', + 'unit_bet_amount' => 100, + 'total_bet_amount' => 100, + 'rebate_rate_snapshot' => 0, + 'commission_rate_snapshot' => 0, + 'actual_deduct_amount' => 100, + 'odds_snapshot_json' => '{}', + 'rule_snapshot_json' => '{}', + 'combination_count' => 1, + 'estimated_max_payout' => 0, + 'risk_locked_amount' => 0, + 'status' => 'pending_payout', + 'win_amount' => 0, + 'jackpot_win_amount' => 0, + ]); + $item->setRelation('player', $player); + + $recorder = app(AgentGameSettlementRecorder::class); + $recorder->recordForTicketItem($item, 0, 'settled_lose'); + expect(countActiveShareLedgerRows($item->id))->toBe(1); + + app(GameSettlementReversalService::class)->reverseTicketItem($item->fresh()); + expect(countActiveShareLedgerRows($item->id))->toBe(0); + + $item->forceFill(['agent_settled_at' => null])->save(); + $recorder->recordForTicketItem($item->fresh(), 0, 'settled_lose'); + expect(countActiveShareLedgerRows($item->id))->toBe(1); +}); + +test('credit player payout skips lottery wallet settle_payout txn', function (): void { + $player = creditAgentPlayerForFixtures('no-wallet-payout'); + $draw = Draw::query()->create([ + 'draw_no' => 'FIX-PAY-DRAW', + 'business_date' => now()->toDateString(), + 'sequence_no' => 1, + 'status' => DrawStatus::Settling->value, + 'current_result_version' => 1, + 'settle_version' => 1, + 'is_reopened' => false, + ]); + $resultBatch = createResultBatchForDraw($draw); + + $batch = SettlementBatch::query()->create([ + 'draw_id' => $draw->id, + 'result_batch_id' => $resultBatch->id, + 'settle_version' => 1, + 'status' => SettlementBatchStatus::Approved->value, + 'total_ticket_count' => 1, + 'total_win_count' => 1, + 'total_payout_amount' => 500, + 'total_jackpot_payout_amount' => 0, + 'review_status' => 'approved', + 'reviewed_by' => null, + 'reviewed_at' => now(), + 'review_remark' => null, + 'paid_at' => null, + 'started_at' => now(), + 'finished_at' => now(), + ]); + + $orderId = (int) DB::table('ticket_orders')->insertGetId([ + 'order_no' => 'ORD-FIX-PAY-1', + 'player_id' => $player->id, + 'draw_id' => $draw->id, + 'currency_code' => 'NPR', + 'total_bet_amount' => 100, + 'total_rebate_amount' => 0, + 'total_actual_deduct' => 100, + 'total_estimated_payout' => 500, + 'status' => 'placed', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $itemId = (int) DB::table('ticket_items')->insertGetId([ + 'ticket_no' => 'T-FIX-PAY-1', + 'order_id' => $orderId, + 'player_id' => $player->id, + 'draw_id' => $draw->id, + 'original_number' => '1234', + 'normalized_number' => '1234', + 'play_code' => 'direct', + 'dimension' => '4d', + 'digit_slot' => null, + 'bet_mode' => 'single', + 'unit_bet_amount' => 100, + 'total_bet_amount' => 100, + 'rebate_rate_snapshot' => 0, + 'commission_rate_snapshot' => 0, + 'actual_deduct_amount' => 100, + 'odds_snapshot_json' => '{}', + 'rule_snapshot_json' => '{}', + 'combination_count' => 1, + 'estimated_max_payout' => 500, + 'risk_locked_amount' => 0, + 'status' => 'pending_payout', + 'win_amount' => 500, + 'jackpot_win_amount' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('ticket_settlement_details')->insert([ + 'settlement_batch_id' => $batch->id, + 'ticket_item_id' => $itemId, + 'matched_prize_tier' => 'first', + 'win_amount' => 500, + 'jackpot_allocation_amount' => 0, + 'match_detail_json' => '{}', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + app(SettlementBatchWorkflowService::class)->payout($batch->fresh()); + + expect(DB::table('wallet_txns')->where('biz_type', 'settle_payout')->count())->toBe(0) + ->and(TicketItem::query()->find($itemId)?->status)->toBe('settled_win'); +}); + +test('settlement report show rejects inaccessible settlement period', function (): void { + $siteAId = (int) DB::table('admin_sites')->where('is_default', true)->value('id'); + $siteBId = (int) DB::table('admin_sites')->insertGetId([ + 'code' => 'site_fix_b', + 'name' => 'Site Fix B', + 'is_default' => false, + 'status' => 1, + 'extra_json' => '{}', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $periodBId = (int) DB::table('settlement_periods')->insertGetId([ + 'admin_site_id' => $siteBId, + 'period_start' => now()->subDay(), + 'period_end' => now()->addDay(), + 'status' => 'open', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $admin = AdminUser::query()->create([ + 'username' => 'report_scope_admin', + 'name' => 'Report Scope Admin', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + $role = AdminRole::query()->create([ + 'slug' => 'report_scope_role', + 'name' => 'Report Scope Role', + ]); + $role->syncLegacyPermissionSlugs(['prd.settlement.agent.view']); + DB::table('admin_user_site_roles')->insert([ + 'admin_user_id' => $admin->id, + 'site_id' => $siteAId, + 'role_id' => $role->id, + 'granted_at' => now(), + ]); + $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/settlement-reports?type=player_win_loss&settlement_period_id='.$periodBId) + ->assertForbidden(); +}); + +test('settlement tick finalizer marks approved batch failed when payout throws', function (): void { + $player = creditAgentPlayerForFixtures('tick-fail'); + $draw = Draw::query()->create([ + 'draw_no' => 'FIX-FAIL-DRAW', + 'business_date' => now()->toDateString(), + 'sequence_no' => 1, + 'status' => DrawStatus::Settling->value, + 'current_result_version' => 1, + 'settle_version' => 1, + 'is_reopened' => false, + ]); + $resultBatch = createResultBatchForDraw($draw); + + $batch = SettlementBatch::query()->create([ + 'draw_id' => $draw->id, + 'result_batch_id' => $resultBatch->id, + 'settle_version' => 1, + 'status' => SettlementBatchStatus::PendingReview->value, + 'total_ticket_count' => 1, + 'total_win_count' => 0, + 'total_payout_amount' => 0, + 'total_jackpot_payout_amount' => 0, + 'review_status' => 'pending', + 'reviewed_by' => null, + 'reviewed_at' => null, + 'review_remark' => null, + 'paid_at' => null, + 'started_at' => now(), + 'finished_at' => now(), + ]); + + $orderId = (int) DB::table('ticket_orders')->insertGetId([ + 'order_no' => 'ORD-FIX-FAIL-1', + 'player_id' => $player->id, + 'draw_id' => $draw->id, + 'currency_code' => 'NPR', + 'total_bet_amount' => 100, + 'total_rebate_amount' => 0, + 'total_actual_deduct' => 100, + 'total_estimated_payout' => 0, + 'status' => 'placed', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $settledItemId = (int) DB::table('ticket_items')->insertGetId([ + 'ticket_no' => 'T-FIX-FAIL-SETTLED', + 'order_id' => $orderId, + 'player_id' => $player->id, + 'draw_id' => $draw->id, + 'original_number' => '1234', + 'normalized_number' => '1234', + 'play_code' => 'direct', + 'dimension' => '4d', + 'digit_slot' => null, + 'bet_mode' => 'single', + 'unit_bet_amount' => 100, + 'total_bet_amount' => 100, + 'rebate_rate_snapshot' => 0, + 'commission_rate_snapshot' => 0, + 'actual_deduct_amount' => 100, + 'odds_snapshot_json' => '{}', + 'rule_snapshot_json' => '{}', + 'combination_count' => 1, + 'estimated_max_payout' => 0, + 'risk_locked_amount' => 0, + 'status' => 'settled_lose', + 'win_amount' => 0, + 'jackpot_win_amount' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('ticket_settlement_details')->insert([ + 'settlement_batch_id' => $batch->id, + 'ticket_item_id' => $settledItemId, + 'matched_prize_tier' => null, + 'win_amount' => 0, + 'jackpot_allocation_amount' => 0, + 'match_detail_json' => '{}', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('ticket_items')->insert([ + 'ticket_no' => 'T-FIX-FAIL-ORPHAN', + 'order_id' => $orderId, + 'player_id' => $player->id, + 'draw_id' => $draw->id, + 'original_number' => '5678', + 'normalized_number' => '5678', + 'play_code' => 'direct', + 'dimension' => '4d', + 'digit_slot' => null, + 'bet_mode' => 'single', + 'unit_bet_amount' => 100, + 'total_bet_amount' => 100, + 'rebate_rate_snapshot' => 0, + 'commission_rate_snapshot' => 0, + 'actual_deduct_amount' => 100, + 'odds_snapshot_json' => '{}', + 'rule_snapshot_json' => '{}', + 'combination_count' => 1, + 'estimated_max_payout' => 0, + 'risk_locked_amount' => 0, + 'status' => 'pending_draw', + 'win_amount' => 0, + 'jackpot_win_amount' => 0, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $result = app(SettlementTickFinalizer::class)->finalizePendingBatches(); + + expect($result['payout_failed'])->toBe(1) + ->and($batch->fresh()->status)->toBe(SettlementBatchStatus::Failed->value) + ->and($batch->fresh()->review_remark)->toContain('auto_payout_failed'); +}); \ No newline at end of file