diff --git a/app/Http/Controllers/Api/V1/Admin/Reconcile/ReconcileItemIndexController.php b/app/Http/Controllers/Api/V1/Admin/Reconcile/ReconcileItemIndexController.php index 5a6306b..49a6261 100644 --- a/app/Http/Controllers/Api/V1/Admin/Reconcile/ReconcileItemIndexController.php +++ b/app/Http/Controllers/Api/V1/Admin/Reconcile/ReconcileItemIndexController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Api\V1\Admin\Reconcile; +use App\Models\AdminUser; use App\Models\TransferOrder; use App\Models\ReconcileJob; use Illuminate\Http\Request; @@ -9,12 +10,24 @@ use App\Models\ReconcileItem; use App\Support\AdminApiList; use Illuminate\Http\JsonResponse; use App\Http\Controllers\Controller; +use App\Support\AdminTransferOrderCapabilities; +use App\Services\Wallet\LotteryTransferService; +use App\Services\Wallet\HttpMainSiteWalletIdempotentProbeClient; +use App\Services\Wallet\MainSiteWalletIdempotentProbeResult; /** GET /api/v1/admin/reconcile-jobs/{reconcile_job}/items */ final class ReconcileItemIndexController extends Controller { + public function __construct( + private readonly LotteryTransferService $transferService, + private readonly HttpMainSiteWalletIdempotentProbeClient $mainSiteProbeClient, + ) {} + public function __invoke(Request $request, ReconcileJob $reconcile_job): JsonResponse { + $admin = $request->lotteryAdmin(); + abort_if($admin === null, 401); + $p = AdminApiList::readPaging($request, 50, 200); $paginator = $reconcile_job->items() @@ -27,26 +40,104 @@ final class ReconcileItemIndexController extends Controller ->values() ->all(); - $transferStatuses = $transferNos === [] - ? [] + $transferOrders = $transferNos === [] + ? collect() : TransferOrder::query() + ->with(['player:id,site_code,site_player_id']) ->whereIn('transfer_no', $transferNos) - ->pluck('status', 'transfer_no') - ->all(); + ->get() + ->keyBy('transfer_no'); - return AdminApiList::jsonWith($paginator, fn (ReconcileItem $r) => [ - 'id' => (int) $r->id, - 'side_a_ref' => $r->side_a_ref, - 'side_b_ref' => $r->side_b_ref, - 'difference_amount' => (int) $r->difference_amount, - 'status' => $r->status, - 'resolved_at' => $r->resolved_at?->toIso8601String(), - 'is_resolved' => $r->resolved_at !== null || in_array($transferStatuses[$r->side_a_ref ?? ''] ?? null, ['success', 'reversed', 'manually_processed'], true), - 'current_transfer_status' => $transferStatuses[$r->side_a_ref ?? ''] ?? null, - 'created_at' => $r->created_at?->toIso8601String(), - ], [ + return AdminApiList::jsonWith($paginator, function (ReconcileItem $r) use ($transferOrders, $admin): array { + $transferNo = (string) ($r->side_a_ref ?? ''); + /** @var TransferOrder|null $order */ + $order = $transferNo !== '' ? $transferOrders->get($transferNo) : null; + $resolvedStatuses = ['success', 'reversed', 'manually_processed']; + $currentStatus = $order?->status; + $isResolved = $r->resolved_at !== null + || in_array($currentStatus, $resolvedStatuses, true); + + $capabilities = $order instanceof TransferOrder + ? AdminTransferOrderCapabilities::forOrder( + $order, + $admin instanceof AdminUser ? $admin : null, + $this->transferService, + ) + : [ + 'can_reverse' => false, + 'can_complete_credit' => false, + 'can_manually_process' => false, + ]; + + $mainSiteCheck = $this->resolveMainSiteCheck($order); + + return [ + 'id' => (int) $r->id, + 'side_a_ref' => $r->side_a_ref, + 'side_b_ref' => $r->side_b_ref, + 'external_ref_no' => $order?->external_ref_no, + 'difference_amount' => (int) $r->difference_amount, + 'status' => $r->status, + 'resolved_at' => $r->resolved_at?->toIso8601String(), + 'is_resolved' => $isResolved, + 'current_transfer_status' => $currentStatus, + 'transfer_direction' => $order?->direction, + 'transfer_fail_reason' => $order?->fail_reason, + 'main_site_check' => $mainSiteCheck['status'], + 'main_site_check_message' => $mainSiteCheck['message'], + 'main_site_external_ref_no' => $mainSiteCheck['external_ref_no'], + ...$capabilities, + 'created_at' => $r->created_at?->toIso8601String(), + ]; + }, [ 'job_id' => (int) $reconcile_job->id, 'job_no' => $reconcile_job->job_no, ]); } + + /** + * @return array{status: string, message: ?string, external_ref_no: ?string} + */ + private function resolveMainSiteCheck(?TransferOrder $order): array + { + if ($order === null || $order->player === null) { + return [ + 'status' => MainSiteWalletIdempotentProbeResult::STATUS_SKIPPED, + 'message' => null, + 'external_ref_no' => null, + ]; + } + + if (trim((string) $order->idempotent_key) === '') { + return [ + 'status' => MainSiteWalletIdempotentProbeResult::STATUS_SKIPPED, + 'message' => null, + 'external_ref_no' => null, + ]; + } + + $shouldProbe = trim((string) $order->external_ref_no) !== '' + || $order->status === 'success' + || ( + $order->direction === 'in' + && $order->status === 'pending_reconcile' + && $order->fail_reason === 'lottery_credit_failed' + ); + + if (! $shouldProbe) { + return [ + 'status' => MainSiteWalletIdempotentProbeResult::STATUS_SKIPPED, + 'message' => null, + 'external_ref_no' => null, + ]; + } + + $probe = $this->mainSiteProbeClient->probe($order->player, (string) $order->idempotent_key); + + return [ + 'status' => $probe->status, + 'message' => $probe->message, + 'external_ref_no' => $probe->externalRefNo ?? $order->external_ref_no, + ]; + } } diff --git a/app/Http/Controllers/Api/V1/Admin/Reconcile/ReconcileJobStoreController.php b/app/Http/Controllers/Api/V1/Admin/Reconcile/ReconcileJobStoreController.php index c1ac1bd..ad17d6b 100644 --- a/app/Http/Controllers/Api/V1/Admin/Reconcile/ReconcileJobStoreController.php +++ b/app/Http/Controllers/Api/V1/Admin/Reconcile/ReconcileJobStoreController.php @@ -30,11 +30,12 @@ final class ReconcileJobStoreController extends Controller (string) ($data['reconcile_type'] ?? 'wallet_transfer'), isset($data['period_start']) ? Carbon::parse((string) $data['period_start']) - : (isset($data['date_from']) ? Carbon::parse((string) $data['date_from']) : null), + : (isset($data['date_from']) ? Carbon::parse((string) $data['date_from'])->startOfDay() : null), isset($data['period_end']) ? Carbon::parse((string) $data['period_end']) - : (isset($data['date_to']) ? Carbon::parse((string) $data['date_to']) : null), + : (isset($data['date_to']) ? Carbon::parse((string) $data['date_to'])->endOfDay() : null), $items, + isset($data['player_id']) ? (int) $data['player_id'] : null, ); return ApiResponse::success([ diff --git a/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportDailyProfitController.php b/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportDailyProfitController.php index 933f8c1..e030b14 100644 --- a/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportDailyProfitController.php +++ b/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportDailyProfitController.php @@ -30,6 +30,8 @@ final class AdminReportDailyProfitController extends Controller $scope, ); - return AdminApiList::json($paginator, static fn (array $row): array => $row); + return AdminApiList::jsonWith($paginator, static fn (array $row): array => $row, [ + 'currency_code' => $service->resolvePeriodCurrencyCode($range['date_from'], $range['date_to'], $scope), + ]); } } diff --git a/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportPlayDimensionController.php b/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportPlayDimensionController.php index 341d0bb..194f62d 100644 --- a/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportPlayDimensionController.php +++ b/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportPlayDimensionController.php @@ -32,7 +32,7 @@ final class AdminReportPlayDimensionController extends Controller $scope, ); - return AdminApiList::json($paginator, static function (object $row): array { + return AdminApiList::jsonWith($paginator, static function (object $row): array { return [ 'play_code' => (string) $row->play_code, 'dimension' => (int) $row->dimension, @@ -40,6 +40,8 @@ final class AdminReportPlayDimensionController extends Controller 'total_payout_minor' => (int) $row->total_payout_minor, 'approx_house_gross_minor' => (int) $row->approx_house_gross_minor, ]; - }); + }, [ + 'currency_code' => $service->resolvePeriodCurrencyCode($range['date_from'], $range['date_to'], $scope), + ]); } } diff --git a/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportPlayerWinLossController.php b/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportPlayerWinLossController.php index 48a8c80..f2fa32a 100644 --- a/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportPlayerWinLossController.php +++ b/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportPlayerWinLossController.php @@ -32,7 +32,7 @@ final class AdminReportPlayerWinLossController extends Controller $scope, ); - return AdminApiList::json($paginator, static function (object $row): array { + return AdminApiList::jsonWith($paginator, static function (object $row): array { return [ 'player_id' => (int) $row->player_id, 'agent_node_id' => isset($row->agent_node_id) ? (int) $row->agent_node_id : null, @@ -43,6 +43,8 @@ final class AdminReportPlayerWinLossController extends Controller 'total_payout_minor' => (int) $row->total_payout_minor, 'net_win_loss_minor' => (int) $row->net_win_loss_minor, ]; - }); + }, [ + 'currency_code' => $service->resolvePeriodCurrencyCode($range['date_from'], $range['date_to'], $scope), + ]); } } diff --git a/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportRebateCommissionController.php b/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportRebateCommissionController.php index c1ddca1..e965fe2 100644 --- a/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportRebateCommissionController.php +++ b/app/Http/Controllers/Api/V1/Admin/Reports/AdminReportRebateCommissionController.php @@ -32,21 +32,16 @@ final class AdminReportRebateCommissionController extends Controller $scope, ); - $response = AdminApiList::json($paginator, static function (object $row): array { + return AdminApiList::jsonWith($paginator, static function (object $row): array { return [ 'play_code' => (string) $row->play_code, 'total_rebate_minor' => (int) $row->total_rebate_minor, 'order_count' => (int) $row->order_count, 'ticket_item_count' => (int) $row->ticket_item_count, ]; - }); - - $payload = $response->getData(true); - if (is_array($payload)) { - $payload['disclaimer'] = 'wallet_instant_rebate_not_agent_period_settlement'; - $response->setData($payload); - } - - return $response; + }, [ + 'currency_code' => $service->resolvePeriodCurrencyCode($range['date_from'], $range['date_to'], $scope), + 'disclaimer' => 'wallet_instant_rebate_not_agent_period_settlement', + ]); } } diff --git a/app/Http/Controllers/Api/V1/Admin/Wallet/TransferOrderListController.php b/app/Http/Controllers/Api/V1/Admin/Wallet/TransferOrderListController.php index 08705da..c82e207 100644 --- a/app/Http/Controllers/Api/V1/Admin/Wallet/TransferOrderListController.php +++ b/app/Http/Controllers/Api/V1/Admin/Wallet/TransferOrderListController.php @@ -9,6 +9,7 @@ use App\Support\PaginationTrait; use Illuminate\Http\JsonResponse; use App\Support\AdminScopePolicy; use App\Support\AgentNodeApiPresenter; +use App\Support\AdminTransferOrderCapabilities; use App\Support\CurrencyFormatter; use App\Services\Wallet\LotteryTransferService; use App\Http\Controllers\Controller; @@ -117,11 +118,6 @@ final class TransferOrderListController extends Controller { $p = $o->player; $amount = (int) $o->amount; - $canWriteWallet = $admin !== null && ( - $admin->hasPermissionCode('service.wallet.adjust') - || $admin->hasPermissionCode('service.reconcile.manage') - || $admin->hasPermissionCode('service.wallet.manage') - ); return [ 'id' => $o->id, @@ -138,16 +134,7 @@ final class TransferOrderListController extends Controller 'amount_formatted' => CurrencyFormatter::fromMinor($amount), 'idempotent_key' => $o->idempotent_key, 'status' => $o->status, - 'can_reverse' => $canWriteWallet - && $o->status === 'pending_reconcile' - && ($o->direction === 'out' || $this->transferService->isEligibleForTransferInReverse($o)), - 'can_complete_credit' => $canWriteWallet - && $o->direction === 'in' - && $o->status === 'pending_reconcile' - && $o->fail_reason === 'lottery_credit_failed' - && trim((string) $o->external_ref_no) !== '', - 'can_manually_process' => $canWriteWallet - && $this->transferService->isEligibleForManualProcess($o), + ...AdminTransferOrderCapabilities::forOrder($o, $admin, $this->transferService), 'external_ref_no' => $o->external_ref_no, 'external_request_payload' => $o->external_request_payload, 'external_response_payload' => $o->external_response_payload, diff --git a/app/Services/Admin/AdminReconcileJobService.php b/app/Services/Admin/AdminReconcileJobService.php index ce607e3..d2a9abf 100644 --- a/app/Services/Admin/AdminReconcileJobService.php +++ b/app/Services/Admin/AdminReconcileJobService.php @@ -10,12 +10,17 @@ use Illuminate\Http\Request; use App\Models\ReconcileItem; use App\Services\AuditLogger; use Illuminate\Support\Facades\DB; +use App\Services\Wallet\WalletTransferReconcileDetector; /** - * 对账任务:落库 `reconcile_jobs` / `reconcile_items`(阶段 7;差异引擎可后续替换)。 + * 对账任务:落库 `reconcile_jobs` / `reconcile_items`。 */ final class AdminReconcileJobService { + public function __construct( + private readonly WalletTransferReconcileDetector $detector, + ) {} + /** * @param list|null $items */ @@ -26,7 +31,12 @@ final class AdminReconcileJobService ?Carbon $periodStart, ?Carbon $periodEnd, ?array $items, + ?int $playerId = null, ): ReconcileJob { + if ($reconcileType === 'wallet_transfer' && $items === null) { + return $this->createWalletTransferScanJob($admin, $request, $periodStart, $periodEnd, $playerId); + } + return DB::transaction(function () use ($admin, $request, $reconcileType, $periodStart, $periodEnd, $items): ReconcileJob { $jobNo = 'REC'.now()->format('YmdHis').strtoupper(Str::random(4)); @@ -81,4 +91,44 @@ final class AdminReconcileJobService return $job->fresh(); }); } + + private function createWalletTransferScanJob( + AdminUser $admin, + Request $request, + ?Carbon $periodStart, + ?Carbon $periodEnd, + ?int $playerId, + ): ReconcileJob { + $periodEnd ??= now()->endOfDay(); + $periodStart ??= $periodEnd->copy()->startOfDay(); + + [$items] = $this->detector->scanItems( + $periodStart, + $periodEnd, + limit: 1000, + staleMinutes: 15, + playerId: $playerId, + includeMainSiteCheck: true, + ); + + $job = $this->detector->persistJob($items, $periodStart, $periodEnd, (int) $admin->getKey()); + + AuditLogger::recordForAdmin( + $admin, + $request, + 'reconcile_jobs', + 'create', + 'reconcile_job', + (string) $job->getKey(), + null, + [ + 'job_no' => $job->job_no, + 'reconcile_type' => 'wallet_transfer', + 'item_count' => count($items), + 'player_id' => $playerId, + ], + ); + + return $job->fresh(); + } } diff --git a/app/Services/Admin/AdminReportQueryService.php b/app/Services/Admin/AdminReportQueryService.php index 706a01c..e63ebd9 100644 --- a/app/Services/Admin/AdminReportQueryService.php +++ b/app/Services/Admin/AdminReportQueryService.php @@ -46,9 +46,9 @@ final class AdminReportQueryService $fromRaw = trim((string) ($filters['date_from'] ?? '')); $toRaw = trim((string) ($filters['date_to'] ?? '')); - // 未传日期时按历史全量范围导出/查询,避免默认“仅今天”导致空数据。 + // 未传日期时默认近 30 个自然日(与后台报表中心默认筛选一致)。 if ($fromRaw === '' && $toRaw === '') { - return $this->lifetimeBusinessDateBounds(); + return $this->defaultReportDateRange(); } $dateFrom = $fromRaw !== '' ? $fromRaw : $toRaw; @@ -99,6 +99,19 @@ final class AdminReportQueryService return ['date_from' => $from, 'date_to' => $to]; } + /** + * @return array{date_from: string, date_to: string} + */ + public function defaultReportDateRange(): array + { + $today = now()->toDateString(); + + return [ + 'date_from' => now()->subDays(29)->toDateString(), + 'date_to' => $today, + ]; + } + /** * @return array{date_from: string, date_to: string} */ @@ -298,7 +311,8 @@ final class AdminReportQueryService $context = $this->normalizeScope($scope); $currencyQuery = DB::table('ticket_orders as o') ->join('draws as d', 'd.id', '=', 'o.draw_id') - ->whereBetween('d.business_date', [$dateFrom, $dateTo]); + ->whereDate('d.business_date', '>=', $dateFrom) + ->whereDate('d.business_date', '<=', $dateTo); AdminDataScope::applyToTicketOrdersViaPlayer($currencyQuery, $context?->admin, 'o'); $currencyCode = (string) ($currencyQuery->orderByDesc('o.id')->value('o.currency_code') ?? ''); @@ -335,10 +349,12 @@ final class AdminReportQueryService AdminDataScope::applyToTicketOrdersViaPlayer($payoutSub, $context?->admin, 'o'); return DB::table('draws as d') - ->whereBetween('d.business_date', [$dateFrom, $dateTo]) + ->whereDate('d.business_date', '>=', $dateFrom) + ->whereDate('d.business_date', '<=', $dateTo) ->leftJoinSub($betSub, 'b', 'b.draw_id', '=', 'd.id') ->leftJoinSub($payoutSub, 'p', 'p.draw_id', '=', 'd.id') ->groupBy('d.business_date') + ->havingRaw('COALESCE(SUM(b.total_bet_minor), 0) > 0 OR COALESCE(SUM(p.total_payout_minor), 0) > 0') ->orderBy('d.business_date') ->get([ 'd.business_date', @@ -349,7 +365,7 @@ final class AdminReportQueryService ->map(static function (object $row): array { $businessDate = $row->business_date instanceof Carbon ? $row->business_date->format('Y-m-d') - : (string) $row->business_date; + : substr((string) $row->business_date, 0, 10); return [ 'business_date' => $businessDate, @@ -665,6 +681,7 @@ final class AdminReportQueryService $context = $this->normalizeScope($scope); $query = DB::table('ticket_items as ti') ->join('ticket_orders as o', 'o.id', '=', 'ti.order_id') + ->join('draws as d', 'd.id', '=', 'o.draw_id') ->leftJoin('players as p', 'p.id', '=', 'ti.player_id') ->leftJoin('agent_nodes as an', 'an.id', '=', 'p.agent_node_id') ->selectRaw('ti.player_id') @@ -675,8 +692,8 @@ final class AdminReportQueryService ->selectRaw('SUM(ti.actual_deduct_amount) as total_bet_minor') ->selectRaw('SUM(ti.win_amount + ti.jackpot_win_amount) as total_payout_minor') ->selectRaw('SUM(ti.actual_deduct_amount) - SUM(ti.win_amount + ti.jackpot_win_amount) as net_win_loss_minor') - ->whereDate('o.created_at', '>=', $dateFrom) - ->whereDate('o.created_at', '<=', $dateTo) + ->whereDate('d.business_date', '>=', $dateFrom) + ->whereDate('d.business_date', '<=', $dateTo) ->groupBy('ti.player_id', 'p.username', 'p.agent_node_id', 'an.code', 'an.name') ->orderByDesc('net_win_loss_minor'); @@ -700,13 +717,14 @@ final class AdminReportQueryService $context = $this->normalizeScope($scope); $query = DB::table('ticket_items as ti') ->join('ticket_orders as o', 'o.id', '=', 'ti.order_id') + ->join('draws as d', 'd.id', '=', 'o.draw_id') ->selectRaw('ti.play_code') ->selectRaw('ti.dimension') ->selectRaw('SUM(ti.actual_deduct_amount) as total_bet_minor') ->selectRaw('SUM(ti.win_amount + ti.jackpot_win_amount) as total_payout_minor') ->selectRaw('SUM(ti.actual_deduct_amount) - SUM(ti.win_amount + ti.jackpot_win_amount) as approx_house_gross_minor') - ->whereDate('o.created_at', '>=', $dateFrom) - ->whereDate('o.created_at', '<=', $dateTo) + ->whereDate('d.business_date', '>=', $dateFrom) + ->whereDate('d.business_date', '<=', $dateTo) ->groupBy('ti.play_code', 'ti.dimension') ->orderBy('ti.play_code') ->orderBy('ti.dimension'); @@ -726,12 +744,13 @@ final class AdminReportQueryService $context = $this->normalizeScope($scope); $query = DB::table('ticket_items as ti') ->join('ticket_orders as o', 'o.id', '=', 'ti.order_id') + ->join('draws as d', 'd.id', '=', 'o.draw_id') ->selectRaw('ti.play_code') ->selectRaw('SUM(ti.total_bet_amount - ti.actual_deduct_amount) as total_rebate_minor') ->selectRaw('COUNT(DISTINCT o.id) as order_count') ->selectRaw('COUNT(ti.id) as ticket_item_count') - ->whereDate('o.created_at', '>=', $dateFrom) - ->whereDate('o.created_at', '<=', $dateTo) + ->whereDate('d.business_date', '>=', $dateFrom) + ->whereDate('d.business_date', '<=', $dateTo) ->groupBy('ti.play_code') ->orderBy('ti.play_code'); diff --git a/app/Services/Integration/PartnerSiteConfig.php b/app/Services/Integration/PartnerSiteConfig.php index 9009ecf..dd49cbc 100644 --- a/app/Services/Integration/PartnerSiteConfig.php +++ b/app/Services/Integration/PartnerSiteConfig.php @@ -18,6 +18,7 @@ final readonly class PartnerSiteConfig public string $walletDebitPath, public string $walletCreditPath, public string $walletBalancePath, + public string $walletLookupIdempotentPath, public ?string $ssoJwtSecret, public ?string $walletApiKey, public int $walletTimeoutSeconds, @@ -48,6 +49,7 @@ final readonly class PartnerSiteConfig 'wallet_debit_path' => $this->walletDebitPath, 'wallet_credit_path' => $this->walletCreditPath, 'wallet_balance_path' => $this->walletBalancePath, + 'wallet_lookup_idempotent_path' => $this->walletLookupIdempotentPath, 'sso_jwt_secret' => $this->ssoJwtSecret, 'wallet_api_key' => $this->walletApiKey, 'wallet_timeout_seconds' => $this->walletTimeoutSeconds, @@ -69,6 +71,7 @@ final readonly class PartnerSiteConfig walletDebitPath: (string) ($data['wallet_debit_path'] ?? '/wallet/debit-for-lottery'), walletCreditPath: (string) ($data['wallet_credit_path'] ?? '/wallet/credit-from-lottery'), walletBalancePath: (string) ($data['wallet_balance_path'] ?? '/wallet/balance'), + walletLookupIdempotentPath: (string) ($data['wallet_lookup_idempotent_path'] ?? '/wallet/lookup-idempotent'), ssoJwtSecret: isset($data['sso_jwt_secret']) && is_string($data['sso_jwt_secret']) && $data['sso_jwt_secret'] !== '' ? $data['sso_jwt_secret'] : null, diff --git a/app/Services/Integration/PartnerSiteConfigResolver.php b/app/Services/Integration/PartnerSiteConfigResolver.php index 3e35a91..36e5d0d 100644 --- a/app/Services/Integration/PartnerSiteConfigResolver.php +++ b/app/Services/Integration/PartnerSiteConfigResolver.php @@ -86,6 +86,7 @@ final class PartnerSiteConfigResolver walletDebitPath: (string) ($site->wallet_debit_path ?: '/wallet/debit-for-lottery'), walletCreditPath: (string) ($site->wallet_credit_path ?: '/wallet/credit-from-lottery'), walletBalancePath: (string) ($site->wallet_balance_path ?: '/wallet/balance'), + walletLookupIdempotentPath: (string) config('lottery.main_site.wallet_lookup_idempotent_path', '/wallet/lookup-idempotent'), ssoJwtSecret: $site->decryptedSsoJwtSecret(), walletApiKey: $site->decryptedWalletApiKey(), walletTimeoutSeconds: max(1, (int) ($site->wallet_timeout_seconds ?? 10)), @@ -129,6 +130,7 @@ final class PartnerSiteConfigResolver walletDebitPath: (string) config('lottery.main_site.wallet_debit_path', '/wallet/debit-for-lottery'), walletCreditPath: (string) config('lottery.main_site.wallet_credit_path', '/wallet/credit-from-lottery'), walletBalancePath: (string) config('lottery.main_site.wallet_balance_path', '/wallet/balance'), + walletLookupIdempotentPath: (string) config('lottery.main_site.wallet_lookup_idempotent_path', '/wallet/lookup-idempotent'), ssoJwtSecret: is_string($sso) && $sso !== '' ? $sso : null, walletApiKey: is_string($walletKey) && $walletKey !== '' ? $walletKey : null, walletTimeoutSeconds: max(1, (int) config('lottery.main_site.wallet_timeout', 10)), @@ -143,6 +145,7 @@ final class PartnerSiteConfigResolver walletDebitPath: '/wallet/debit-for-lottery', walletCreditPath: '/wallet/credit-from-lottery', walletBalancePath: '/wallet/balance', + walletLookupIdempotentPath: '/wallet/lookup-idempotent', ssoJwtSecret: null, walletApiKey: null, walletTimeoutSeconds: 10, diff --git a/app/Services/Wallet/HttpMainSiteWalletIdempotentProbeClient.php b/app/Services/Wallet/HttpMainSiteWalletIdempotentProbeClient.php new file mode 100644 index 0000000..65aa7c8 --- /dev/null +++ b/app/Services/Wallet/HttpMainSiteWalletIdempotentProbeClient.php @@ -0,0 +1,109 @@ +partnerSiteConfigResolver->resolveForPlayer($player); + if (! $config->enabled || ! $config->hasWalletApi()) { + return new MainSiteWalletIdempotentProbeResult( + status: MainSiteWalletIdempotentProbeResult::STATUS_SKIPPED, + message: 'wallet_api_not_configured', + ); + } + + $base = WalletApiUrlSanitizer::normalizeAndValidate($config->walletApiUrl); + if ($base === null) { + return new MainSiteWalletIdempotentProbeResult( + status: MainSiteWalletIdempotentProbeResult::STATUS_UNAVAILABLE, + message: 'wallet_api_url_invalid', + ); + } + + $path = $config->walletLookupIdempotentPath; + $url = $base.'/'.ltrim($path, '/'); + $headers = ['Accept' => 'application/json']; + if (is_string($config->walletApiKey) && $config->walletApiKey !== '') { + $headers['Authorization'] = 'Bearer '.$config->walletApiKey; + } + + $query = [ + 'site_code' => $player->site_code, + 'site_player_id' => $player->site_player_id, + 'idempotent_key' => $idempotentKey, + ]; + + try { + $response = Http::withHeaders($headers) + ->timeout($config->walletTimeoutSeconds) + ->acceptJson() + ->get($url, $query); + } catch (\Throwable $e) { + return new MainSiteWalletIdempotentProbeResult( + status: MainSiteWalletIdempotentProbeResult::STATUS_UNAVAILABLE, + message: $e->getMessage(), + ); + } + + $payload = $response->json(); + if (! $response->successful() || ! is_array($payload)) { + $message = is_array($payload) && is_string($payload['message'] ?? null) + ? (string) $payload['message'] + : 'HTTP '.$response->status(); + + return new MainSiteWalletIdempotentProbeResult( + status: MainSiteWalletIdempotentProbeResult::STATUS_UNAVAILABLE, + message: $message, + ); + } + + $data = data_get($payload, 'data'); + if (! is_array($data)) { + return new MainSiteWalletIdempotentProbeResult( + status: MainSiteWalletIdempotentProbeResult::STATUS_UNAVAILABLE, + message: 'invalid_response', + ); + } + + $found = (bool) ($data['found'] ?? false); + if (! $found) { + return new MainSiteWalletIdempotentProbeResult( + status: MainSiteWalletIdempotentProbeResult::STATUS_NOT_FOUND, + found: false, + success: null, + ); + } + + $success = (bool) ($data['success'] ?? false); + + return new MainSiteWalletIdempotentProbeResult( + status: $success + ? MainSiteWalletIdempotentProbeResult::STATUS_MATCHED + : MainSiteWalletIdempotentProbeResult::STATUS_FAILED, + found: true, + success: $success, + externalRefNo: is_string($data['external_ref_no'] ?? null) ? $data['external_ref_no'] : null, + operation: is_string($data['operation'] ?? null) ? $data['operation'] : null, + ); + } +} diff --git a/app/Services/Wallet/MainSiteWalletIdempotentProbeResult.php b/app/Services/Wallet/MainSiteWalletIdempotentProbeResult.php new file mode 100644 index 0000000..8dde042 --- /dev/null +++ b/app/Services/Wallet/MainSiteWalletIdempotentProbeResult.php @@ -0,0 +1,22 @@ + $items + * @param Collection $orders + * @return list + */ + public function appendIssues(array $items, Collection $orders): array + { + $existingTransferNos = collect($items) + ->map(fn (array $row): string => (string) ($row['side_a_ref'] ?? '')) + ->filter() + ->flip(); + + $players = Player::query() + ->whereIn('id', $orders->pluck('player_id')->unique()->all()) + ->get() + ->keyBy('id'); + + foreach ($orders as $order) { + if (! $this->shouldProbeMainSite($order)) { + continue; + } + + $transferNo = (string) $order->transfer_no; + if ($existingTransferNos->has($transferNo)) { + continue; + } + + $player = $players->get((int) $order->player_id); + if (! $player instanceof Player) { + continue; + } + + $probe = $this->probeClient->probe($player, (string) $order->idempotent_key); + if ($probe->status === MainSiteWalletIdempotentProbeResult::STATUS_SKIPPED) { + continue; + } + + if ($probe->status === MainSiteWalletIdempotentProbeResult::STATUS_NOT_FOUND) { + $items[] = [ + 'side_a_ref' => $transferNo, + 'side_b_ref' => null, + 'difference_amount' => (int) $order->amount, + 'status' => 'main_site_record_missing', + ]; + continue; + } + + if ($probe->status === MainSiteWalletIdempotentProbeResult::STATUS_FAILED) { + $items[] = [ + 'side_a_ref' => $transferNo, + 'side_b_ref' => $probe->externalRefNo, + 'difference_amount' => (int) $order->amount, + 'status' => 'main_site_failed', + ]; + } + } + + return $items; + } + + private function shouldProbeMainSite(TransferOrder $order): bool + { + if (trim((string) $order->external_ref_no) !== '') { + return true; + } + + if ($order->status === 'success') { + return true; + } + + return $order->direction === 'in' + && $order->status === 'pending_reconcile' + && $order->fail_reason === 'lottery_credit_failed'; + } +} diff --git a/app/Services/Wallet/WalletTransferReconcileDetector.php b/app/Services/Wallet/WalletTransferReconcileDetector.php index 6f7e113..5fd8d27 100644 --- a/app/Services/Wallet/WalletTransferReconcileDetector.php +++ b/app/Services/Wallet/WalletTransferReconcileDetector.php @@ -28,15 +28,40 @@ final class WalletTransferReconcileDetector ?Carbon $periodEnd = null, int $limit = 500, int $staleMinutes = 15, + ?int $playerId = null, + bool $includeMainSiteCheck = true, ): ?ReconcileJob { $periodEnd ??= now(); $periodStart ??= $periodEnd->copy()->subDay(); + + [$items, $orders] = $this->scanItems($periodStart, $periodEnd, $limit, $staleMinutes, $playerId, $includeMainSiteCheck); + + if ($items === []) { + return null; + } + + return $this->persistJob($items, $periodStart, $periodEnd, null); + } + + /** + * @return array{ + * 0: list, + * 1: \Illuminate\Support\Collection + * } + */ + public function scanItems( + Carbon $periodStart, + Carbon $periodEnd, + int $limit = 500, + int $staleMinutes = 15, + ?int $playerId = null, + bool $includeMainSiteCheck = true, + ): array { $limit = max(1, $limit); $staleMinutes = max(1, $staleMinutes); - $staleCutoff = $periodEnd->copy()->subMinutes($staleMinutes); - $orders = TransferOrder::query() + $query = TransferOrder::query() ->where(function ($q) use ($periodStart, $periodEnd, $staleCutoff): void { $q->whereBetween('created_at', [$periodStart, $periodEnd]) ->orWhere('status', 'pending_reconcile') @@ -46,11 +71,16 @@ final class WalletTransferReconcileDetector }); }) ->orderBy('id') - ->limit($limit) - ->get(); + ->limit($limit); + + if ($playerId !== null) { + $query->where('player_id', $playerId); + } + + $orders = $query->get(); if ($orders->isEmpty()) { - return null; + return [[], $orders]; } $txnsByTransferNo = WalletTxn::query() @@ -73,16 +103,28 @@ final class WalletTransferReconcileDetector } } - if ($items === []) { - return null; + if ($includeMainSiteCheck) { + $items = app(WalletTransferMainSiteReconcileChecker::class)->appendIssues($items, $orders); } - return DB::transaction(function () use ($items, $periodStart, $periodEnd): ReconcileJob { + return [$items, $orders]; + } + + /** + * @param list $items + */ + public function persistJob( + array $items, + Carbon $periodStart, + Carbon $periodEnd, + ?int $adminUserId, + ): ReconcileJob { + return DB::transaction(function () use ($items, $periodStart, $periodEnd, $adminUserId): ReconcileJob { $jobNo = 'REC'.now()->format('YmdHis').strtoupper(str_replace('-', '', Str::uuid()->toString())); $job = ReconcileJob::query()->create([ 'job_no' => $jobNo, - 'admin_user_id' => null, + 'admin_user_id' => $adminUserId, 'reconcile_type' => self::RECONCILE_TYPE, 'status' => 'completed', 'period_start' => $periodStart, diff --git a/app/Support/AdminAuthorizationRegistry.php b/app/Support/AdminAuthorizationRegistry.php index 9effc5d..5fe7f2a 100644 --- a/app/Support/AdminAuthorizationRegistry.php +++ b/app/Support/AdminAuthorizationRegistry.php @@ -550,12 +550,13 @@ final class AdminAuthorizationRegistry ['code' => 'admin.players.wallets', 'module_code' => 'player_service', 'name' => '玩家钱包查看', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/players/{player}/wallets', 'route_name' => 'api.v1.admin.players.wallets', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.wallet.view']], ['code' => 'admin.players.ticket-items', 'module_code' => 'player_service', 'name' => '玩家注单查看', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/players/{player}/ticket-items', 'route_name' => 'api.v1.admin.players.ticket-items.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.players.manage', 'service.tickets.view']], ['code' => 'admin.tickets.index', 'module_code' => 'ticket', 'name' => '后台注单列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/tickets', 'route_name' => 'api.v1.admin.tickets.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.tickets.view']], + ['code' => 'admin.tickets.show', 'module_code' => 'ticket', 'name' => '后台注单详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/tickets/{ticket_no}', 'route_name' => 'api.v1.admin.tickets.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.tickets.view', 'prd.draw_result.manage', 'prd.draw_result.view', 'prd.risk.view', 'prd.risk.manage']], ['code' => 'admin.wallet.transfer-orders', 'module_code' => 'wallet', 'name' => '转账单查询', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/wallet/transfer-orders', 'route_name' => 'api.v1.admin.wallet.transfer-orders', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs', 'prd.wallet_adjust.manage']], ['code' => 'admin.wallet.transactions', 'module_code' => 'wallet', 'name' => '钱包流水查询', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/wallet/transactions', 'route_name' => 'api.v1.admin.wallet.transactions', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs']], - ['code' => 'admin.wallet.transfer-orders.reverse', 'module_code' => 'wallet', 'name' => '冲正转账单', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/wallet/transfer-orders/{transfer_no}/reverse', 'route_name' => 'api.v1.admin.wallet.transfer-orders.reverse', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['service.wallet.adjust']], - ['code' => 'admin.wallet.transfer-orders.manually-process', 'module_code' => 'wallet', 'name' => '手工处理转账单', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/wallet/transfer-orders/{transfer_no}/manually-process', 'route_name' => 'api.v1.admin.wallet.transfer-orders.manually-process', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['service.wallet.adjust']], - ['code' => 'admin.wallet.transfer-orders.complete-credit', 'module_code' => 'wallet', 'name' => '补完成转入入账', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/wallet/transfer-orders/{transfer_no}/complete-credit', 'route_name' => 'api.v1.admin.wallet.transfer-orders.complete-credit', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['service.wallet.adjust']], + ['code' => 'admin.wallet.transfer-orders.reverse', 'module_code' => 'wallet', 'name' => '冲正转账单', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/wallet/transfer-orders/{transfer_no}/reverse', 'route_name' => 'api.v1.admin.wallet.transfer-orders.reverse', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['service.wallet.adjust', 'service.reconcile.manage']], + ['code' => 'admin.wallet.transfer-orders.manually-process', 'module_code' => 'wallet', 'name' => '手工处理转账单', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/wallet/transfer-orders/{transfer_no}/manually-process', 'route_name' => 'api.v1.admin.wallet.transfer-orders.manually-process', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['service.wallet.adjust', 'service.reconcile.manage']], + ['code' => 'admin.wallet.transfer-orders.complete-credit', 'module_code' => 'wallet', 'name' => '补完成转入入账', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/wallet/transfer-orders/{transfer_no}/complete-credit', 'route_name' => 'api.v1.admin.wallet.transfer-orders.complete-credit', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['service.wallet.adjust', 'service.reconcile.manage']], ['code' => 'admin.reconcile-jobs.index', 'module_code' => 'reconcile', 'name' => '对账任务列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reconcile-jobs', 'route_name' => 'api.v1.admin.reconcile-jobs.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs']], ['code' => 'admin.reconcile-jobs.show', 'module_code' => 'reconcile', 'name' => '对账任务详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reconcile-jobs/{reconcile_job}', 'route_name' => 'api.v1.admin.reconcile-jobs.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs']], ['code' => 'admin.reconcile-jobs.items.index', 'module_code' => 'reconcile', 'name' => '对账任务明细', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/reconcile-jobs/{reconcile_job}/items', 'route_name' => 'api.v1.admin.reconcile-jobs.items.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'legacy_permission_slugs' => ['prd.wallet_reconcile.manage', 'prd.wallet_reconcile.view', 'prd.wallet_reconcile.view_cs']], diff --git a/app/Support/AdminTransferOrderCapabilities.php b/app/Support/AdminTransferOrderCapabilities.php new file mode 100644 index 0000000..17478dc --- /dev/null +++ b/app/Support/AdminTransferOrderCapabilities.php @@ -0,0 +1,47 @@ +hasPermissionCode('service.wallet.adjust') + || $admin->hasPermissionCode('service.reconcile.manage') + || $admin->hasPermissionCode('service.wallet.manage'); + } + + /** + * @return array{ + * can_reverse: bool, + * can_complete_credit: bool, + * can_manually_process: bool + * } + */ + public static function forOrder(TransferOrder $order, ?AdminUser $admin, LotteryTransferService $transferService): array + { + $canWrite = self::canWriteWallet($admin); + + return [ + 'can_reverse' => $canWrite + && $order->status === 'pending_reconcile' + && ($order->direction === 'out' || $transferService->isEligibleForTransferInReverse($order)), + 'can_complete_credit' => $canWrite + && $order->direction === 'in' + && $order->status === 'pending_reconcile' + && $order->fail_reason === 'lottery_credit_failed' + && trim((string) $order->external_ref_no) !== '', + 'can_manually_process' => $canWrite + && $transferService->isEligibleForManualProcess($order), + ]; + } +} diff --git a/config/lottery.php b/config/lottery.php index f23ab17..1b0d4af 100644 --- a/config/lottery.php +++ b/config/lottery.php @@ -40,6 +40,7 @@ return [ 'wallet_debit_path' => env('MAIN_SITE_WALLET_DEBIT_PATH', '/wallet/debit-for-lottery'), 'wallet_credit_path' => env('MAIN_SITE_WALLET_CREDIT_PATH', '/wallet/credit-from-lottery'), 'wallet_balance_path' => env('MAIN_SITE_WALLET_BALANCE_PATH', '/wallet/balance'), + 'wallet_lookup_idempotent_path' => env('MAIN_SITE_WALLET_LOOKUP_IDEMPOTENT_PATH', '/wallet/lookup-idempotent'), ], /* diff --git a/database/migrations/2026_06_16_120000_seed_admin_tickets_show_api_resource.php b/database/migrations/2026_06_16_120000_seed_admin_tickets_show_api_resource.php new file mode 100644 index 0000000..fcde3e7 --- /dev/null +++ b/database/migrations/2026_06_16_120000_seed_admin_tickets_show_api_resource.php @@ -0,0 +1,89 @@ +firstWhere('code', self::RESOURCE_CODE); + + if (! is_array($resource)) { + return; + } + + $now = Carbon::now(); + $menuActionIds = DB::table('admin_menu_actions')->pluck('id', 'permission_code'); + + $resourceId = DB::table('admin_api_resources') + ->where('code', self::RESOURCE_CODE) + ->value('id'); + + $payload = [ + 'module_code' => $resource['module_code'], + 'name' => $resource['name'], + 'http_method' => $resource['http_method'], + 'uri_pattern' => $resource['uri_pattern'], + 'route_name' => $resource['route_name'], + 'auth_mode' => $resource['auth_mode'], + 'is_audit_required' => $resource['is_audit_required'], + 'status' => 1, + 'meta_json' => null, + 'updated_at' => $now, + ]; + + if ($resourceId === null) { + $resourceId = DB::table('admin_api_resources')->insertGetId($payload + [ + 'code' => self::RESOURCE_CODE, + 'created_at' => $now, + ]); + } else { + DB::table('admin_api_resources') + ->where('id', (int) $resourceId) + ->update($payload); + } + + DB::table('admin_api_resource_bindings') + ->where('api_resource_id', (int) $resourceId) + ->delete(); + + foreach ($resource['permission_codes'] as $permissionCode) { + $menuActionId = $menuActionIds[$permissionCode] ?? null; + if ($menuActionId === null) { + continue; + } + + DB::table('admin_api_resource_bindings')->insert([ + 'api_resource_id' => (int) $resourceId, + 'menu_action_id' => (int) $menuActionId, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } + + public function down(): void + { + $resourceId = DB::table('admin_api_resources') + ->where('code', self::RESOURCE_CODE) + ->value('id'); + + if ($resourceId === null) { + return; + } + + DB::table('admin_api_resource_bindings') + ->where('api_resource_id', (int) $resourceId) + ->delete(); + DB::table('admin_api_resources') + ->where('id', (int) $resourceId) + ->delete(); + } +}; diff --git a/tests/Feature/AdminReconcileJobScanTest.php b/tests/Feature/AdminReconcileJobScanTest.php new file mode 100644 index 0000000..8065b81 --- /dev/null +++ b/tests/Feature/AdminReconcileJobScanTest.php @@ -0,0 +1,88 @@ +seed(AdminRbacAndUserSeeder::class); + $this->artisan('lottery:admin-auth-sync')->assertExitCode(0); +}); + +function reconcileScanToken(): string +{ + $admin = AdminUser::query()->create([ + 'username' => 'reconcile_scan_admin', + 'name' => 'Reconcile Scan', + 'email' => null, + 'password' => Hash::make('secret-strong'), + 'status' => 0, + ]); + grantSuperAdminRole($admin); + + return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken; +} + +test('manual reconcile job create runs wallet transfer detector scan', function (): void { + $token = reconcileScanToken(); + $admin = AdminUser::query()->where('username', 'reconcile_scan_admin')->firstOrFail(); + + $player = Player::query()->create([ + 'site_code' => 'main', + 'site_player_id' => 'manual-reconcile-1', + 'username' => null, + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + TransferOrder::query()->create([ + 'transfer_no' => 'TO_manual_scan', + 'player_id' => $player->id, + 'direction' => 'out', + 'currency_code' => 'NPR', + 'amount' => 500, + 'idempotent_key' => 'manual-scan-key', + 'status' => 'pending_reconcile', + 'external_request_payload' => null, + 'external_response_payload' => null, + 'external_ref_no' => null, + 'fail_reason' => 'main_site_timeout', + 'finished_at' => null, + 'created_at' => now()->subHours(2), + ]); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/reconcile-jobs', [ + 'reconcile_type' => 'wallet_transfer', + 'date_from' => now()->subDay()->toDateString(), + 'date_to' => now()->toDateString(), + 'player_id' => $player->id, + ]) + ->assertOk() + ->assertJsonPath('data.item_count', 1); + + $job = ReconcileJob::query()->latest('id')->firstOrFail(); + expect($job->admin_user_id)->toBe($admin->id) + ->and($job->items()->count())->toBe(1) + ->and($job->items()->value('side_a_ref'))->toBe('TO_manual_scan'); +}); + +test('manual reconcile job create returns zero items when scan finds nothing', function (): void { + $token = reconcileScanToken(); + + $this->withHeader('Authorization', 'Bearer '.$token) + ->postJson('/api/v1/admin/reconcile-jobs', [ + 'reconcile_type' => 'wallet_transfer', + 'date_from' => now()->subDay()->toDateString(), + 'date_to' => now()->toDateString(), + ]) + ->assertOk() + ->assertJsonPath('data.item_count', 0); +}); diff --git a/tests/Feature/AdminReportDateRangeFixTest.php b/tests/Feature/AdminReportDateRangeFixTest.php new file mode 100644 index 0000000..73846e2 --- /dev/null +++ b/tests/Feature/AdminReportDateRangeFixTest.php @@ -0,0 +1,171 @@ +resolveDateRange([]); + + expect($range['date_to'])->toBe(now()->toDateString()) + ->and($range['date_from'])->toBe(now()->subDays(29)->toDateString()); +}); + +test('daily profit rows omit business days with no betting activity', function (): void { + $player = Player::query()->create([ + 'site_code' => 'main', + 'site_player_id' => 'rpt-daily-1', + 'username' => 'daily_rpt', + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + Draw::query()->create([ + 'draw_no' => '20260502-001', + 'business_date' => '2026-05-02', + 'sequence_no' => 2, + 'status' => 'settled', + 'start_time' => now()->subDay(), + 'close_time' => now()->subDay()->addHour(), + 'draw_time' => now()->subDay()->addHours(2), + 'cooling_end_time' => null, + 'result_source' => null, + 'current_result_version' => 1, + 'settle_version' => 1, + 'is_reopened' => false, + ]); + + $activeDraw = Draw::query()->create([ + 'draw_no' => '20260501-001', + 'business_date' => '2026-05-01', + 'sequence_no' => 1, + 'status' => 'settled', + 'start_time' => now()->subDays(2), + 'close_time' => now()->subDays(2)->addHour(), + 'draw_time' => now()->subDays(2)->addHours(2), + 'cooling_end_time' => null, + 'result_source' => null, + 'current_result_version' => 1, + 'settle_version' => 1, + 'is_reopened' => false, + ]); + + $order = TicketOrder::query()->create([ + 'order_no' => 'RPT-DAILY-ORD', + 'player_id' => $player->id, + 'draw_id' => $activeDraw->id, + 'currency_code' => 'NPR', + 'total_bet_amount' => 1000, + 'total_rebate_amount' => 0, + 'total_actual_deduct' => 1000, + 'total_estimated_payout' => 0, + 'status' => 'settled', + 'submit_source' => 'h5', + 'client_trace_id' => 'trace-daily', + ]); + + TicketItem::query()->create([ + 'ticket_no' => 'RPT-DAILY-TK', + 'order_id' => $order->id, + 'player_id' => $player->id, + 'draw_id' => $activeDraw->id, + 'original_number' => '1234', + 'normalized_number' => '1234', + 'play_code' => 'big', + 'dimension' => 4, + 'digit_slot' => null, + 'bet_mode' => 'single', + 'unit_bet_amount' => 1000, + 'total_bet_amount' => 1000, + 'rebate_rate_snapshot' => '0.0000', + 'commission_rate_snapshot' => '0.0000', + 'actual_deduct_amount' => 1000, + 'win_amount' => 0, + 'jackpot_win_amount' => 0, + 'status' => 'settled', + ]); + + $rows = app(AdminReportQueryService::class)->dailyProfitRows('2026-05-01', '2026-05-02'); + + expect($rows)->toHaveCount(1) + ->and($rows[0]['business_date'])->toBe('2026-05-01') + ->and($rows[0]['total_bet_minor'])->toBe(1000); +}); + +test('player win loss uses draw business date not order created date', function (): void { + $player = Player::query()->create([ + 'site_code' => 'main', + 'site_player_id' => 'rpt-axis-1', + 'username' => 'axis_user', + 'nickname' => null, + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + $businessDate = now()->toDateString(); + $orderCreatedAt = now()->addDay()->startOfDay(); + + $draw = Draw::query()->create([ + 'draw_no' => 'RPT-AXIS-'.now()->format('YmdHis'), + 'business_date' => $businessDate, + 'sequence_no' => 1, + 'status' => 'settled', + 'start_time' => now()->subHour(), + 'close_time' => now()->addHour(), + 'draw_time' => now()->addHours(2), + 'cooling_end_time' => null, + 'result_source' => null, + 'current_result_version' => 1, + 'settle_version' => 1, + 'is_reopened' => false, + ]); + + $order = TicketOrder::query()->create([ + 'order_no' => 'RPT-AXIS-ORD', + 'player_id' => $player->id, + 'draw_id' => $draw->id, + 'currency_code' => 'NPR', + 'total_bet_amount' => 2000, + 'total_rebate_amount' => 0, + 'total_actual_deduct' => 2000, + 'total_estimated_payout' => 0, + 'status' => 'settled', + 'submit_source' => 'h5', + 'client_trace_id' => 'trace-axis', + 'created_at' => $orderCreatedAt, + 'updated_at' => $orderCreatedAt, + ]); + + TicketItem::query()->create([ + 'ticket_no' => 'RPT-AXIS-TK', + 'order_id' => $order->id, + 'player_id' => $player->id, + 'draw_id' => $draw->id, + 'original_number' => '5678', + 'normalized_number' => '5678', + 'play_code' => 'big', + 'dimension' => 4, + 'digit_slot' => null, + 'bet_mode' => 'single', + 'unit_bet_amount' => 2000, + 'total_bet_amount' => 2000, + 'rebate_rate_snapshot' => '0.0000', + 'commission_rate_snapshot' => '0.0000', + 'actual_deduct_amount' => 2000, + 'win_amount' => 0, + 'jackpot_win_amount' => 0, + 'status' => 'settled', + ]); + + $service = app(AdminReportQueryService::class); + $orderDate = $orderCreatedAt->toDateString(); + + expect($service->playerWinLossPaginated(null, $businessDate, $businessDate, 1, 20)->total())->toBe(1) + ->and($service->playerWinLossPaginated(null, $orderDate, $orderDate, 1, 20)->total())->toBe(0); +});