From c83343e989c6125a52f36ca0fed82d585740e1d4 Mon Sep 17 00:00:00 2001 From: kang Date: Fri, 26 Jun 2026 16:40:46 +0800 Subject: [PATCH] =?UTF-8?q?fix(settlement):=20=E6=93=8D=E4=BD=9C=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E5=88=86=E9=A1=B5=E3=80=81=E6=B5=81=E6=B0=B4=E5=8A=A8?= =?UTF-8?q?=E4=BD=9C=E4=B8=8E=E5=9D=8F=E8=B4=A6=E5=B9=82=E7=AD=89=E5=8A=A0?= =?UTF-8?q?=E5=9B=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 settlement-operations 合并列表 API,收付/调账接口改为标准分页 - actionable_only 流水在内存过滤后正确分页 - 已结账单流水不再展示补差/冲正快捷动作 - 坏账核销支持 idempotency_key 并写入 result_bill_id - 补充操作记录、流水与坏账幂等相关测试 --- ...entSettlementAdjustmentIndexController.php | 25 +- ...ettlementBillBadDebtWriteOffController.php | 11 +- ...entSettlementOperationsIndexController.php | 53 +++ .../AgentSettlementPaymentIndexController.php | 20 +- .../Api/V1/E2E/E2ECaptchaPeekController.php | 14 +- .../Api/V1/E2E/E2EDrawController.php | 23 +- .../Api/V1/E2E/E2EPlayerStateController.php | 14 +- .../Api/V1/E2E/E2EProvisionController.php | 48 +-- .../AdminSettlementBillBadDebtRequest.php | 1 + .../AgentSettlementBadDebtService.php | 259 ++++++++------ .../SettlementCenterLedgerService.php | 128 +++++-- .../SettlementOperationsListFilters.php | 52 +++ .../SettlementOperationsListService.php | 315 ++++++++++++++++++ app/Support/AdminAuthorizationRegistry.php | 2 +- e2e/global-setup.ts | 2 + e2e/tests/ui/admin-settlement-center.spec.ts | 7 +- routes/api/v1/admin/agent-settlement.php | 23 +- tests/Feature/AdminCreditLedgerFilterTest.php | 160 ++++++++- tests/Feature/AgentSettlementListsApiTest.php | 24 +- tests/Feature/FundOperationsHardeningTest.php | 76 ++++- 20 files changed, 1045 insertions(+), 212 deletions(-) create mode 100644 app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementOperationsIndexController.php create mode 100644 app/Services/AgentSettlement/SettlementOperationsListFilters.php create mode 100644 app/Services/AgentSettlement/SettlementOperationsListService.php diff --git a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementAdjustmentIndexController.php b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementAdjustmentIndexController.php index c557a4c..896effa 100644 --- a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementAdjustmentIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementAdjustmentIndexController.php @@ -2,16 +2,19 @@ namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement; -use App\Http\Controllers\Controller; -use App\Support\AdminAgentSettlementScope; use App\Support\ApiResponse; -use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Illuminate\Database\Query\Builder; +use App\Support\PaginationTrait; +use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\DB; +use App\Http\Controllers\Controller; +use Illuminate\Database\Query\Builder; +use App\Support\AdminAgentSettlementScope; final class AgentSettlementAdjustmentIndexController extends Controller { + use PaginationTrait; + public function __invoke(Request $request): JsonResponse { $admin = $request->lotteryAdmin(); @@ -69,8 +72,20 @@ final class AgentSettlementAdjustmentIndexController extends Controller }); } + $meta = $this->paginationMeta($request, defaultPerPage: 20, maxPerPage: 200); + $paginator = $query->paginate( + $meta['per_page'], + ['sa.*', 'sp.period_start', 'sp.period_end', 'sp.admin_site_id', 'sb.bill_type as original_bill_type', 'sb.owner_type as original_owner_type', 'sb.owner_id as original_owner_id'], + 'page', + $meta['page'], + ); + return ApiResponse::success([ - 'items' => $query->limit(200)->get(), + 'items' => $paginator->items(), + 'total' => $paginator->total(), + 'page' => $paginator->currentPage(), + 'per_page' => $paginator->perPage(), + 'last_page' => $paginator->lastPage(), ]); } } diff --git a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillBadDebtWriteOffController.php b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillBadDebtWriteOffController.php index dbefe5d..a0dd127 100644 --- a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillBadDebtWriteOffController.php +++ b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementBillBadDebtWriteOffController.php @@ -2,15 +2,15 @@ namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement; +use App\Support\ApiResponse; +use App\Services\AuditLogger; +use Illuminate\Http\JsonResponse; +use Illuminate\Support\Facades\DB; use App\Http\Controllers\Controller; +use App\Support\AdminAgentSettlementScope; use App\Http\Middleware\RecordAdminApiAudit; use App\Http\Requests\Admin\AdminSettlementBillBadDebtRequest; use App\Services\AgentSettlement\AgentSettlementBadDebtService; -use App\Services\AuditLogger; -use App\Support\AdminAgentSettlementScope; -use App\Support\ApiResponse; -use Illuminate\Http\JsonResponse; -use Illuminate\Support\Facades\DB; final class AgentSettlementBillBadDebtWriteOffController extends Controller { @@ -31,6 +31,7 @@ final class AgentSettlementBillBadDebtWriteOffController extends Controller $settlement_bill, $request->validated('reason'), (int) $admin->id, + $request->validated('idempotency_key'), ); $after = DB::table('settlement_bills')->where('id', $settlement_bill)->first(); diff --git a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementOperationsIndexController.php b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementOperationsIndexController.php new file mode 100644 index 0000000..54d3daa --- /dev/null +++ b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementOperationsIndexController.php @@ -0,0 +1,53 @@ +lotteryAdmin(); + abort_if($admin === null, 401); + + $filters = SettlementOperationsListFilters::fromQuery($request->query()); + + if ($filters->settlementPeriodId !== null) { + abort_if(! AdminAgentSettlementScope::periodAccessible($admin, $filters->settlementPeriodId), 403); + } + + if ($filters->adminSiteId !== null) { + abort_if(! AdminAgentSettlementScope::siteAccessible($admin, $filters->adminSiteId), 403); + } + + $page = $this->page($request); + $perPage = $this->perPage($request, 'per_page', 20, 100); + + $result = $this->operationsList->list($admin, $page, $perPage, $filters); + $total = (int) $result['total']; + $lastPage = max(1, (int) ceil($total / max(1, $perPage))); + + return ApiResponse::success([ + 'items' => $result['items'], + 'total' => $total, + 'page' => $page, + 'per_page' => $perPage, + 'last_page' => $lastPage, + ]); + } +} diff --git a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementPaymentIndexController.php b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementPaymentIndexController.php index 44e8c42..f9db9d0 100644 --- a/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementPaymentIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/AgentSettlement/AgentSettlementPaymentIndexController.php @@ -2,15 +2,18 @@ namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement; +use App\Support\ApiResponse; +use Illuminate\Http\Request; +use App\Support\PaginationTrait; +use Illuminate\Http\JsonResponse; +use Illuminate\Support\Facades\DB; use App\Http\Controllers\Controller; use App\Support\AdminAgentSettlementScope; -use App\Support\ApiResponse; -use Illuminate\Http\JsonResponse; -use Illuminate\Http\Request; -use Illuminate\Support\Facades\DB; final class AgentSettlementPaymentIndexController extends Controller { + use PaginationTrait; + public function __invoke(Request $request): JsonResponse { $admin = $request->lotteryAdmin(); @@ -54,8 +57,15 @@ final class AgentSettlementPaymentIndexController extends Controller AdminAgentSettlementScope::applyDirectEdgeScopeToBillsQuery($query, $admin, 'sb'); + $meta = $this->paginationMeta($request, defaultPerPage: 20, maxPerPage: 200); + $paginator = $query->paginate($meta['per_page'], ['pr.*', 'sb.bill_type', 'sb.owner_type', 'sb.owner_id', 'sb.counterparty_type', 'sb.counterparty_id', 'sp.period_start', 'sp.period_end', 'sp.admin_site_id'], 'page', $meta['page']); + return ApiResponse::success([ - 'items' => $query->limit(200)->get(), + 'items' => $paginator->items(), + 'total' => $paginator->total(), + 'page' => $paginator->currentPage(), + 'per_page' => $paginator->perPage(), + 'last_page' => $paginator->lastPage(), ]); } } diff --git a/app/Http/Controllers/Api/V1/E2E/E2ECaptchaPeekController.php b/app/Http/Controllers/Api/V1/E2E/E2ECaptchaPeekController.php index eccab35..ba230e6 100644 --- a/app/Http/Controllers/Api/V1/E2E/E2ECaptchaPeekController.php +++ b/app/Http/Controllers/Api/V1/E2E/E2ECaptchaPeekController.php @@ -2,10 +2,12 @@ namespace App\Http\Controllers\Api\V1\E2E; -use App\Services\AdminCaptchaService; +use App\Lottery\ErrorCode; use App\Support\ApiResponse; -use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Http\JsonResponse; +use App\Http\Controllers\Controller; +use App\Services\AdminCaptchaService; /** * E2E 辅助:peek captcha bypass 码。 @@ -14,14 +16,15 @@ use Illuminate\Http\Request; * 改用 e2e 约定:captcha_code == "LOTTERY_E2E_BYPASS" 在 LOTTERY_E2E=true 时视为通过。 * 本端点不返回任何"答案",仅返回约定码提示。 */ -final class E2ECaptchaPeekController extends \App\Http\Controllers\Controller +final class E2ECaptchaPeekController extends Controller { public function player(Request $request): JsonResponse { $key = (string) $request->input('captcha_key', ''); if ($key === '') { - return ApiResponse::error('captcha_key required', 'e2e_invalid_input', null, 422); + return ApiResponse::error('captcha_key required', ErrorCode::ValidationFailed->value, null, 422); } + return $this->peek($key, AdminCaptchaService::SCOPE_PLAYER); } @@ -29,8 +32,9 @@ final class E2ECaptchaPeekController extends \App\Http\Controllers\Controller { $key = (string) $request->input('captcha_key', ''); if ($key === '') { - return ApiResponse::error('captcha_key required', 'e2e_invalid_input', null, 422); + return ApiResponse::error('captcha_key required', ErrorCode::ValidationFailed->value, null, 422); } + return $this->peek($key, AdminCaptchaService::SCOPE_ADMIN); } diff --git a/app/Http/Controllers/Api/V1/E2E/E2EDrawController.php b/app/Http/Controllers/Api/V1/E2E/E2EDrawController.php index b3f0c1e..d0485bc 100644 --- a/app/Http/Controllers/Api/V1/E2E/E2EDrawController.php +++ b/app/Http/Controllers/Api/V1/E2E/E2EDrawController.php @@ -2,11 +2,14 @@ namespace App\Http\Controllers\Api\V1\E2E; +use App\Lottery\ErrorCode; use App\Lottery\DrawStatus; use App\Support\ApiResponse; -use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\DB; +use App\Http\Controllers\Controller; +use Illuminate\Support\Facades\Artisan; /** * E2E 辅助:期号时间/状态快进。 @@ -19,13 +22,13 @@ use Illuminate\Support\Facades\DB; * * 严格仅在 LOTTERY_E2E=true 时由 E2EServiceProvider 注册。 */ -final class E2EDrawController extends \App\Http\Controllers\Controller +final class E2EDrawController extends Controller { public function closeNow(Request $request, string $drawNo): JsonResponse { $draw = DB::table('draws')->where('draw_no', $drawNo)->first(); if (! $draw) { - return ApiResponse::error('draw not found', 'e2e_draw_missing', null, 404); + return ApiResponse::error('draw not found', ErrorCode::NotFound->value, null, 404); } DB::table('draws') @@ -44,7 +47,7 @@ final class E2EDrawController extends \App\Http\Controllers\Controller { $draw = DB::table('draws')->where('draw_no', $drawNo)->first(); if (! $draw) { - return ApiResponse::error('draw not found', 'e2e_draw_missing', null, 404); + return ApiResponse::error('draw not found', ErrorCode::NotFound->value, null, 404); } DB::table('draws') @@ -62,7 +65,7 @@ final class E2EDrawController extends \App\Http\Controllers\Controller { $draw = DB::table('draws')->where('draw_no', $drawNo)->first(); if (! $draw) { - return ApiResponse::error('draw not found', 'e2e_draw_missing', null, 404); + return ApiResponse::error('draw not found', ErrorCode::NotFound->value, null, 404); } DB::table('draws') @@ -82,8 +85,9 @@ final class E2EDrawController extends \App\Http\Controllers\Controller { $draw = DB::table('draws')->where('draw_no', $drawNo)->first(); if (! $draw) { - return ApiResponse::error('draw not found', 'e2e_draw_missing', null, 404); + return ApiResponse::error('draw not found', ErrorCode::NotFound->value, null, 404); } + return $this->inspectById((int) $draw->id); } @@ -91,11 +95,11 @@ final class E2EDrawController extends \App\Http\Controllers\Controller public function tick(Request $request): JsonResponse { try { - $exit = \Illuminate\Support\Facades\Artisan::call('lottery:draw-tick'); + $exit = Artisan::call('lottery:draw-tick'); } catch (\Throwable $e) { return ApiResponse::error( 'draw-tick failed: '.$e->getMessage(), - 'e2e_tick_failed', + ErrorCode::InternalError->value, ['trace' => array_slice(explode("\n", $e->getTraceAsString()), 0, 5)], 500, ); @@ -103,13 +107,14 @@ final class E2EDrawController extends \App\Http\Controllers\Controller return ApiResponse::success([ 'exit_code' => $exit, - 'output' => \Illuminate\Support\Facades\Artisan::output(), + 'output' => Artisan::output(), ]); } private function inspectById(int $id): JsonResponse { $d = DB::table('draws')->where('id', $id)->first(); + return ApiResponse::success([ 'id' => (int) $d->id, 'draw_no' => $d->draw_no, diff --git a/app/Http/Controllers/Api/V1/E2E/E2EPlayerStateController.php b/app/Http/Controllers/Api/V1/E2E/E2EPlayerStateController.php index d7bec9f..aa1dd0a 100644 --- a/app/Http/Controllers/Api/V1/E2E/E2EPlayerStateController.php +++ b/app/Http/Controllers/Api/V1/E2E/E2EPlayerStateController.php @@ -2,10 +2,12 @@ namespace App\Http\Controllers\Api\V1\E2E; +use App\Lottery\ErrorCode; use App\Support\ApiResponse; -use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\DB; +use App\Http\Controllers\Controller; /** * E2E 辅助:重置/查询玩家状态。 @@ -17,7 +19,7 @@ use Illuminate\Support\Facades\DB; * * 严格仅在 LOTTERY_E2E=true 时由 E2EServiceProvider 注册。 */ -final class E2EPlayerStateController extends \App\Http\Controllers\Controller +final class E2EPlayerStateController extends Controller { public function reset(Request $request): JsonResponse { @@ -32,7 +34,7 @@ final class E2EPlayerStateController extends \App\Http\Controllers\Controller ->value('id'); if (! $playerId) { - return ApiResponse::error('e2e player not found', 'e2e_player_missing', null, 404); + return ApiResponse::error('e2e player not found', ErrorCode::NotFound->value, null, 404); } DB::transaction(function () use ($playerId, $balance, $currency) { @@ -70,7 +72,7 @@ final class E2EPlayerStateController extends \App\Http\Controllers\Controller $balance = (int) $request->input('balance', -1); if ($balance < 0) { - return ApiResponse::error('balance must be >= 0', 'e2e_invalid_input', null, 422); + return ApiResponse::error('balance must be >= 0', ErrorCode::ValidationFailed->value, null, 422); } $playerId = DB::table('players') @@ -79,7 +81,7 @@ final class E2EPlayerStateController extends \App\Http\Controllers\Controller ->value('id'); if (! $playerId) { - return ApiResponse::error('e2e player not found', 'e2e_player_missing', null, 404); + return ApiResponse::error('e2e player not found', ErrorCode::NotFound->value, null, 404); } DB::table('player_wallets') @@ -130,7 +132,7 @@ final class E2EPlayerStateController extends \App\Http\Controllers\Controller ->first(); if (! $player) { - return ApiResponse::error('e2e player not found', 'e2e_player_missing', null, 404); + return ApiResponse::error('e2e player not found', ErrorCode::NotFound->value, null, 404); } $wallets = DB::table('player_wallets') diff --git a/app/Http/Controllers/Api/V1/E2E/E2EProvisionController.php b/app/Http/Controllers/Api/V1/E2E/E2EProvisionController.php index b56f4ed..352d46e 100644 --- a/app/Http/Controllers/Api/V1/E2E/E2EProvisionController.php +++ b/app/Http/Controllers/Api/V1/E2E/E2EProvisionController.php @@ -2,27 +2,29 @@ namespace App\Http\Controllers\Api\V1\E2E; +use Firebase\JWT\JWT; +use App\Models\Player; use App\Models\AdminSite; use App\Models\AdminUser; use App\Models\AgentNode; -use App\Models\Player; +use App\Lottery\ErrorCode; use App\Models\PlayerWallet; +use App\Support\ApiResponse; +use Illuminate\Http\Request; +use App\Support\PlayerAuthSource; +use Illuminate\Http\JsonResponse; +use App\Support\SiteOperatorRoles; +use Illuminate\Support\Facades\DB; +use App\Http\Controllers\Controller; +use App\Support\PlatformSystemRoles; +use Illuminate\Support\Facades\Hash; use App\Services\Agent\AgentNodeService; use App\Services\Integration\PartnerSiteConfigResolver; -use App\Support\ApiResponse; -use App\Support\PlatformSystemRoles; -use App\Support\PlayerAuthSource; -use App\Support\SiteOperatorRoles; -use Firebase\JWT\JWT; -use Illuminate\Http\JsonResponse; -use Illuminate\Http\Request; -use Illuminate\Support\Facades\DB; -use Illuminate\Support\Facades\Hash; /** * E2E 辅助:信用玩家 / SSO JWT / 主站钱包 mock 配置。 */ -final class E2EProvisionController extends \App\Http\Controllers\Controller +final class E2EProvisionController extends Controller { public function setupCreditPlayer(Request $request): JsonResponse { @@ -34,7 +36,7 @@ final class E2EProvisionController extends \App\Http\Controllers\Controller $site = AdminSite::query()->where('code', $siteCode)->first(); if ($site === null) { - return ApiResponse::error('site not found', 'e2e_site_missing', null, 404); + return ApiResponse::error('site not found', ErrorCode::NotFound->value, null, 404); } $extra = $site->extra_json ?? []; @@ -54,7 +56,7 @@ final class E2EProvisionController extends \App\Http\Controllers\Controller ->value('id'); if ($rootId <= 0) { - return ApiResponse::error('root agent missing', 'e2e_agent_missing', null, 500); + return ApiResponse::error('root agent missing', ErrorCode::InternalError->value, null, 500); } $leafCode = (string) $request->input('agent_code', 'e2e_leaf'); @@ -64,9 +66,9 @@ final class E2EProvisionController extends \App\Http\Controllers\Controller ->first(); if ($leaf === null) { - $super = \App\Models\AdminUser::query()->where('username', 'admin')->first(); + $super = AdminUser::query()->where('username', 'admin')->first(); if ($super === null) { - return ApiResponse::error('admin user missing', 'e2e_admin_missing', null, 500); + return ApiResponse::error('admin user missing', ErrorCode::InternalError->value, null, 500); } $leaf = app(AgentNodeService::class)->createChild($super, [ 'parent_id' => $rootId, @@ -153,12 +155,12 @@ final class E2EProvisionController extends \App\Http\Controllers\Controller $site = AdminSite::query()->where('code', $siteCode)->first(); if ($site === null) { - return ApiResponse::error('site not found', 'e2e_site_missing', null, 404); + return ApiResponse::error('site not found', ErrorCode::NotFound->value, null, 404); } $roleId = (int) DB::table('admin_roles')->where('slug', $roleSlug)->value('id'); if ($roleId <= 0) { - return ApiResponse::error('role not found', 'e2e_role_missing', null, 500); + return ApiResponse::error('role not found', ErrorCode::InternalError->value, null, 500); } /** @var AdminUser $user */ @@ -192,7 +194,7 @@ final class E2EProvisionController extends \App\Http\Controllers\Controller $siteCode = (string) ($request->input('site_code') ?: env('E2E_PLAYER_SITE_CODE', 'demo')); $site = AdminSite::query()->where('code', $siteCode)->first(); if ($site === null) { - return ApiResponse::error('site not found', 'e2e_site_missing', null, 404); + return ApiResponse::error('site not found', ErrorCode::NotFound->value, null, 404); } $siteId = (int) $site->id; @@ -230,12 +232,12 @@ final class E2EProvisionController extends \App\Http\Controllers\Controller $site = AdminSite::query()->where('code', $siteCode)->first(); if ($site === null) { - return ApiResponse::error('site not found', 'e2e_site_missing', null, 404); + return ApiResponse::error('site not found', ErrorCode::NotFound->value, null, 404); } $secret = $site->decryptedSsoJwtSecret(); if (! is_string($secret) || $secret === '') { - return ApiResponse::error('sso secret missing on site', 'e2e_sso_secret_missing', null, 500); + return ApiResponse::error('sso secret missing on site', ErrorCode::InternalError->value, null, 500); } $now = time(); @@ -258,12 +260,12 @@ final class E2EProvisionController extends \App\Http\Controllers\Controller $siteCode = (string) ($request->input('site_code') ?: env('E2E_PLAYER_SITE_CODE', 'demo')); $baseUrl = rtrim((string) $request->input('base_url', ''), '/'); if ($baseUrl === '') { - return ApiResponse::error('base_url required', 'e2e_invalid_input', null, 422); + return ApiResponse::error('base_url required', ErrorCode::ValidationFailed->value, null, 422); } $site = AdminSite::query()->where('code', $siteCode)->first(); if ($site === null) { - return ApiResponse::error('site not found', 'e2e_site_missing', null, 404); + return ApiResponse::error('site not found', ErrorCode::NotFound->value, null, 404); } $site->wallet_api_url = $baseUrl; @@ -286,7 +288,7 @@ final class E2EProvisionController extends \App\Http\Controllers\Controller $site = AdminSite::query()->where('code', $siteCode)->first(); if ($site === null) { - return ApiResponse::error('site not found', 'e2e_site_missing', null, 404); + return ApiResponse::error('site not found', ErrorCode::NotFound->value, null, 404); } $site->wallet_api_url = null; diff --git a/app/Http/Requests/Admin/AdminSettlementBillBadDebtRequest.php b/app/Http/Requests/Admin/AdminSettlementBillBadDebtRequest.php index 0548cbf..519064a 100644 --- a/app/Http/Requests/Admin/AdminSettlementBillBadDebtRequest.php +++ b/app/Http/Requests/Admin/AdminSettlementBillBadDebtRequest.php @@ -15,6 +15,7 @@ final class AdminSettlementBillBadDebtRequest extends ApiFormRequest { return [ 'reason' => ['sometimes', 'nullable', 'string', 'max:255'], + 'idempotency_key' => ['sometimes', 'nullable', 'string', 'max:64'], ]; } } diff --git a/app/Services/AgentSettlement/AgentSettlementBadDebtService.php b/app/Services/AgentSettlement/AgentSettlementBadDebtService.php index cb0fbcd..1ee7400 100644 --- a/app/Services/AgentSettlement/AgentSettlementBadDebtService.php +++ b/app/Services/AgentSettlement/AgentSettlementBadDebtService.php @@ -3,8 +3,10 @@ namespace App\Services\AgentSettlement; use App\Models\Player; -use App\Services\Player\PlayerCreditService; use Illuminate\Support\Facades\DB; +use Illuminate\Database\QueryException; +use App\Support\DatabaseUniqueViolation; +use App\Services\Player\PlayerCreditService; use Illuminate\Validation\ValidationException; /** 坏账核销:原账单保留,记 bad_debt 调整与归档单(§2、§21.1)。 */ @@ -15,112 +17,167 @@ final class AgentSettlementBadDebtService private readonly PlayerCreditService $playerCreditService, ) {} - public function writeOff(int $originalBillId, ?string $reason, int $adminUserId): int - { - return (int) DB::transaction(function () use ($originalBillId, $reason, $adminUserId): int { - /** @var object|null $original */ - $original = DB::table('settlement_bills')->where('id', $originalBillId)->lockForUpdate()->first(); - if ($original === null) { - throw new \InvalidArgumentException('bill_not_found'); - } - - $meta = $this->decodeMeta($original->meta_json); - $existingArchiveId = (int) ($meta['bad_debt_bill_id'] ?? 0); - if ($existingArchiveId > 0) { + public function writeOff( + int $originalBillId, + ?string $reason, + int $adminUserId, + ?string $idempotencyKey = null, + ): int { + if ($idempotencyKey !== null && $idempotencyKey !== '') { + $existingArchiveId = $this->findArchiveBillByIdempotency($originalBillId, $idempotencyKey); + if ($existingArchiveId !== null) { return $existingArchiveId; } + } - if ($this->periodCompletion->isPeriodReadOnly((int) $original->settlement_period_id)) { - throw ValidationException::withMessages([ - 'period' => ['completed'], - ]); - } - - if (! in_array((string) $original->status, ['confirmed', 'partial_paid', 'overdue'], true)) { - throw ValidationException::withMessages([ - 'bill' => ['not_eligible'], - ]); - } - - $unpaid = (int) $original->unpaid_amount; - if ($unpaid <= 0) { - throw ValidationException::withMessages([ - 'bill' => ['no_unpaid'], - ]); - } - - if (in_array((string) $original->bill_type, ['adjustment', 'reversal', 'bad_debt'], true)) { - throw ValidationException::withMessages([ - 'bill' => ['not_eligible'], - ]); - } - - $now = now(); - $periodId = (int) $original->settlement_period_id; - - $archiveBillId = (int) DB::table('settlement_bills')->insertGetId([ - 'settlement_period_id' => $periodId, - 'bill_type' => 'bad_debt', - 'owner_type' => (string) $original->owner_type, - 'owner_id' => (int) $original->owner_id, - 'counterparty_type' => (string) $original->counterparty_type, - 'counterparty_id' => (int) $original->counterparty_id, - 'gross_win_loss' => 0, - 'rebate_amount' => 0, - 'adjustment_amount' => -$unpaid, - 'platform_rounding_adjustment' => 0, - 'net_amount' => 0, - 'paid_amount' => 0, - 'unpaid_amount' => 0, - 'status' => 'settled', - 'reversed_bill_id' => $originalBillId, - 'meta_json' => json_encode([ - 'original_bill_id' => $originalBillId, - 'written_off_amount' => $unpaid, - 'original_net_amount' => (int) $original->net_amount, - ]), - 'locked_at' => $now, - 'confirmed_at' => $now, - 'created_at' => $now, - 'updated_at' => $now, - ]); - - DB::table('settlement_adjustments')->insert([ - 'settlement_period_id' => $periodId, - 'original_bill_id' => $originalBillId, - 'adjustment_type' => 'bad_debt', - 'amount' => $unpaid, - 'reason' => $reason, - 'created_by' => $adminUserId > 0 ? $adminUserId : null, - 'created_at' => $now, - 'updated_at' => $now, - ]); - - DB::table('settlement_bills')->where('id', $originalBillId)->update([ - 'unpaid_amount' => 0, - 'status' => 'settled', - 'meta_json' => json_encode(array_merge( - $meta, - [ - 'bad_debt_bill_id' => $archiveBillId, - 'written_off_amount' => $unpaid, - ], - )), - 'updated_at' => $now, - ]); - - if ((string) $original->owner_type === 'player' && (int) $original->owner_id > 0 && (int) $original->net_amount > 0) { - $player = Player::query()->find((int) $original->owner_id); - if ($player !== null) { - $cumulativePaid = (int) $original->paid_amount + $unpaid; - $this->playerCreditService->releaseFromSettlement($player, $cumulativePaid, $originalBillId); + try { + return (int) DB::transaction(function () use ($originalBillId, $reason, $adminUserId, $idempotencyKey): int { + return $this->insertBadDebtWriteOff($originalBillId, $reason, $adminUserId, $idempotencyKey); + }); + } catch (QueryException $e) { + if ( + $idempotencyKey !== null + && $idempotencyKey !== '' + && DatabaseUniqueViolation::matches($e) + ) { + $existingArchiveId = $this->findArchiveBillByIdempotency($originalBillId, $idempotencyKey); + if ($existingArchiveId !== null) { + return $existingArchiveId; } } - $this->periodCompletion->syncIfReady($periodId); + throw $e; + } + } - return $archiveBillId; - }); + private function insertBadDebtWriteOff( + int $originalBillId, + ?string $reason, + int $adminUserId, + ?string $idempotencyKey, + ): int { + /** @var object|null $original */ + $original = DB::table('settlement_bills')->where('id', $originalBillId)->lockForUpdate()->first(); + if ($original === null) { + throw new \InvalidArgumentException('bill_not_found'); + } + + $meta = $this->decodeMeta($original->meta_json); + $existingArchiveId = (int) ($meta['bad_debt_bill_id'] ?? 0); + if ($existingArchiveId > 0) { + return $existingArchiveId; + } + + if ($idempotencyKey !== null && $idempotencyKey !== '') { + $existingArchiveId = $this->findArchiveBillByIdempotency($originalBillId, $idempotencyKey); + if ($existingArchiveId !== null) { + return $existingArchiveId; + } + } + + if ($this->periodCompletion->isPeriodReadOnly((int) $original->settlement_period_id)) { + throw ValidationException::withMessages([ + 'period' => ['completed'], + ]); + } + + if (! in_array((string) $original->status, ['confirmed', 'partial_paid', 'overdue'], true)) { + throw ValidationException::withMessages([ + 'bill' => ['not_eligible'], + ]); + } + + $unpaid = (int) $original->unpaid_amount; + if ($unpaid <= 0) { + throw ValidationException::withMessages([ + 'bill' => ['no_unpaid'], + ]); + } + + if (in_array((string) $original->bill_type, ['adjustment', 'reversal', 'bad_debt'], true)) { + throw ValidationException::withMessages([ + 'bill' => ['not_eligible'], + ]); + } + + $now = now(); + $periodId = (int) $original->settlement_period_id; + + $archiveBillId = (int) DB::table('settlement_bills')->insertGetId([ + 'settlement_period_id' => $periodId, + 'bill_type' => 'bad_debt', + 'owner_type' => (string) $original->owner_type, + 'owner_id' => (int) $original->owner_id, + 'counterparty_type' => (string) $original->counterparty_type, + 'counterparty_id' => (int) $original->counterparty_id, + 'gross_win_loss' => 0, + 'rebate_amount' => 0, + 'adjustment_amount' => -$unpaid, + 'platform_rounding_adjustment' => 0, + 'net_amount' => 0, + 'paid_amount' => 0, + 'unpaid_amount' => 0, + 'status' => 'settled', + 'reversed_bill_id' => $originalBillId, + 'meta_json' => json_encode([ + 'original_bill_id' => $originalBillId, + 'written_off_amount' => $unpaid, + 'original_net_amount' => (int) $original->net_amount, + ]), + 'locked_at' => $now, + 'confirmed_at' => $now, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + DB::table('settlement_adjustments')->insert([ + 'settlement_period_id' => $periodId, + 'original_bill_id' => $originalBillId, + 'result_bill_id' => $archiveBillId, + 'adjustment_type' => 'bad_debt', + 'amount' => $unpaid, + 'reason' => $reason, + 'idempotency_key' => $idempotencyKey, + 'created_by' => $adminUserId > 0 ? $adminUserId : null, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + DB::table('settlement_bills')->where('id', $originalBillId)->update([ + 'unpaid_amount' => 0, + 'status' => 'settled', + 'meta_json' => json_encode(array_merge( + $meta, + [ + 'bad_debt_bill_id' => $archiveBillId, + 'written_off_amount' => $unpaid, + ], + )), + 'updated_at' => $now, + ]); + + if ((string) $original->owner_type === 'player' && (int) $original->owner_id > 0 && (int) $original->net_amount > 0) { + $player = Player::query()->find((int) $original->owner_id); + if ($player !== null) { + $cumulativePaid = (int) $original->paid_amount + $unpaid; + $this->playerCreditService->releaseFromSettlement($player, $cumulativePaid, $originalBillId); + } + } + + $this->periodCompletion->syncIfReady($periodId); + + return $archiveBillId; + } + + private function findArchiveBillByIdempotency(int $originalBillId, string $idempotencyKey): ?int + { + $resultBillId = DB::table('settlement_adjustments') + ->where('original_bill_id', $originalBillId) + ->where('adjustment_type', 'bad_debt') + ->where('idempotency_key', $idempotencyKey) + ->value('result_bill_id'); + + return $resultBillId !== null ? (int) $resultBillId : null; } /** @@ -140,4 +197,4 @@ final class AgentSettlementBadDebtService return is_array($decoded) ? $decoded : []; } -} \ No newline at end of file +} diff --git a/app/Services/AgentSettlement/SettlementCenterLedgerService.php b/app/Services/AgentSettlement/SettlementCenterLedgerService.php index bb92a6f..8ba7c16 100644 --- a/app/Services/AgentSettlement/SettlementCenterLedgerService.php +++ b/app/Services/AgentSettlement/SettlementCenterLedgerService.php @@ -2,14 +2,16 @@ namespace App\Services\AgentSettlement; +use Carbon\Carbon; use App\Models\AdminUser; +use App\Support\LimitedQuery; +use App\Support\CurrencyFormatter; +use App\Support\PlayerFundingMode; +use Illuminate\Support\Collection; +use Illuminate\Support\Facades\DB; +use Illuminate\Database\Query\Builder; use App\Support\AdminAgentSettlementScope; use App\Support\AgentSettlementPeriodWindow; -use App\Support\CurrencyFormatter; -use App\Support\LimitedQuery; -use App\Support\PlayerFundingMode; -use Carbon\Carbon; -use Illuminate\Support\Facades\DB; /** 结算中心统一账务流水(credit_ledger + 收付 + 调账)。 */ final class SettlementCenterLedgerService @@ -36,6 +38,7 @@ final class SettlementCenterLedgerService private readonly SettlementPartyEnrichment $partyEnrichment, private readonly CreditLedgerBetFlowPresenter $betFlowPresenter, ) {} + /** * @return array{ * items: list>, @@ -95,6 +98,19 @@ final class SettlementCenterLedgerService ]; } + if ($filters->actionableOnly) { + return $this->listUnifiedActionableFiltered( + $admin, + $siteCode, + $page, + $perPage, + $filters, + $stubQueries, + $playerBills, + $billsTruncated, + ); + } + $offset = max(0, ($page - 1) * $perPage); if (count($stubQueries) === 1) { $base = $stubQueries[0]; @@ -122,11 +138,6 @@ final class SettlementCenterLedgerService $items = $this->hydrateLedgerStubs($admin, $siteCode, $stubs, $playerBills); $items = $this->applyFilters($items, $filters); - $filteredCount = count($items); - - if ($filteredCount !== count($stubs)) { - $total = $filteredCount; - } return [ 'items' => array_values($items), @@ -248,7 +259,7 @@ final class SettlementCenterLedgerService string $siteCode, ?array $range, SettlementLedgerListFilters $filters, - ): ?\Illuminate\Database\Query\Builder { + ): ?Builder { $query = DB::table('share_ledger as sl') ->join('players as p', 'p.id', '=', 'sl.player_id') ->where('p.site_code', $siteCode) @@ -275,7 +286,7 @@ final class SettlementCenterLedgerService string $siteCode, ?array $range, SettlementLedgerListFilters $filters, - ): ?\Illuminate\Database\Query\Builder { + ): ?Builder { $query = DB::table('credit_ledger as cl') ->join('players as p', function ($join): void { $join->on('p.id', '=', 'cl.owner_id') @@ -313,7 +324,7 @@ final class SettlementCenterLedgerService string $siteCode, ?int $periodId, SettlementLedgerListFilters $filters, - ): ?\Illuminate\Database\Query\Builder { + ): ?Builder { $adminSiteId = (int) DB::table('admin_sites')->where('code', $siteCode)->value('id'); if ($adminSiteId <= 0) { return null; @@ -347,7 +358,7 @@ final class SettlementCenterLedgerService } private function applyPaymentPlayerFilters( - \Illuminate\Database\Query\Builder $query, + Builder $query, SettlementLedgerListFilters $filters, ): void { if ($filters->playerId !== null && $filters->playerId > 0) { @@ -366,7 +377,7 @@ final class SettlementCenterLedgerService string $siteCode, ?int $periodId, SettlementLedgerListFilters $filters, - ): ?\Illuminate\Database\Query\Builder { + ): ?Builder { $adminSiteId = (int) DB::table('admin_sites')->where('code', $siteCode)->value('id'); if ($adminSiteId <= 0) { return null; @@ -409,14 +420,14 @@ final class SettlementCenterLedgerService } private function applyAdjustmentPlayerScope( - \Illuminate\Database\Query\Builder $query, + Builder $query, AdminUser $admin, string $siteCode, SettlementLedgerListFilters $filters, ): void { - $query->where(function (\Illuminate\Database\Query\Builder $outer) use ($admin, $siteCode, $filters): void { + $query->where(function (Builder $outer) use ($admin, $siteCode, $filters): void { $outer->whereNull('p.id') - ->orWhere(function (\Illuminate\Database\Query\Builder $scoped) use ($admin, $siteCode, $filters): void { + ->orWhere(function (Builder $scoped) use ($admin, $siteCode, $filters): void { $scoped->where('p.site_code', $siteCode); AdminAgentSettlementScope::applyDirectPlayersToAlias($scoped, $admin, 'p'); $this->applyLedgerPlayerFilters($scoped, 'p', $filters); @@ -425,7 +436,7 @@ final class SettlementCenterLedgerService } private function applyTxnNoStubFilter( - \Illuminate\Database\Query\Builder $query, + Builder $query, string $idColumn, string $prefix, ?string $txnNo, @@ -435,7 +446,7 @@ final class SettlementCenterLedgerService } $needle = strtolower(trim($txnNo)); - $query->where(function (\Illuminate\Database\Query\Builder $match) use ($idColumn, $prefix, $needle): void { + $query->where(function (Builder $match) use ($idColumn, $prefix, $needle): void { if (ctype_digit($needle)) { $match->where($idColumn, (int) $needle); } @@ -447,7 +458,7 @@ final class SettlementCenterLedgerService }); } - private function applyLedgerSiteScope(\Illuminate\Database\Query\Builder $query, AdminUser $admin, string $periodsAlias): void + private function applyLedgerSiteScope(Builder $query, AdminUser $admin, string $periodsAlias): void { $siteIds = $admin->accessibleAdminSiteIds(); if ($siteIds === null) { @@ -464,7 +475,7 @@ final class SettlementCenterLedgerService } private function applyLedgerPlayerFilters( - \Illuminate\Database\Query\Builder $query, + Builder $query, string $playerAlias, SettlementLedgerListFilters $filters, ): void { @@ -474,7 +485,7 @@ final class SettlementCenterLedgerService if ($filters->playerAccount !== null && $filters->playerAccount !== '') { $like = '%'.addcslashes($filters->playerAccount, '%_\\').'%'; - $query->where(function (\Illuminate\Database\Query\Builder $match) use ($playerAlias, $like): void { + $query->where(function (Builder $match) use ($playerAlias, $like): void { $match->where("{$playerAlias}.username", 'like', $like) ->orWhere("{$playerAlias}.site_player_id", 'like', $like) ->orWhere("{$playerAlias}.nickname", 'like', $like); @@ -483,14 +494,14 @@ final class SettlementCenterLedgerService } /** - * @param \Illuminate\Support\Collection $stubs + * @param Collection $stubs * @param array $playerBills * @return list> */ private function hydrateLedgerStubs( AdminUser $admin, string $siteCode, - \Illuminate\Support\Collection $stubs, + Collection $stubs, array $playerBills, ): array { if ($stubs->isEmpty()) { @@ -641,6 +652,71 @@ final class SettlementCenterLedgerService return $selected === $kind; } + /** + * actionable_only 依赖 hydrated 行的 available_actions,需在内存中过滤后再分页。 + * + * @param list $stubQueries + * @param array $playerBills + * @return array{ + * items: list>, + * total: int, + * page: int, + * per_page: int, + * ledger_source: string, + * truncated: bool, + * } + */ + private function listUnifiedActionableFiltered( + AdminUser $admin, + string $siteCode, + int $page, + int $perPage, + SettlementLedgerListFilters $filters, + array $stubQueries, + array $playerBills, + bool $billsTruncated, + ): array { + $stubs = $this->fetchAllLedgerStubs($stubQueries); + $items = $this->hydrateLedgerStubs($admin, $siteCode, $stubs, $playerBills); + $items = $this->applyFilters($items, $filters); + + $total = count($items); + $offset = max(0, ($page - 1) * $perPage); + $pageItems = array_slice($items, $offset, $perPage); + + return [ + 'items' => array_values($pageItems), + 'total' => $total, + 'page' => $page, + 'per_page' => $perPage, + 'ledger_source' => 'settlement_ledger', + 'truncated' => $billsTruncated || $total > 5000, + ]; + } + + /** + * @param list $stubQueries + */ + private function fetchAllLedgerStubs(array $stubQueries): Collection + { + if (count($stubQueries) === 1) { + return (clone $stubQueries[0]) + ->orderByDesc('sort_at') + ->orderByDesc('entry_id') + ->get(); + } + + $union = null; + foreach ($stubQueries as $stubQuery) { + $union = $union === null ? $stubQuery : $union->unionAll($stubQuery); + } + + return DB::query()->fromSub($union, 'ledger_page') + ->orderByDesc('sort_at') + ->orderByDesc('entry_id') + ->get(); + } + /** * @param list> $items * @return list> @@ -1541,7 +1617,7 @@ final class SettlementCenterLedgerService } if ($billStatus !== null - && in_array($billStatus, ['confirmed', 'partial_paid', 'settled', 'overdue'], true) + && in_array($billStatus, ['confirmed', 'partial_paid', 'overdue'], true) && ! in_array((string) $billType, ['adjustment', 'reversal', 'bad_debt'], true)) { $actions[] = 'adjustment'; $actions[] = 'reversal'; diff --git a/app/Services/AgentSettlement/SettlementOperationsListFilters.php b/app/Services/AgentSettlement/SettlementOperationsListFilters.php new file mode 100644 index 0000000..9a2aa21 --- /dev/null +++ b/app/Services/AgentSettlement/SettlementOperationsListFilters.php @@ -0,0 +1,52 @@ + 0 ? $periodId : null, + adminSiteId: $siteId > 0 ? $siteId : null, + billId: $billId > 0 ? $billId : null, + keyword: self::nonEmptyString($query['keyword'] ?? null), + operationType: $operationType, + ); + } + + public function includesPayments(): bool + { + return in_array($this->operationType, ['all', 'payment'], true); + } + + public function includesAdjustments(): bool + { + return in_array($this->operationType, ['all', 'adjustment', 'reversal', 'bad_debt'], true); + } + + private static function nonEmptyString(mixed $value): ?string + { + $text = trim((string) $value); + + return $text !== '' ? $text : null; + } +} diff --git a/app/Services/AgentSettlement/SettlementOperationsListService.php b/app/Services/AgentSettlement/SettlementOperationsListService.php new file mode 100644 index 0000000..a223754 --- /dev/null +++ b/app/Services/AgentSettlement/SettlementOperationsListService.php @@ -0,0 +1,315 @@ +>, + * total: int, + * page: int, + * per_page: int, + * } + */ + public function list( + AdminUser $admin, + int $page, + int $perPage, + SettlementOperationsListFilters $filters, + ): array { + $stubQueries = []; + + if ($filters->includesPayments()) { + $paymentStub = $this->paymentStubQuery($admin, $filters); + if ($paymentStub !== null) { + $stubQueries[] = $paymentStub; + } + } + + if ($filters->includesAdjustments()) { + $adjustmentStub = $this->adjustmentStubQuery($admin, $filters); + if ($adjustmentStub !== null) { + $stubQueries[] = $adjustmentStub; + } + } + + if ($stubQueries === []) { + return [ + 'items' => [], + 'total' => 0, + 'page' => $page, + 'per_page' => $perPage, + ]; + } + + $offset = max(0, ($page - 1) * $perPage); + + if (count($stubQueries) === 1) { + $base = $stubQueries[0]; + $total = (int) (clone $base)->count(); + $stubs = (clone $base) + ->orderByDesc('sort_at') + ->orderByDesc('record_id') + ->offset($offset) + ->limit($perPage) + ->get(); + } else { + $union = null; + foreach ($stubQueries as $stubQuery) { + $union = $union === null ? $stubQuery : $union->unionAll($stubQuery); + } + $wrapped = DB::query()->fromSub($union, 'operations_page'); + $total = (int) (clone $wrapped)->count(); + $stubs = $wrapped + ->orderByDesc('sort_at') + ->orderByDesc('record_id') + ->offset($offset) + ->limit($perPage) + ->get(); + } + + return [ + 'items' => $this->hydrateStubs($stubs), + 'total' => $total, + 'page' => $page, + 'per_page' => $perPage, + ]; + } + + private function paymentStubQuery(AdminUser $admin, SettlementOperationsListFilters $filters): ?Builder + { + $query = DB::table('payment_records as pr') + ->join('settlement_bills as sb', 'sb.id', '=', 'pr.settlement_bill_id') + ->join('settlement_periods as sp', 'sp.id', '=', 'sb.settlement_period_id') + ->selectRaw("'payment' as op_kind") + ->selectRaw('pr.id as record_id') + ->selectRaw('pr.settlement_bill_id as bill_id') + ->selectRaw('COALESCE(pr.confirmed_at, pr.created_at) as sort_at'); + + $this->applySiteAndPeriodFilters($query, $admin, $filters, 'sp', 'sb.settlement_period_id'); + AdminAgentSettlementScope::applyDirectEdgeScopeToBillsQuery($query, $admin, 'sb'); + + if ($filters->billId !== null) { + $query->where('pr.settlement_bill_id', $filters->billId); + } + + $this->applyPaymentKeywordFilter($query, $filters->keyword); + + return $query; + } + + private function adjustmentStubQuery(AdminUser $admin, SettlementOperationsListFilters $filters): ?Builder + { + $query = DB::table('settlement_adjustments as sa') + ->leftJoin('settlement_periods as sp', 'sp.id', '=', 'sa.settlement_period_id') + ->leftJoin('settlement_bills as sb', 'sb.id', '=', 'sa.original_bill_id') + ->selectRaw("CASE sa.adjustment_type WHEN 'bad_debt' THEN 'bad_debt' WHEN 'reversal' THEN 'reversal' ELSE 'adjustment' END as op_kind") + ->selectRaw('sa.id as record_id') + ->selectRaw('sa.original_bill_id as bill_id') + ->selectRaw('sa.created_at as sort_at'); + + $this->applySiteAndPeriodFilters($query, $admin, $filters, 'sp', 'sa.settlement_period_id'); + + if ($filters->billId !== null) { + $query->where('sa.original_bill_id', $filters->billId); + } + + if ($filters->operationType === 'adjustment') { + $query->where('sa.adjustment_type', 'adjustment'); + } elseif ($filters->operationType === 'reversal') { + $query->where('sa.adjustment_type', 'reversal'); + } elseif ($filters->operationType === 'bad_debt') { + $query->where('sa.adjustment_type', 'bad_debt'); + } + + $actorId = AdminAgentSettlementScope::boundAgentNodeId($admin); + if ($actorId !== null) { + $query->where(function (Builder $outer) use ($admin): void { + $outer->whereNull('sa.original_bill_id') + ->orWhereExists(function (Builder $exists) use ($admin): void { + $exists->selectRaw('1') + ->from('settlement_bills as sb') + ->whereColumn('sb.id', 'sa.original_bill_id'); + AdminAgentSettlementScope::applyDirectEdgeScopeToBillsQuery($exists, $admin, 'sb'); + }); + }); + } + + $this->applyAdjustmentKeywordFilter($query, $filters->keyword); + + return $query; + } + + private function applySiteAndPeriodFilters( + Builder $query, + AdminUser $admin, + SettlementOperationsListFilters $filters, + string $periodsAlias, + string $periodColumn, + ): void { + if ($filters->settlementPeriodId !== null) { + $query->where($periodColumn, $filters->settlementPeriodId); + } + + if ($filters->adminSiteId !== null) { + $query->where($periodsAlias.'.admin_site_id', $filters->adminSiteId); + } + + $siteIds = $admin->accessibleAdminSiteIds(); + if ($siteIds !== null) { + if ($siteIds === []) { + $query->whereRaw('0 = 1'); + + return; + } + $query->whereIn($periodsAlias.'.admin_site_id', $siteIds); + } + } + + private function applyPaymentKeywordFilter(Builder $query, ?string $keyword): void + { + if ($keyword === null) { + return; + } + + $like = '%'.addcslashes(strtolower($keyword), '%_\\').'%'; + $query->where(function (Builder $match) use ($like, $keyword): void { + $match->whereRaw('LOWER(COALESCE(pr.method, \'\')) LIKE ?', [$like]) + ->orWhereRaw('LOWER(COALESCE(pr.proof, \'\')) LIKE ?', [$like]) + ->orWhereRaw('LOWER(COALESCE(pr.remark, \'\')) LIKE ?', [$like]) + ->orWhereRaw('LOWER(CONCAT(pr.payer_type, \'#\', pr.payer_id, \' \', pr.payee_type, \'#\', pr.payee_id)) LIKE ?', [$like]) + ->orWhereRaw('LOWER(CONCAT(\'payment\')) LIKE ?', [$like]); + + if (ctype_digit($keyword)) { + $match->orWhere('pr.id', (int) $keyword) + ->orWhere('pr.settlement_bill_id', (int) $keyword); + } + }); + } + + private function applyAdjustmentKeywordFilter(Builder $query, ?string $keyword): void + { + if ($keyword === null) { + return; + } + + $like = '%'.addcslashes(strtolower($keyword), '%_\\').'%'; + $query->where(function (Builder $match) use ($like, $keyword): void { + $match->whereRaw('LOWER(COALESCE(sa.reason, \'\')) LIKE ?', [$like]) + ->orWhereRaw('LOWER(sa.adjustment_type) LIKE ?', [$like]); + + if (ctype_digit($keyword)) { + $match->orWhere('sa.id', (int) $keyword) + ->orWhere('sa.original_bill_id', (int) $keyword); + } + }); + } + + /** + * @param Collection $stubs + * @return list> + */ + private function hydrateStubs(Collection $stubs): array + { + if ($stubs->isEmpty()) { + return []; + } + + $paymentIds = []; + $adjustmentIds = []; + foreach ($stubs as $stub) { + $kind = (string) $stub->op_kind; + $id = (int) $stub->record_id; + if ($kind === 'payment') { + $paymentIds[] = $id; + } else { + $adjustmentIds[] = $id; + } + } + + $payments = $paymentIds === [] + ? collect() + : DB::table('payment_records')->whereIn('id', $paymentIds)->get()->keyBy('id'); + $adjustments = $adjustmentIds === [] + ? collect() + : DB::table('settlement_adjustments')->whereIn('id', $adjustmentIds)->get()->keyBy('id'); + + $items = []; + foreach ($stubs as $stub) { + $kind = (string) $stub->op_kind; + $id = (int) $stub->record_id; + if ($kind === 'payment') { + $row = $payments->get($id); + if ($row === null) { + continue; + } + $items[] = $this->formatPaymentRow($row); + } else { + $row = $adjustments->get($id); + if ($row === null) { + continue; + } + $items[] = $this->formatAdjustmentRow($row, $kind); + } + } + + return $items; + } + + /** + * @return array + */ + private function formatPaymentRow(object $row): array + { + $payer = (string) $row->payer_type === 'platform' + ? 'platform' + : (string) $row->payer_type.'#'.(int) $row->payer_id; + $payee = (string) $row->payee_type === 'platform' + ? 'platform' + : (string) $row->payee_type.'#'.(int) $row->payee_id; + $detailParts = [$payer.' → '.$payee]; + if (trim((string) ($row->proof ?? '')) !== '') { + $detailParts[] = (string) $row->proof; + } + if (trim((string) ($row->remark ?? '')) !== '') { + $detailParts[] = (string) $row->remark; + } + + $sortAt = $row->confirmed_at ?? $row->created_at; + + return [ + 'kind' => 'payment', + 'record_id' => (int) $row->id, + 'bill_id' => (int) $row->settlement_bill_id, + 'amount' => (int) $row->amount, + 'summary' => trim((string) ($row->method ?? '')) !== '' ? (string) $row->method : '—', + 'detail' => implode(' · ', $detailParts), + 'sort_at' => $sortAt !== null ? Carbon::parse($sortAt)->toIso8601String() : null, + ]; + } + + /** + * @return array + */ + private function formatAdjustmentRow(object $row, string $kind): array + { + return [ + 'kind' => $kind, + 'record_id' => (int) $row->id, + 'bill_id' => (int) ($row->original_bill_id ?? 0), + 'amount' => (int) $row->amount, + 'summary' => trim((string) ($row->reason ?? '')) !== '' ? (string) $row->reason : '—', + 'detail' => null, + 'sort_at' => $row->created_at !== null ? Carbon::parse($row->created_at)->toIso8601String() : null, + ]; + } +} diff --git a/app/Support/AdminAuthorizationRegistry.php b/app/Support/AdminAuthorizationRegistry.php index f0331af..9c7d94d 100644 --- a/app/Support/AdminAuthorizationRegistry.php +++ b/app/Support/AdminAuthorizationRegistry.php @@ -451,6 +451,7 @@ final class AdminAuthorizationRegistry ['code' => 'admin.settlement-periods.close', 'module_code' => 'settlement', 'name' => '关闭代理账期', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/settlement-periods/{settlement_period}/close', 'route_name' => 'api.v1.admin.settlement-periods.close', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.manage']], ['code' => 'admin.credit-ledger.index', 'module_code' => 'settlement', 'name' => '信用流水查询', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/credit-ledger', 'route_name' => 'api.v1.admin.credit-ledger.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['settlement.agent.view', 'settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.view', 'prd.settlement.agent.manage']], ['code' => 'admin.settlement-bills.index', 'module_code' => 'settlement', 'name' => '代理账单列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-bills', 'route_name' => 'api.v1.admin.settlement-bills.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['settlement.agent.view', 'settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.view', 'prd.settlement.agent.manage']], + ['code' => 'admin.settlement-operations.index', 'module_code' => 'settlement', 'name' => '结算操作记录', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-operations', 'route_name' => 'api.v1.admin.settlement-operations.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['settlement.agent.view', 'settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.view', 'prd.settlement.agent.manage']], ['code' => 'admin.settlement-payments.index', 'module_code' => 'settlement', 'name' => '代理账单收付记录', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-payments', 'route_name' => 'api.v1.admin.settlement-payments.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['settlement.agent.view', 'settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.view', 'prd.settlement.agent.manage']], ['code' => 'admin.settlement-adjustments.index', 'module_code' => 'settlement', 'name' => '代理账单调账记录', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-adjustments', 'route_name' => 'api.v1.admin.settlement-adjustments.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['settlement.agent.view', 'settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.view', 'prd.settlement.agent.manage']], ['code' => 'admin.settlement-bills.show', 'module_code' => 'settlement', 'name' => '代理账单详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-bills/{settlement_bill}', 'route_name' => 'api.v1.admin.settlement-bills.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['settlement.agent.view', 'settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.view', 'prd.settlement.agent.manage']], @@ -569,7 +570,6 @@ final class AdminAuthorizationRegistry ['code' => 'admin.report-jobs.store', 'module_code' => 'report', 'name' => '创建报表任务', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/report-jobs', 'route_name' => 'api.v1.admin.report-jobs.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['service.report.export']], ['code' => 'admin.report-jobs.show', 'module_code' => 'report', 'name' => '报表任务详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/report-jobs/{report_job}', 'route_name' => 'api.v1.admin.report-jobs.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view', 'service.report.export']], ['code' => 'admin.report-jobs.download', 'module_code' => 'report', 'name' => '下载报表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/report-jobs/{report_job}/download', 'route_name' => 'api.v1.admin.report-jobs.download', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.export']], - ]; } } diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts index 37c61d6..5b70711 100644 --- a/e2e/global-setup.ts +++ b/e2e/global-setup.ts @@ -8,8 +8,10 @@ export default async function globalSetup(): Promise { const mockPort = process.env.E2E_MOCK_WALLET_PORT ?? '5555'; const ctx = await pwRequest.newContext({ baseURL: api }); + const siteCode = process.env.E2E_PLAYER_SITE_CODE ?? 'demo'; const walletResp = await ctx.post('/api/v1/_e2e/site/wallet-api', { data: { + site_code: siteCode, base_url: `http://127.0.0.1:${mockPort}`, wallet_api_key: 'e2e-mock-key', }, diff --git a/e2e/tests/ui/admin-settlement-center.spec.ts b/e2e/tests/ui/admin-settlement-center.spec.ts index bc61e8b..dec29b9 100644 --- a/e2e/tests/ui/admin-settlement-center.spec.ts +++ b/e2e/tests/ui/admin-settlement-center.spec.ts @@ -22,7 +22,8 @@ test('超管进入结算中心 → 账期管理页可见', async ({ page }) => { timeout: 60_000, }); await expect(page.getByText(/账期管理|Period/i).first()).toBeVisible({ timeout: 30_000 }); - await expect(page.getByRole('button', { name: /开账|Open period/i }).first()).toBeVisible({ - timeout: 30_000, - }); + const periodAction = page + .getByRole('button', { name: /开账|Open period|关账|Close period/i }) + .first(); + await expect(periodAction).toBeVisible({ timeout: 30_000 }); }); diff --git a/routes/api/v1/admin/agent-settlement.php b/routes/api/v1/admin/agent-settlement.php index 19370f7..1ca66b7 100644 --- a/routes/api/v1/admin/agent-settlement.php +++ b/routes/api/v1/admin/agent-settlement.php @@ -1,21 +1,22 @@ group(function (): void { @@ -31,6 +32,8 @@ Route::middleware('admin.api-resource') ->name('api.v1.admin.credit-ledger.index'); Route::get('settlement-bills', AgentSettlementBillIndexController::class) ->name('api.v1.admin.settlement-bills.index'); + Route::get('settlement-operations', AgentSettlementOperationsIndexController::class) + ->name('api.v1.admin.settlement-operations.index'); Route::get('settlement-payments', AgentSettlementPaymentIndexController::class) ->name('api.v1.admin.settlement-payments.index'); Route::get('settlement-adjustments', AgentSettlementAdjustmentIndexController::class) diff --git a/tests/Feature/AdminCreditLedgerFilterTest.php b/tests/Feature/AdminCreditLedgerFilterTest.php index 900b308..548a2a7 100644 --- a/tests/Feature/AdminCreditLedgerFilterTest.php +++ b/tests/Feature/AdminCreditLedgerFilterTest.php @@ -1,11 +1,13 @@ assertJsonPath('data.items.0.ref_id', $referencedBillId); }); +test('credit ledger settled bill payment row omits adjustment actions', function (): void { + $site = DB::table('admin_sites')->where('is_default', true)->first(); + $siteId = (int) $site->id; + $siteCode = (string) $site->code; + + $periodId = (int) DB::table('settlement_periods')->insertGetId([ + 'admin_site_id' => $siteId, + 'period_start' => now()->subDay(), + 'period_end' => now()->addDay(), + 'status' => 'closed', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $player = Player::query()->create([ + 'site_code' => $siteCode, + 'site_player_id' => 'native:ledger-settled-actions', + 'funding_mode' => PlayerFundingMode::CREDIT, + 'username' => 'ledger_settled_actions', + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + $billId = (int) DB::table('settlement_bills')->insertGetId([ + 'settlement_period_id' => $periodId, + 'bill_type' => 'player', + 'owner_type' => 'player', + 'owner_id' => $player->id, + 'counterparty_type' => 'agent', + 'counterparty_id' => 1, + 'net_amount' => 100, + 'unpaid_amount' => 0, + 'paid_amount' => 100, + 'status' => 'settled', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + DB::table('payment_records')->insert([ + 'settlement_bill_id' => $billId, + 'payer_type' => 'player', + 'payer_id' => $player->id, + 'payee_type' => 'agent', + 'payee_id' => 1, + 'amount' => 100, + 'status' => 'confirmed', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $admin = AdminUser::query()->create([ + 'username' => 'ledger_settled_actions_super', + 'name' => 'Ledger Settled Actions', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($admin); + $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/credit-ledger?admin_site_id='.$siteId.'&settlement_period_id='.$periodId.'&reason=payment_record') + ->assertOk() + ->assertJsonPath('data.items.0.available_actions', ['view_player', 'view_bill']); +}); + +test('credit ledger actionable_only paginates after hydration filter', function (): void { + $site = DB::table('admin_sites')->where('is_default', true)->first(); + $siteId = (int) $site->id; + $siteCode = (string) $site->code; + $agentId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id'); + + $periodId = (int) DB::table('settlement_periods')->insertGetId([ + 'admin_site_id' => $siteId, + 'period_start' => now()->subDay(), + 'period_end' => now()->addDay(), + 'status' => 'closed', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $player = Player::query()->create([ + 'site_code' => $siteCode, + 'site_player_id' => 'native:ledger-actionable', + 'funding_mode' => PlayerFundingMode::CREDIT, + 'username' => 'ledger_actionable_user', + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + DB::table('credit_ledger')->insert([ + 'owner_type' => 'player', + 'owner_id' => $player->id, + 'amount' => -100, + 'reason' => 'bet_hold', + 'created_at' => now()->subMinute(), + 'updated_at' => now()->subMinute(), + ]); + + $actionableBillId = (int) DB::table('settlement_bills')->insertGetId([ + 'settlement_period_id' => $periodId, + 'bill_type' => 'player', + 'owner_type' => 'player', + 'owner_id' => $player->id, + 'counterparty_type' => 'agent', + 'counterparty_id' => $agentId, + 'net_amount' => 200, + 'unpaid_amount' => 200, + 'paid_amount' => 0, + 'status' => 'confirmed', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $actionablePaymentId = (int) DB::table('payment_records')->insertGetId([ + 'settlement_bill_id' => $actionableBillId, + 'payer_type' => 'player', + 'payer_id' => $player->id, + 'payee_type' => 'agent', + 'payee_id' => $agentId, + 'amount' => 50, + 'status' => 'confirmed', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $admin = AdminUser::query()->create([ + 'username' => 'ledger_actionable_super', + 'name' => 'Ledger Actionable', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($admin); + $token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/credit-ledger?admin_site_id='.$siteId.'&settlement_period_id='.$periodId.'&actionable_only=1') + ->assertOk() + ->assertJsonPath('data.total', 1) + ->assertJsonPath('data.items.0.entry_kind', 'payment') + ->assertJsonPath('data.items.0.id', $actionablePaymentId); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/credit-ledger?admin_site_id='.$siteId.'&settlement_period_id='.$periodId.'&actionable_only=1&per_page=1&page=2') + ->assertOk() + ->assertJsonPath('data.total', 1) + ->assertJsonPath('data.items', []); +}); + test('credit ledger entry_kind share returns share ledger rows', function (): void { $site = DB::table('admin_sites')->where('is_default', true)->first(); $siteId = (int) $site->id; @@ -301,11 +453,11 @@ test('credit ledger entry_kind share returns share ledger rows', function (): vo function createShareLedgerTicketItem(Player $player): int { - $draw = \App\Models\Draw::query()->create([ + $draw = Draw::query()->create([ 'draw_no' => 'DRAW-SHARE-LEDGER', 'business_date' => now()->toDateString(), 'sequence_no' => random_int(1, 9999), - 'status' => \App\Lottery\DrawStatus::Open->value, + 'status' => DrawStatus::Open->value, 'current_result_version' => 0, 'settle_version' => 0, 'is_reopened' => false, diff --git a/tests/Feature/AgentSettlementListsApiTest.php b/tests/Feature/AgentSettlementListsApiTest.php index cd50e95..633c96c 100644 --- a/tests/Feature/AgentSettlementListsApiTest.php +++ b/tests/Feature/AgentSettlementListsApiTest.php @@ -1,9 +1,9 @@ withHeader('Authorization', 'Bearer '.$token) ->getJson('/api/v1/admin/settlement-payments?admin_site_id='.$siteId) ->assertOk() - ->assertJsonPath('data.items.0.settlement_bill_id', $billId); + ->assertJsonPath('data.items.0.settlement_bill_id', $billId) + ->assertJsonPath('data.total', 1) + ->assertJsonPath('data.page', 1); $this->withHeader('Authorization', 'Bearer '.$token) ->getJson('/api/v1/admin/settlement-adjustments?admin_site_id='.$siteId) ->assertOk() - ->assertJsonPath('data.items.0.original_bill_id', $billId); + ->assertJsonPath('data.items.0.original_bill_id', $billId) + ->assertJsonPath('data.total', 1); + + $operations = $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/settlement-operations?admin_site_id='.$siteId.'&settlement_period_id='.$periodId) + ->assertOk() + ->assertJsonPath('data.total', 2) + ->json('data.items'); + + expect(collect($operations)->pluck('kind')->sort()->values()->all()) + ->toBe(['adjustment', 'payment']); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->getJson('/api/v1/admin/settlement-operations?admin_site_id='.$siteId.'&settlement_period_id='.$periodId.'&operation_type=payment') + ->assertOk() + ->assertJsonPath('data.total', 1) + ->assertJsonPath('data.items.0.kind', 'payment'); $this->withHeader('Authorization', 'Bearer '.$token) ->getJson('/api/v1/admin/settlement-bills?admin_site_id='.$siteId.'&bill_type=player') diff --git a/tests/Feature/FundOperationsHardeningTest.php b/tests/Feature/FundOperationsHardeningTest.php index 5adafd0..e5538f8 100644 --- a/tests/Feature/FundOperationsHardeningTest.php +++ b/tests/Feature/FundOperationsHardeningTest.php @@ -1,15 +1,15 @@ and(DB::table('settlement_adjustments')->where('original_bill_id', $billId)->count())->toBe(1); }); +test('bad debt write off idempotency key prevents duplicate records', function (): void { + $site = DB::table('admin_sites')->where('is_default', true)->first(); + $agentId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id'); + + $periodId = (int) DB::table('settlement_periods')->insertGetId([ + 'admin_site_id' => (int) $site->id, + 'period_start' => now()->subDays(7), + 'period_end' => now(), + 'status' => 'closed', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $player = Player::query()->create([ + 'site_code' => (string) $site->code, + 'agent_node_id' => $agentId, + 'site_player_id' => 'bd-idem-key', + 'auth_source' => 'lottery_native', + 'funding_mode' => 'credit', + 'username' => 'bdidemkey', + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + $billId = (int) DB::table('settlement_bills')->insertGetId([ + 'settlement_period_id' => $periodId, + 'bill_type' => 'player', + 'owner_type' => 'player', + 'owner_id' => $player->id, + 'counterparty_type' => 'agent', + 'counterparty_id' => $agentId, + 'gross_win_loss' => 5000, + 'rebate_amount' => 0, + 'adjustment_amount' => 0, + 'net_amount' => 5000, + 'paid_amount' => 0, + 'unpaid_amount' => 5000, + 'status' => 'overdue', + 'confirmed_at' => now(), + 'locked_at' => now(), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $admin = AdminUser::query()->create([ + 'username' => 'bad_debt_key_admin', + 'name' => 'Bad Debt Key', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + + $service = app(AgentSettlementBadDebtService::class); + $key = 'bd-idem-key-1'; + $first = $service->writeOff($billId, 'uncollectible', (int) $admin->id, $key); + $second = $service->writeOff($billId, 'uncollectible', (int) $admin->id, $key); + + expect($second)->toBe($first) + ->and(DB::table('settlement_adjustments')->where('original_bill_id', $billId)->count())->toBe(1) + ->and(DB::table('settlement_adjustments')->where('original_bill_id', $billId)->value('idempotency_key')) + ->toBe($key); +}); + test('out pending reconcile reverse still credits lottery wallet once', function (): void { $player = Player::query()->create([ 'site_code' => 'main', @@ -258,4 +322,4 @@ test('out pending reconcile reverse still credits lottery wallet once', function expect((int) $wallet->balance)->toBe(1_400) ->and($order->status)->toBe('reversed'); -}); \ No newline at end of file +});