feat: 增强代理结算和账单管理功能

- 在多个控制器中引入 SettlementPartyEnrichment 服务,以优化代理结算和账单的处理逻辑。
- 更新 AgentSettlementBillIndexController 和 AgentSettlementBillShowController,支持根据账单 ID 和关键字进行查询。
- 在 AgentSettlementPeriodCloseController 中添加对站点管理权限的验证,确保只有具备相应权限的管理员能够关闭账期。
- 在 AgentSettlementPeriodIndexController 中更新账期数据的返回格式,提升数据的完整性和可用性。
- 引入对相对占成比例的支持,增强代理资料的管理能力,确保数据一致性。
This commit is contained in:
2026-06-05 18:00:56 +08:00
parent a44679665d
commit 2d32f006c5
63 changed files with 4893 additions and 288 deletions

View File

@@ -0,0 +1,399 @@
<?php
use App\Models\AdminUser;
use App\Models\AgentProfile;
use App\Models\Draw;
use App\Models\Player;
use App\Lottery\DrawStatus;
use App\Services\Agent\AgentNodeService;
use App\Services\AgentSettlement\AgentSettlementPeriodCloseService;
use App\Services\AgentSettlement\SettlementPaymentService;
use App\Support\Settlement\DesignDocExample12;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
uses(RefreshDatabase::class);
beforeEach(function (): void {
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
});
function createSiteWithRoot(string $code): array
{
$siteId = (int) DB::table('admin_sites')->insertGetId([
'code' => $code,
'name' => $code,
'is_default' => false,
'created_at' => now(),
'updated_at' => now(),
]);
$rootId = (int) DB::table('agent_nodes')->insertGetId([
'admin_site_id' => $siteId,
'parent_id' => null,
'depth' => 0,
'path' => '/'.$code,
'code' => $code,
'name' => 'Root '.$code,
'status' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
AgentProfile::query()->create([
'agent_node_id' => $rootId,
'total_share_rate' => 100,
'credit_limit' => 500_000,
'allocated_credit' => 0,
'used_credit' => 0,
'rebate_limit' => 0.01,
'default_player_rebate' => 0.005,
'settlement_cycle' => 'weekly',
]);
return ['site_id' => $siteId, 'site_code' => $code, 'root_id' => $rootId];
}
function createTicketItemForPlayer(Player $player, string $ticketNo): int
{
$draw = Draw::query()->create([
'draw_no' => 'DRAW-'.$ticketNo,
'business_date' => now()->toDateString(),
'sequence_no' => random_int(1, 9999),
'status' => DrawStatus::Open->value,
'current_result_version' => 0,
'settle_version' => 0,
'is_reopened' => false,
]);
$orderId = (int) DB::table('ticket_orders')->insertGetId([
'order_no' => 'ORD-'.$ticketNo,
'player_id' => $player->id,
'draw_id' => $draw->id,
'currency_code' => 'NPR',
'total_bet_amount' => 10_000,
'total_rebate_amount' => 0,
'total_actual_deduct' => 10_000,
'total_estimated_payout' => 0,
'status' => 'confirmed',
'submit_source' => 'h5',
'client_trace_id' => null,
'created_at' => now(),
'updated_at' => now(),
]);
return (int) DB::table('ticket_items')->insertGetId([
'ticket_no' => $ticketNo,
'order_id' => $orderId,
'player_id' => $player->id,
'draw_id' => $draw->id,
'original_number' => null,
'normalized_number' => '1234',
'play_code' => 'big',
'dimension' => 2,
'digit_slot' => null,
'bet_mode' => null,
'unit_bet_amount' => 10_000,
'total_bet_amount' => 10_000,
'rebate_rate_snapshot' => 0,
'commission_rate_snapshot' => 0,
'actual_deduct_amount' => 10_000,
'odds_snapshot_json' => null,
'rule_snapshot_json' => null,
'combination_count' => 1,
'estimated_max_payout' => 0,
'risk_locked_amount' => 0,
'status' => 'settled_lose',
'win_amount' => 0,
'jackpot_win_amount' => 0,
'settled_at' => null,
'created_at' => now(),
'updated_at' => now(),
]);
}
test('period close only tags share ledger rows for the closing site', function (): void {
$siteA = createSiteWithRoot('close-site-a');
$siteB = createSiteWithRoot('close-site-b');
$playerA = Player::query()->create([
'site_code' => $siteA['site_code'],
'agent_node_id' => $siteA['root_id'],
'site_player_id' => 'p-close-a',
'username' => 'close_a',
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
$playerB = Player::query()->create([
'site_code' => $siteB['site_code'],
'agent_node_id' => $siteB['root_id'],
'site_player_id' => 'p-close-b',
'username' => 'close_b',
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
$settledAt = now();
$snapshot = json_encode([
'total_shares' => ['close-site-a' => 100],
'chain_codes' => ['close-site-a'],
'agent_path' => [$siteA['root_id']],
]);
$snapshotB = json_encode([
'total_shares' => ['close-site-b' => 100],
'chain_codes' => ['close-site-b'],
'agent_path' => [$siteB['root_id']],
]);
$ticketAId = createTicketItemForPlayer($playerA, 'T-P0-A');
$ticketBId = createTicketItemForPlayer($playerB, 'T-P0-B');
$ledgerAId = (int) DB::table('share_ledger')->insertGetId([
'ticket_item_id' => $ticketAId,
'player_id' => $playerA->id,
'agent_node_id' => $siteA['root_id'],
'agent_path' => json_encode([$siteA['root_id']]),
'share_snapshot' => $snapshot,
'game_win_loss' => 1000,
'basic_rebate' => 0,
'shared_net_win_loss' => 1000,
'allocations_json' => json_encode([]),
'settled_at' => $settledAt,
'created_at' => $settledAt,
'updated_at' => $settledAt,
]);
$ledgerBId = (int) DB::table('share_ledger')->insertGetId([
'ticket_item_id' => $ticketBId,
'player_id' => $playerB->id,
'agent_node_id' => $siteB['root_id'],
'agent_path' => json_encode([$siteB['root_id']]),
'share_snapshot' => $snapshotB,
'game_win_loss' => 2000,
'basic_rebate' => 0,
'shared_net_win_loss' => 2000,
'allocations_json' => json_encode([]),
'settled_at' => $settledAt,
'created_at' => $settledAt,
'updated_at' => $settledAt,
]);
$periodId = (int) DB::table('settlement_periods')->insertGetId([
'admin_site_id' => $siteA['site_id'],
'period_start' => $settledAt->copy()->subDay(),
'period_end' => $settledAt->copy()->addDay(),
'status' => 'open',
'created_at' => now(),
'updated_at' => now(),
]);
app(AgentSettlementPeriodCloseService::class)->closePeriod($periodId);
expect((int) DB::table('share_ledger')->where('id', $ledgerAId)->value('settlement_period_id'))
->toBe($periodId);
expect(DB::table('share_ledger')->where('id', $ledgerBId)->value('settlement_period_id'))
->toBeNull();
});
test('agent bill unpaid amount uses abs net so player-wins chain can be paid', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$siteCode = (string) DB::table('admin_sites')->where('id', $siteId)->value('code');
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
$super = AdminUser::query()->create([
'username' => 'p0_agent_pay',
'name' => 'P0',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($super);
$service = app(AgentNodeService::class);
$a = $service->createChild($super, agentChildPayload([
'parent_id' => $rootId,
'code' => 'P0A',
'name' => 'P0A',
'username' => 'p0_a',
'total_share_rate' => 60,
'credit_limit' => 500_000,
]));
$b = $service->createChild($super, agentChildPayload([
'parent_id' => $a->id,
'code' => 'P0B',
'name' => 'P0B',
'username' => 'p0_b',
'total_share_rate' => 40,
'credit_limit' => 200_000,
]));
$c = $service->createChild($super, agentChildPayload([
'parent_id' => $b->id,
'code' => 'P0C',
'name' => 'P0C',
'username' => 'p0_c',
'total_share_rate' => 25,
'credit_limit' => 100_000,
]));
$player = Player::query()->create([
'site_code' => $siteCode,
'agent_node_id' => $c->id,
'site_player_id' => 'p0-win-p1',
'username' => 'p0win',
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
$settledAt = now();
$periodId = (int) DB::table('settlement_periods')->insertGetId([
'admin_site_id' => $siteId,
'period_start' => $settledAt->copy()->subDay(),
'period_end' => $settledAt->copy()->addDay(),
'status' => 'open',
'created_at' => now(),
'updated_at' => now(),
]);
$ticketItemId = createTicketItemForPlayer($player, 'T-P0-WIN');
// Player wins: negative game_win_loss produces negative agent edge settlements.
DB::table('share_ledger')->insert([
'ticket_item_id' => $ticketItemId,
'player_id' => $player->id,
'agent_node_id' => $c->id,
'agent_path' => json_encode([$a->id, $b->id, $c->id]),
'share_snapshot' => json_encode([
'total_shares' => ['P0C' => 25, 'P0B' => 40, 'P0A' => 60],
'actual_shares' => ['P0C' => 25, 'P0B' => 15, 'P0A' => 20, 'platform' => 40],
'chain_codes' => ['P0C', 'P0B', 'P0A'],
'agent_path' => [$a->id, $b->id, $c->id],
]),
'game_win_loss' => -DesignDocExample12::GAME_WIN_LOSS,
'basic_rebate' => DesignDocExample12::BASIC_REBATE,
'shared_net_win_loss' => -DesignDocExample12::SHARED_NET_WIN_LOSS,
'allocations_json' => json_encode([]),
'settled_at' => $settledAt,
'created_at' => $settledAt,
'updated_at' => $settledAt,
]);
app(AgentSettlementPeriodCloseService::class)->closePeriod($periodId);
$agentBill = DB::table('settlement_bills')
->where('settlement_period_id', $periodId)
->where('bill_type', 'agent')
->where('meta_json', 'like', '%P0C_to_P0B%')
->first();
expect($agentBill)->not->toBeNull();
expect((int) $agentBill->net_amount)->toBeLessThan(0);
expect((int) $agentBill->unpaid_amount)->toBe(abs((int) $agentBill->net_amount));
DB::table('settlement_bills')->where('id', $agentBill->id)->update([
'status' => 'confirmed',
'updated_at' => now(),
]);
app(SettlementPaymentService::class)->recordPayment(
(int) $agentBill->id,
(int) $agentBill->unpaid_amount,
(int) $super->id,
['method' => 'cash'],
);
$record = DB::table('payment_records')->where('settlement_bill_id', $agentBill->id)->first();
expect($record)->not->toBeNull();
expect((string) $record->payer_type)->toBe('agent');
expect((int) $record->payer_id)->toBe((int) $agentBill->counterparty_id);
expect((string) $record->payee_type)->toBe('agent');
expect((int) $record->payee_id)->toBe((int) $agentBill->owner_id);
$refreshed = DB::table('settlement_bills')->where('id', $agentBill->id)->first();
expect((int) $refreshed->unpaid_amount)->toBe(0);
expect((string) $refreshed->status)->toBe('settled');
});
test('period close fails when share ledger row is missing snapshot', function (): void {
$siteId = (int) DB::table('admin_sites')->where('is_default', true)->value('id');
$siteCode = (string) DB::table('admin_sites')->where('id', $siteId)->value('code');
$rootId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
$player = Player::query()->create([
'site_code' => $siteCode,
'agent_node_id' => $rootId,
'site_player_id' => 'p0-missing-snap',
'username' => 'p0snap',
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
$settledAt = now();
$ticketItemId = createTicketItemForPlayer($player, 'T-P0-NOSNAP');
DB::table('share_ledger')->insert([
'ticket_item_id' => $ticketItemId,
'player_id' => $player->id,
'agent_node_id' => $rootId,
'agent_path' => json_encode([$rootId]),
'share_snapshot' => null,
'game_win_loss' => 500,
'basic_rebate' => 0,
'shared_net_win_loss' => 500,
'allocations_json' => json_encode([]),
'settled_at' => $settledAt,
'created_at' => $settledAt,
'updated_at' => $settledAt,
]);
$periodId = (int) DB::table('settlement_periods')->insertGetId([
'admin_site_id' => $siteId,
'period_start' => $settledAt->copy()->subDay(),
'period_end' => $settledAt->copy()->addDay(),
'status' => 'open',
'created_at' => now(),
'updated_at' => now(),
]);
$caught = null;
try {
app(AgentSettlementPeriodCloseService::class)->closePeriod($periodId);
} catch (\Illuminate\Validation\ValidationException $e) {
$caught = $e;
}
expect($caught)->toBeInstanceOf(\Illuminate\Validation\ValidationException::class);
expect($caught?->errors()['period'][0] ?? null)->toBe('share_snapshot_missing');
expect((string) DB::table('settlement_periods')->where('id', $periodId)->value('status'))
->toBe('open');
expect(DB::table('settlement_bills')->where('settlement_period_id', $periodId)->count())
->toBe(0);
});
test('period close succeeds with no share ledger rows in window', function (): void {
['site_id' => $siteId] = createSiteWithRoot('empty-close');
$periodId = (int) DB::table('settlement_periods')->insertGetId([
'admin_site_id' => $siteId,
'period_start' => '2026-06-01 00:00:00',
'period_end' => '2026-06-07 23:59:59',
'status' => 'open',
'created_at' => now(),
'updated_at' => now(),
]);
$result = app(AgentSettlementPeriodCloseService::class)->closePeriod($periodId);
expect($result['bill_ids'])->toBe([]);
expect($result['player_count'])->toBe(0);
expect((string) DB::table('settlement_periods')->where('id', $periodId)->value('status'))
->toBe('closed');
expect(DB::table('settlement_bills')->where('settlement_period_id', $periodId)->count())
->toBe(0);
});