fix(settlement): 操作记录分页、流水动作与坏账幂等加固
- 新增 settlement-operations 合并列表 API,收付/调账接口改为标准分页 - actionable_only 流水在内存过滤后正确分页 - 已结账单流水不再展示补差/冲正快捷动作 - 坏账核销支持 idempotency_key 并写入 result_bill_id - 补充操作记录、流水与坏账幂等相关测试
This commit is contained in:
@@ -2,16 +2,19 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement;
|
namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use App\Support\AdminAgentSettlementScope;
|
|
||||||
use App\Support\ApiResponse;
|
use App\Support\ApiResponse;
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Database\Query\Builder;
|
use App\Support\PaginationTrait;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Database\Query\Builder;
|
||||||
|
use App\Support\AdminAgentSettlementScope;
|
||||||
|
|
||||||
final class AgentSettlementAdjustmentIndexController extends Controller
|
final class AgentSettlementAdjustmentIndexController extends Controller
|
||||||
{
|
{
|
||||||
|
use PaginationTrait;
|
||||||
|
|
||||||
public function __invoke(Request $request): JsonResponse
|
public function __invoke(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$admin = $request->lotteryAdmin();
|
$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([
|
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(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,15 +2,15 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement;
|
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\Http\Controllers\Controller;
|
||||||
|
use App\Support\AdminAgentSettlementScope;
|
||||||
use App\Http\Middleware\RecordAdminApiAudit;
|
use App\Http\Middleware\RecordAdminApiAudit;
|
||||||
use App\Http\Requests\Admin\AdminSettlementBillBadDebtRequest;
|
use App\Http\Requests\Admin\AdminSettlementBillBadDebtRequest;
|
||||||
use App\Services\AgentSettlement\AgentSettlementBadDebtService;
|
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
|
final class AgentSettlementBillBadDebtWriteOffController extends Controller
|
||||||
{
|
{
|
||||||
@@ -31,6 +31,7 @@ final class AgentSettlementBillBadDebtWriteOffController extends Controller
|
|||||||
$settlement_bill,
|
$settlement_bill,
|
||||||
$request->validated('reason'),
|
$request->validated('reason'),
|
||||||
(int) $admin->id,
|
(int) $admin->id,
|
||||||
|
$request->validated('idempotency_key'),
|
||||||
);
|
);
|
||||||
|
|
||||||
$after = DB::table('settlement_bills')->where('id', $settlement_bill)->first();
|
$after = DB::table('settlement_bills')->where('id', $settlement_bill)->first();
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
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 App\Http\Controllers\Controller;
|
||||||
|
use App\Support\AdminAgentSettlementScope;
|
||||||
|
use App\Services\AgentSettlement\SettlementOperationsListFilters;
|
||||||
|
use App\Services\AgentSettlement\SettlementOperationsListService;
|
||||||
|
|
||||||
|
/** GET /api/v1/admin/settlement-operations */
|
||||||
|
final class AgentSettlementOperationsIndexController extends Controller
|
||||||
|
{
|
||||||
|
use PaginationTrait;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly SettlementOperationsListService $operationsList,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$admin = $request->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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,15 +2,18 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement;
|
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\Http\Controllers\Controller;
|
||||||
use App\Support\AdminAgentSettlementScope;
|
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
|
final class AgentSettlementPaymentIndexController extends Controller
|
||||||
{
|
{
|
||||||
|
use PaginationTrait;
|
||||||
|
|
||||||
public function __invoke(Request $request): JsonResponse
|
public function __invoke(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$admin = $request->lotteryAdmin();
|
$admin = $request->lotteryAdmin();
|
||||||
@@ -54,8 +57,15 @@ final class AgentSettlementPaymentIndexController extends Controller
|
|||||||
|
|
||||||
AdminAgentSettlementScope::applyDirectEdgeScopeToBillsQuery($query, $admin, 'sb');
|
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([
|
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(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api\V1\E2E;
|
namespace App\Http\Controllers\Api\V1\E2E;
|
||||||
|
|
||||||
use App\Services\AdminCaptchaService;
|
use App\Lottery\ErrorCode;
|
||||||
use App\Support\ApiResponse;
|
use App\Support\ApiResponse;
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Services\AdminCaptchaService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* E2E 辅助:peek captcha bypass 码。
|
* E2E 辅助:peek captcha bypass 码。
|
||||||
@@ -14,14 +16,15 @@ use Illuminate\Http\Request;
|
|||||||
* 改用 e2e 约定:captcha_code == "LOTTERY_E2E_BYPASS" 在 LOTTERY_E2E=true 时视为通过。
|
* 改用 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
|
public function player(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$key = (string) $request->input('captcha_key', '');
|
$key = (string) $request->input('captcha_key', '');
|
||||||
if ($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);
|
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', '');
|
$key = (string) $request->input('captcha_key', '');
|
||||||
if ($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);
|
return $this->peek($key, AdminCaptchaService::SCOPE_ADMIN);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,14 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api\V1\E2E;
|
namespace App\Http\Controllers\Api\V1\E2E;
|
||||||
|
|
||||||
|
use App\Lottery\ErrorCode;
|
||||||
use App\Lottery\DrawStatus;
|
use App\Lottery\DrawStatus;
|
||||||
use App\Support\ApiResponse;
|
use App\Support\ApiResponse;
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Support\Facades\Artisan;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* E2E 辅助:期号时间/状态快进。
|
* E2E 辅助:期号时间/状态快进。
|
||||||
@@ -19,13 +22,13 @@ use Illuminate\Support\Facades\DB;
|
|||||||
*
|
*
|
||||||
* 严格仅在 LOTTERY_E2E=true 时由 E2EServiceProvider 注册。
|
* 严格仅在 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
|
public function closeNow(Request $request, string $drawNo): JsonResponse
|
||||||
{
|
{
|
||||||
$draw = DB::table('draws')->where('draw_no', $drawNo)->first();
|
$draw = DB::table('draws')->where('draw_no', $drawNo)->first();
|
||||||
if (! $draw) {
|
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')
|
DB::table('draws')
|
||||||
@@ -44,7 +47,7 @@ final class E2EDrawController extends \App\Http\Controllers\Controller
|
|||||||
{
|
{
|
||||||
$draw = DB::table('draws')->where('draw_no', $drawNo)->first();
|
$draw = DB::table('draws')->where('draw_no', $drawNo)->first();
|
||||||
if (! $draw) {
|
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')
|
DB::table('draws')
|
||||||
@@ -62,7 +65,7 @@ final class E2EDrawController extends \App\Http\Controllers\Controller
|
|||||||
{
|
{
|
||||||
$draw = DB::table('draws')->where('draw_no', $drawNo)->first();
|
$draw = DB::table('draws')->where('draw_no', $drawNo)->first();
|
||||||
if (! $draw) {
|
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')
|
DB::table('draws')
|
||||||
@@ -82,8 +85,9 @@ final class E2EDrawController extends \App\Http\Controllers\Controller
|
|||||||
{
|
{
|
||||||
$draw = DB::table('draws')->where('draw_no', $drawNo)->first();
|
$draw = DB::table('draws')->where('draw_no', $drawNo)->first();
|
||||||
if (! $draw) {
|
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);
|
return $this->inspectById((int) $draw->id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,11 +95,11 @@ final class E2EDrawController extends \App\Http\Controllers\Controller
|
|||||||
public function tick(Request $request): JsonResponse
|
public function tick(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$exit = \Illuminate\Support\Facades\Artisan::call('lottery:draw-tick');
|
$exit = Artisan::call('lottery:draw-tick');
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
return ApiResponse::error(
|
return ApiResponse::error(
|
||||||
'draw-tick failed: '.$e->getMessage(),
|
'draw-tick failed: '.$e->getMessage(),
|
||||||
'e2e_tick_failed',
|
ErrorCode::InternalError->value,
|
||||||
['trace' => array_slice(explode("\n", $e->getTraceAsString()), 0, 5)],
|
['trace' => array_slice(explode("\n", $e->getTraceAsString()), 0, 5)],
|
||||||
500,
|
500,
|
||||||
);
|
);
|
||||||
@@ -103,13 +107,14 @@ final class E2EDrawController extends \App\Http\Controllers\Controller
|
|||||||
|
|
||||||
return ApiResponse::success([
|
return ApiResponse::success([
|
||||||
'exit_code' => $exit,
|
'exit_code' => $exit,
|
||||||
'output' => \Illuminate\Support\Facades\Artisan::output(),
|
'output' => Artisan::output(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function inspectById(int $id): JsonResponse
|
private function inspectById(int $id): JsonResponse
|
||||||
{
|
{
|
||||||
$d = DB::table('draws')->where('id', $id)->first();
|
$d = DB::table('draws')->where('id', $id)->first();
|
||||||
|
|
||||||
return ApiResponse::success([
|
return ApiResponse::success([
|
||||||
'id' => (int) $d->id,
|
'id' => (int) $d->id,
|
||||||
'draw_no' => $d->draw_no,
|
'draw_no' => $d->draw_no,
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api\V1\E2E;
|
namespace App\Http\Controllers\Api\V1\E2E;
|
||||||
|
|
||||||
|
use App\Lottery\ErrorCode;
|
||||||
use App\Support\ApiResponse;
|
use App\Support\ApiResponse;
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* E2E 辅助:重置/查询玩家状态。
|
* E2E 辅助:重置/查询玩家状态。
|
||||||
@@ -17,7 +19,7 @@ use Illuminate\Support\Facades\DB;
|
|||||||
*
|
*
|
||||||
* 严格仅在 LOTTERY_E2E=true 时由 E2EServiceProvider 注册。
|
* 严格仅在 LOTTERY_E2E=true 时由 E2EServiceProvider 注册。
|
||||||
*/
|
*/
|
||||||
final class E2EPlayerStateController extends \App\Http\Controllers\Controller
|
final class E2EPlayerStateController extends Controller
|
||||||
{
|
{
|
||||||
public function reset(Request $request): JsonResponse
|
public function reset(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
@@ -32,7 +34,7 @@ final class E2EPlayerStateController extends \App\Http\Controllers\Controller
|
|||||||
->value('id');
|
->value('id');
|
||||||
|
|
||||||
if (! $playerId) {
|
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) {
|
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);
|
$balance = (int) $request->input('balance', -1);
|
||||||
|
|
||||||
if ($balance < 0) {
|
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')
|
$playerId = DB::table('players')
|
||||||
@@ -79,7 +81,7 @@ final class E2EPlayerStateController extends \App\Http\Controllers\Controller
|
|||||||
->value('id');
|
->value('id');
|
||||||
|
|
||||||
if (! $playerId) {
|
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')
|
DB::table('player_wallets')
|
||||||
@@ -130,7 +132,7 @@ final class E2EPlayerStateController extends \App\Http\Controllers\Controller
|
|||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (! $player) {
|
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')
|
$wallets = DB::table('player_wallets')
|
||||||
|
|||||||
@@ -2,27 +2,29 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers\Api\V1\E2E;
|
namespace App\Http\Controllers\Api\V1\E2E;
|
||||||
|
|
||||||
|
use Firebase\JWT\JWT;
|
||||||
|
use App\Models\Player;
|
||||||
use App\Models\AdminSite;
|
use App\Models\AdminSite;
|
||||||
use App\Models\AdminUser;
|
use App\Models\AdminUser;
|
||||||
use App\Models\AgentNode;
|
use App\Models\AgentNode;
|
||||||
use App\Models\Player;
|
use App\Lottery\ErrorCode;
|
||||||
use App\Models\PlayerWallet;
|
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\Agent\AgentNodeService;
|
||||||
use App\Services\Integration\PartnerSiteConfigResolver;
|
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 配置。
|
* E2E 辅助:信用玩家 / SSO JWT / 主站钱包 mock 配置。
|
||||||
*/
|
*/
|
||||||
final class E2EProvisionController extends \App\Http\Controllers\Controller
|
final class E2EProvisionController extends Controller
|
||||||
{
|
{
|
||||||
public function setupCreditPlayer(Request $request): JsonResponse
|
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();
|
$site = AdminSite::query()->where('code', $siteCode)->first();
|
||||||
if ($site === null) {
|
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 ?? [];
|
$extra = $site->extra_json ?? [];
|
||||||
@@ -54,7 +56,7 @@ final class E2EProvisionController extends \App\Http\Controllers\Controller
|
|||||||
->value('id');
|
->value('id');
|
||||||
|
|
||||||
if ($rootId <= 0) {
|
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');
|
$leafCode = (string) $request->input('agent_code', 'e2e_leaf');
|
||||||
@@ -64,9 +66,9 @@ final class E2EProvisionController extends \App\Http\Controllers\Controller
|
|||||||
->first();
|
->first();
|
||||||
|
|
||||||
if ($leaf === null) {
|
if ($leaf === null) {
|
||||||
$super = \App\Models\AdminUser::query()->where('username', 'admin')->first();
|
$super = AdminUser::query()->where('username', 'admin')->first();
|
||||||
if ($super === null) {
|
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, [
|
$leaf = app(AgentNodeService::class)->createChild($super, [
|
||||||
'parent_id' => $rootId,
|
'parent_id' => $rootId,
|
||||||
@@ -153,12 +155,12 @@ final class E2EProvisionController extends \App\Http\Controllers\Controller
|
|||||||
|
|
||||||
$site = AdminSite::query()->where('code', $siteCode)->first();
|
$site = AdminSite::query()->where('code', $siteCode)->first();
|
||||||
if ($site === null) {
|
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');
|
$roleId = (int) DB::table('admin_roles')->where('slug', $roleSlug)->value('id');
|
||||||
if ($roleId <= 0) {
|
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 */
|
/** @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'));
|
$siteCode = (string) ($request->input('site_code') ?: env('E2E_PLAYER_SITE_CODE', 'demo'));
|
||||||
$site = AdminSite::query()->where('code', $siteCode)->first();
|
$site = AdminSite::query()->where('code', $siteCode)->first();
|
||||||
if ($site === null) {
|
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;
|
$siteId = (int) $site->id;
|
||||||
@@ -230,12 +232,12 @@ final class E2EProvisionController extends \App\Http\Controllers\Controller
|
|||||||
|
|
||||||
$site = AdminSite::query()->where('code', $siteCode)->first();
|
$site = AdminSite::query()->where('code', $siteCode)->first();
|
||||||
if ($site === null) {
|
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();
|
$secret = $site->decryptedSsoJwtSecret();
|
||||||
if (! is_string($secret) || $secret === '') {
|
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();
|
$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'));
|
$siteCode = (string) ($request->input('site_code') ?: env('E2E_PLAYER_SITE_CODE', 'demo'));
|
||||||
$baseUrl = rtrim((string) $request->input('base_url', ''), '/');
|
$baseUrl = rtrim((string) $request->input('base_url', ''), '/');
|
||||||
if ($baseUrl === '') {
|
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();
|
$site = AdminSite::query()->where('code', $siteCode)->first();
|
||||||
if ($site === null) {
|
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;
|
$site->wallet_api_url = $baseUrl;
|
||||||
@@ -286,7 +288,7 @@ final class E2EProvisionController extends \App\Http\Controllers\Controller
|
|||||||
|
|
||||||
$site = AdminSite::query()->where('code', $siteCode)->first();
|
$site = AdminSite::query()->where('code', $siteCode)->first();
|
||||||
if ($site === null) {
|
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;
|
$site->wallet_api_url = null;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ final class AdminSettlementBillBadDebtRequest extends ApiFormRequest
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'reason' => ['sometimes', 'nullable', 'string', 'max:255'],
|
'reason' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||||
|
'idempotency_key' => ['sometimes', 'nullable', 'string', 'max:64'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
namespace App\Services\AgentSettlement;
|
namespace App\Services\AgentSettlement;
|
||||||
|
|
||||||
use App\Models\Player;
|
use App\Models\Player;
|
||||||
use App\Services\Player\PlayerCreditService;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Database\QueryException;
|
||||||
|
use App\Support\DatabaseUniqueViolation;
|
||||||
|
use App\Services\Player\PlayerCreditService;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
/** 坏账核销:原账单保留,记 bad_debt 调整与归档单(§2、§21.1)。 */
|
/** 坏账核销:原账单保留,记 bad_debt 调整与归档单(§2、§21.1)。 */
|
||||||
@@ -15,112 +17,167 @@ final class AgentSettlementBadDebtService
|
|||||||
private readonly PlayerCreditService $playerCreditService,
|
private readonly PlayerCreditService $playerCreditService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function writeOff(int $originalBillId, ?string $reason, int $adminUserId): int
|
public function writeOff(
|
||||||
{
|
int $originalBillId,
|
||||||
return (int) DB::transaction(function () use ($originalBillId, $reason, $adminUserId): int {
|
?string $reason,
|
||||||
/** @var object|null $original */
|
int $adminUserId,
|
||||||
$original = DB::table('settlement_bills')->where('id', $originalBillId)->lockForUpdate()->first();
|
?string $idempotencyKey = null,
|
||||||
if ($original === null) {
|
): int {
|
||||||
throw new \InvalidArgumentException('bill_not_found');
|
if ($idempotencyKey !== null && $idempotencyKey !== '') {
|
||||||
}
|
$existingArchiveId = $this->findArchiveBillByIdempotency($originalBillId, $idempotencyKey);
|
||||||
|
if ($existingArchiveId !== null) {
|
||||||
$meta = $this->decodeMeta($original->meta_json);
|
|
||||||
$existingArchiveId = (int) ($meta['bad_debt_bill_id'] ?? 0);
|
|
||||||
if ($existingArchiveId > 0) {
|
|
||||||
return $existingArchiveId;
|
return $existingArchiveId;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ($this->periodCompletion->isPeriodReadOnly((int) $original->settlement_period_id)) {
|
try {
|
||||||
throw ValidationException::withMessages([
|
return (int) DB::transaction(function () use ($originalBillId, $reason, $adminUserId, $idempotencyKey): int {
|
||||||
'period' => ['completed'],
|
return $this->insertBadDebtWriteOff($originalBillId, $reason, $adminUserId, $idempotencyKey);
|
||||||
]);
|
});
|
||||||
}
|
} catch (QueryException $e) {
|
||||||
|
if (
|
||||||
if (! in_array((string) $original->status, ['confirmed', 'partial_paid', 'overdue'], true)) {
|
$idempotencyKey !== null
|
||||||
throw ValidationException::withMessages([
|
&& $idempotencyKey !== ''
|
||||||
'bill' => ['not_eligible'],
|
&& DatabaseUniqueViolation::matches($e)
|
||||||
]);
|
) {
|
||||||
}
|
$existingArchiveId = $this->findArchiveBillByIdempotency($originalBillId, $idempotencyKey);
|
||||||
|
if ($existingArchiveId !== null) {
|
||||||
$unpaid = (int) $original->unpaid_amount;
|
return $existingArchiveId;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,14 +2,16 @@
|
|||||||
|
|
||||||
namespace App\Services\AgentSettlement;
|
namespace App\Services\AgentSettlement;
|
||||||
|
|
||||||
|
use Carbon\Carbon;
|
||||||
use App\Models\AdminUser;
|
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\AdminAgentSettlementScope;
|
||||||
use App\Support\AgentSettlementPeriodWindow;
|
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 + 收付 + 调账)。 */
|
/** 结算中心统一账务流水(credit_ledger + 收付 + 调账)。 */
|
||||||
final class SettlementCenterLedgerService
|
final class SettlementCenterLedgerService
|
||||||
@@ -36,6 +38,7 @@ final class SettlementCenterLedgerService
|
|||||||
private readonly SettlementPartyEnrichment $partyEnrichment,
|
private readonly SettlementPartyEnrichment $partyEnrichment,
|
||||||
private readonly CreditLedgerBetFlowPresenter $betFlowPresenter,
|
private readonly CreditLedgerBetFlowPresenter $betFlowPresenter,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{
|
* @return array{
|
||||||
* items: list<array<string, mixed>>,
|
* items: list<array<string, mixed>>,
|
||||||
@@ -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);
|
$offset = max(0, ($page - 1) * $perPage);
|
||||||
if (count($stubQueries) === 1) {
|
if (count($stubQueries) === 1) {
|
||||||
$base = $stubQueries[0];
|
$base = $stubQueries[0];
|
||||||
@@ -122,11 +138,6 @@ final class SettlementCenterLedgerService
|
|||||||
|
|
||||||
$items = $this->hydrateLedgerStubs($admin, $siteCode, $stubs, $playerBills);
|
$items = $this->hydrateLedgerStubs($admin, $siteCode, $stubs, $playerBills);
|
||||||
$items = $this->applyFilters($items, $filters);
|
$items = $this->applyFilters($items, $filters);
|
||||||
$filteredCount = count($items);
|
|
||||||
|
|
||||||
if ($filteredCount !== count($stubs)) {
|
|
||||||
$total = $filteredCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'items' => array_values($items),
|
'items' => array_values($items),
|
||||||
@@ -248,7 +259,7 @@ final class SettlementCenterLedgerService
|
|||||||
string $siteCode,
|
string $siteCode,
|
||||||
?array $range,
|
?array $range,
|
||||||
SettlementLedgerListFilters $filters,
|
SettlementLedgerListFilters $filters,
|
||||||
): ?\Illuminate\Database\Query\Builder {
|
): ?Builder {
|
||||||
$query = DB::table('share_ledger as sl')
|
$query = DB::table('share_ledger as sl')
|
||||||
->join('players as p', 'p.id', '=', 'sl.player_id')
|
->join('players as p', 'p.id', '=', 'sl.player_id')
|
||||||
->where('p.site_code', $siteCode)
|
->where('p.site_code', $siteCode)
|
||||||
@@ -275,7 +286,7 @@ final class SettlementCenterLedgerService
|
|||||||
string $siteCode,
|
string $siteCode,
|
||||||
?array $range,
|
?array $range,
|
||||||
SettlementLedgerListFilters $filters,
|
SettlementLedgerListFilters $filters,
|
||||||
): ?\Illuminate\Database\Query\Builder {
|
): ?Builder {
|
||||||
$query = DB::table('credit_ledger as cl')
|
$query = DB::table('credit_ledger as cl')
|
||||||
->join('players as p', function ($join): void {
|
->join('players as p', function ($join): void {
|
||||||
$join->on('p.id', '=', 'cl.owner_id')
|
$join->on('p.id', '=', 'cl.owner_id')
|
||||||
@@ -313,7 +324,7 @@ final class SettlementCenterLedgerService
|
|||||||
string $siteCode,
|
string $siteCode,
|
||||||
?int $periodId,
|
?int $periodId,
|
||||||
SettlementLedgerListFilters $filters,
|
SettlementLedgerListFilters $filters,
|
||||||
): ?\Illuminate\Database\Query\Builder {
|
): ?Builder {
|
||||||
$adminSiteId = (int) DB::table('admin_sites')->where('code', $siteCode)->value('id');
|
$adminSiteId = (int) DB::table('admin_sites')->where('code', $siteCode)->value('id');
|
||||||
if ($adminSiteId <= 0) {
|
if ($adminSiteId <= 0) {
|
||||||
return null;
|
return null;
|
||||||
@@ -347,7 +358,7 @@ final class SettlementCenterLedgerService
|
|||||||
}
|
}
|
||||||
|
|
||||||
private function applyPaymentPlayerFilters(
|
private function applyPaymentPlayerFilters(
|
||||||
\Illuminate\Database\Query\Builder $query,
|
Builder $query,
|
||||||
SettlementLedgerListFilters $filters,
|
SettlementLedgerListFilters $filters,
|
||||||
): void {
|
): void {
|
||||||
if ($filters->playerId !== null && $filters->playerId > 0) {
|
if ($filters->playerId !== null && $filters->playerId > 0) {
|
||||||
@@ -366,7 +377,7 @@ final class SettlementCenterLedgerService
|
|||||||
string $siteCode,
|
string $siteCode,
|
||||||
?int $periodId,
|
?int $periodId,
|
||||||
SettlementLedgerListFilters $filters,
|
SettlementLedgerListFilters $filters,
|
||||||
): ?\Illuminate\Database\Query\Builder {
|
): ?Builder {
|
||||||
$adminSiteId = (int) DB::table('admin_sites')->where('code', $siteCode)->value('id');
|
$adminSiteId = (int) DB::table('admin_sites')->where('code', $siteCode)->value('id');
|
||||||
if ($adminSiteId <= 0) {
|
if ($adminSiteId <= 0) {
|
||||||
return null;
|
return null;
|
||||||
@@ -409,14 +420,14 @@ final class SettlementCenterLedgerService
|
|||||||
}
|
}
|
||||||
|
|
||||||
private function applyAdjustmentPlayerScope(
|
private function applyAdjustmentPlayerScope(
|
||||||
\Illuminate\Database\Query\Builder $query,
|
Builder $query,
|
||||||
AdminUser $admin,
|
AdminUser $admin,
|
||||||
string $siteCode,
|
string $siteCode,
|
||||||
SettlementLedgerListFilters $filters,
|
SettlementLedgerListFilters $filters,
|
||||||
): void {
|
): 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')
|
$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);
|
$scoped->where('p.site_code', $siteCode);
|
||||||
AdminAgentSettlementScope::applyDirectPlayersToAlias($scoped, $admin, 'p');
|
AdminAgentSettlementScope::applyDirectPlayersToAlias($scoped, $admin, 'p');
|
||||||
$this->applyLedgerPlayerFilters($scoped, 'p', $filters);
|
$this->applyLedgerPlayerFilters($scoped, 'p', $filters);
|
||||||
@@ -425,7 +436,7 @@ final class SettlementCenterLedgerService
|
|||||||
}
|
}
|
||||||
|
|
||||||
private function applyTxnNoStubFilter(
|
private function applyTxnNoStubFilter(
|
||||||
\Illuminate\Database\Query\Builder $query,
|
Builder $query,
|
||||||
string $idColumn,
|
string $idColumn,
|
||||||
string $prefix,
|
string $prefix,
|
||||||
?string $txnNo,
|
?string $txnNo,
|
||||||
@@ -435,7 +446,7 @@ final class SettlementCenterLedgerService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$needle = strtolower(trim($txnNo));
|
$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)) {
|
if (ctype_digit($needle)) {
|
||||||
$match->where($idColumn, (int) $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();
|
$siteIds = $admin->accessibleAdminSiteIds();
|
||||||
if ($siteIds === null) {
|
if ($siteIds === null) {
|
||||||
@@ -464,7 +475,7 @@ final class SettlementCenterLedgerService
|
|||||||
}
|
}
|
||||||
|
|
||||||
private function applyLedgerPlayerFilters(
|
private function applyLedgerPlayerFilters(
|
||||||
\Illuminate\Database\Query\Builder $query,
|
Builder $query,
|
||||||
string $playerAlias,
|
string $playerAlias,
|
||||||
SettlementLedgerListFilters $filters,
|
SettlementLedgerListFilters $filters,
|
||||||
): void {
|
): void {
|
||||||
@@ -474,7 +485,7 @@ final class SettlementCenterLedgerService
|
|||||||
|
|
||||||
if ($filters->playerAccount !== null && $filters->playerAccount !== '') {
|
if ($filters->playerAccount !== null && $filters->playerAccount !== '') {
|
||||||
$like = '%'.addcslashes($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)
|
$match->where("{$playerAlias}.username", 'like', $like)
|
||||||
->orWhere("{$playerAlias}.site_player_id", 'like', $like)
|
->orWhere("{$playerAlias}.site_player_id", 'like', $like)
|
||||||
->orWhere("{$playerAlias}.nickname", 'like', $like);
|
->orWhere("{$playerAlias}.nickname", 'like', $like);
|
||||||
@@ -483,14 +494,14 @@ final class SettlementCenterLedgerService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param \Illuminate\Support\Collection<int, object> $stubs
|
* @param Collection<int, object> $stubs
|
||||||
* @param array<int, object> $playerBills
|
* @param array<int, object> $playerBills
|
||||||
* @return list<array<string, mixed>>
|
* @return list<array<string, mixed>>
|
||||||
*/
|
*/
|
||||||
private function hydrateLedgerStubs(
|
private function hydrateLedgerStubs(
|
||||||
AdminUser $admin,
|
AdminUser $admin,
|
||||||
string $siteCode,
|
string $siteCode,
|
||||||
\Illuminate\Support\Collection $stubs,
|
Collection $stubs,
|
||||||
array $playerBills,
|
array $playerBills,
|
||||||
): array {
|
): array {
|
||||||
if ($stubs->isEmpty()) {
|
if ($stubs->isEmpty()) {
|
||||||
@@ -641,6 +652,71 @@ final class SettlementCenterLedgerService
|
|||||||
return $selected === $kind;
|
return $selected === $kind;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* actionable_only 依赖 hydrated 行的 available_actions,需在内存中过滤后再分页。
|
||||||
|
*
|
||||||
|
* @param list<Builder> $stubQueries
|
||||||
|
* @param array<int, object> $playerBills
|
||||||
|
* @return array{
|
||||||
|
* items: list<array<string, mixed>>,
|
||||||
|
* 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<Builder> $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<array<string, mixed>> $items
|
* @param list<array<string, mixed>> $items
|
||||||
* @return list<array<string, mixed>>
|
* @return list<array<string, mixed>>
|
||||||
@@ -1541,7 +1617,7 @@ final class SettlementCenterLedgerService
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ($billStatus !== null
|
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)) {
|
&& ! in_array((string) $billType, ['adjustment', 'reversal', 'bad_debt'], true)) {
|
||||||
$actions[] = 'adjustment';
|
$actions[] = 'adjustment';
|
||||||
$actions[] = 'reversal';
|
$actions[] = 'reversal';
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\AgentSettlement;
|
||||||
|
|
||||||
|
/** 结算中心收付与调账操作记录筛选。 */
|
||||||
|
final class SettlementOperationsListFilters
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly ?int $settlementPeriodId = null,
|
||||||
|
public readonly ?int $adminSiteId = null,
|
||||||
|
public readonly ?int $billId = null,
|
||||||
|
public readonly ?string $keyword = null,
|
||||||
|
public readonly string $operationType = 'all',
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public static function fromQuery(array $query): self
|
||||||
|
{
|
||||||
|
$billId = (int) ($query['bill_id'] ?? 0);
|
||||||
|
$operationType = trim((string) ($query['operation_type'] ?? 'all'));
|
||||||
|
if (! in_array($operationType, ['all', 'payment', 'adjustment', 'reversal', 'bad_debt'], true)) {
|
||||||
|
$operationType = 'all';
|
||||||
|
}
|
||||||
|
|
||||||
|
$periodId = (int) ($query['settlement_period_id'] ?? 0);
|
||||||
|
$siteId = (int) ($query['admin_site_id'] ?? 0);
|
||||||
|
|
||||||
|
return new self(
|
||||||
|
settlementPeriodId: $periodId > 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
315
app/Services/AgentSettlement/SettlementOperationsListService.php
Normal file
315
app/Services/AgentSettlement/SettlementOperationsListService.php
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\AgentSettlement;
|
||||||
|
|
||||||
|
use Carbon\Carbon;
|
||||||
|
use App\Models\AdminUser;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Database\Query\Builder;
|
||||||
|
use App\Support\AdminAgentSettlementScope;
|
||||||
|
|
||||||
|
/** 结算中心收付 + 调账合并列表(DB 分页)。 */
|
||||||
|
final class SettlementOperationsListService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* items: list<array<string, mixed>>,
|
||||||
|
* 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<int, object> $stubs
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
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<string, mixed>
|
||||||
|
*/
|
||||||
|
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<string, mixed>
|
||||||
|
*/
|
||||||
|
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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.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.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-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-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-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']],
|
['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.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.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']],
|
['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']],
|
||||||
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ export default async function globalSetup(): Promise<void> {
|
|||||||
const mockPort = process.env.E2E_MOCK_WALLET_PORT ?? '5555';
|
const mockPort = process.env.E2E_MOCK_WALLET_PORT ?? '5555';
|
||||||
const ctx = await pwRequest.newContext({ baseURL: api });
|
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', {
|
const walletResp = await ctx.post('/api/v1/_e2e/site/wallet-api', {
|
||||||
data: {
|
data: {
|
||||||
|
site_code: siteCode,
|
||||||
base_url: `http://127.0.0.1:${mockPort}`,
|
base_url: `http://127.0.0.1:${mockPort}`,
|
||||||
wallet_api_key: 'e2e-mock-key',
|
wallet_api_key: 'e2e-mock-key',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ test('超管进入结算中心 → 账期管理页可见', async ({ page }) => {
|
|||||||
timeout: 60_000,
|
timeout: 60_000,
|
||||||
});
|
});
|
||||||
await expect(page.getByText(/账期管理|Period/i).first()).toBeVisible({ timeout: 30_000 });
|
await expect(page.getByText(/账期管理|Period/i).first()).toBeVisible({ timeout: 30_000 });
|
||||||
await expect(page.getByRole('button', { name: /开账|Open period/i }).first()).toBeVisible({
|
const periodAction = page
|
||||||
timeout: 30_000,
|
.getByRole('button', { name: /开账|Open period|关账|Close period/i })
|
||||||
});
|
.first();
|
||||||
|
await expect(periodAction).toBeVisible({ timeout: 30_000 });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AdminCreditLedgerIndexController;
|
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AdminCreditLedgerIndexController;
|
||||||
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementAdjustmentIndexController;
|
|
||||||
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementBillAdjustmentController;
|
|
||||||
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementBillBadDebtWriteOffController;
|
|
||||||
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementPaymentIndexController;
|
|
||||||
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementBillConfirmController;
|
|
||||||
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementBillIndexController;
|
|
||||||
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementBillPaymentController;
|
|
||||||
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementBillShowController;
|
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementBillShowController;
|
||||||
|
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementBillIndexController;
|
||||||
|
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementReportShowController;
|
||||||
|
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementBillConfirmController;
|
||||||
|
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementBillPaymentController;
|
||||||
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementPeriodCloseController;
|
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementPeriodCloseController;
|
||||||
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementPeriodOpenHintsController;
|
|
||||||
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementPeriodIndexController;
|
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementPeriodIndexController;
|
||||||
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementPeriodStoreController;
|
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementPeriodStoreController;
|
||||||
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementReportIndexController;
|
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementReportIndexController;
|
||||||
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementReportShowController;
|
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementPaymentIndexController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementBillAdjustmentController;
|
||||||
|
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementAdjustmentIndexController;
|
||||||
|
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementOperationsIndexController;
|
||||||
|
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementPeriodOpenHintsController;
|
||||||
|
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementBillBadDebtWriteOffController;
|
||||||
|
|
||||||
Route::middleware('admin.api-resource')
|
Route::middleware('admin.api-resource')
|
||||||
->group(function (): void {
|
->group(function (): void {
|
||||||
@@ -31,6 +32,8 @@ Route::middleware('admin.api-resource')
|
|||||||
->name('api.v1.admin.credit-ledger.index');
|
->name('api.v1.admin.credit-ledger.index');
|
||||||
Route::get('settlement-bills', AgentSettlementBillIndexController::class)
|
Route::get('settlement-bills', AgentSettlementBillIndexController::class)
|
||||||
->name('api.v1.admin.settlement-bills.index');
|
->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)
|
Route::get('settlement-payments', AgentSettlementPaymentIndexController::class)
|
||||||
->name('api.v1.admin.settlement-payments.index');
|
->name('api.v1.admin.settlement-payments.index');
|
||||||
Route::get('settlement-adjustments', AgentSettlementAdjustmentIndexController::class)
|
Route::get('settlement-adjustments', AgentSettlementAdjustmentIndexController::class)
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Models\AdminUser;
|
use App\Models\Draw;
|
||||||
use App\Models\Player;
|
use App\Models\Player;
|
||||||
|
use App\Models\AdminUser;
|
||||||
|
use App\Lottery\DrawStatus;
|
||||||
use App\Support\PlayerFundingMode;
|
use App\Support\PlayerFundingMode;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
@@ -235,6 +237,156 @@ test('credit ledger settlement bill reference keeps the referenced bill id', fun
|
|||||||
->assertJsonPath('data.items.0.ref_id', $referencedBillId);
|
->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 {
|
test('credit ledger entry_kind share returns share ledger rows', function (): void {
|
||||||
$site = DB::table('admin_sites')->where('is_default', true)->first();
|
$site = DB::table('admin_sites')->where('is_default', true)->first();
|
||||||
$siteId = (int) $site->id;
|
$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
|
function createShareLedgerTicketItem(Player $player): int
|
||||||
{
|
{
|
||||||
$draw = \App\Models\Draw::query()->create([
|
$draw = Draw::query()->create([
|
||||||
'draw_no' => 'DRAW-SHARE-LEDGER',
|
'draw_no' => 'DRAW-SHARE-LEDGER',
|
||||||
'business_date' => now()->toDateString(),
|
'business_date' => now()->toDateString(),
|
||||||
'sequence_no' => random_int(1, 9999),
|
'sequence_no' => random_int(1, 9999),
|
||||||
'status' => \App\Lottery\DrawStatus::Open->value,
|
'status' => DrawStatus::Open->value,
|
||||||
'current_result_version' => 0,
|
'current_result_version' => 0,
|
||||||
'settle_version' => 0,
|
'settle_version' => 0,
|
||||||
'is_reopened' => false,
|
'is_reopened' => false,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Models\AdminUser;
|
use App\Models\AdminUser;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
@@ -87,12 +87,30 @@ test('settlement payments and adjustments index return items', function (): void
|
|||||||
$this->withHeader('Authorization', 'Bearer '.$token)
|
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||||
->getJson('/api/v1/admin/settlement-payments?admin_site_id='.$siteId)
|
->getJson('/api/v1/admin/settlement-payments?admin_site_id='.$siteId)
|
||||||
->assertOk()
|
->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)
|
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||||
->getJson('/api/v1/admin/settlement-adjustments?admin_site_id='.$siteId)
|
->getJson('/api/v1/admin/settlement-adjustments?admin_site_id='.$siteId)
|
||||||
->assertOk()
|
->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)
|
$this->withHeader('Authorization', 'Bearer '.$token)
|
||||||
->getJson('/api/v1/admin/settlement-bills?admin_site_id='.$siteId.'&bill_type=player')
|
->getJson('/api/v1/admin/settlement-bills?admin_site_id='.$siteId.'&bill_type=player')
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Models\AdminUser;
|
|
||||||
use App\Models\Player;
|
use App\Models\Player;
|
||||||
|
use App\Models\AdminUser;
|
||||||
use App\Models\PlayerWallet;
|
use App\Models\PlayerWallet;
|
||||||
use App\Models\TransferOrder;
|
use App\Models\TransferOrder;
|
||||||
use App\Services\AgentSettlement\AgentSettlementBadDebtService;
|
|
||||||
use App\Services\AgentSettlement\SettlementPaymentService;
|
|
||||||
use App\Services\Wallet\LotteryTransferService;
|
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use App\Services\Wallet\LotteryTransferService;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use App\Services\AgentSettlement\SettlementPaymentService;
|
||||||
|
use App\Services\AgentSettlement\AgentSettlementBadDebtService;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
@@ -215,6 +215,70 @@ test('bad debt write off is idempotent when retried', function (): void {
|
|||||||
->and(DB::table('settlement_adjustments')->where('original_bill_id', $billId)->count())->toBe(1);
|
->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 {
|
test('out pending reconcile reverse still credits lottery wallet once', function (): void {
|
||||||
$player = Player::query()->create([
|
$player = Player::query()->create([
|
||||||
'site_code' => 'main',
|
'site_code' => 'main',
|
||||||
|
|||||||
Reference in New Issue
Block a user