fix(settlement): 操作记录分页、流水动作与坏账幂等加固
Some checks failed
lotterLaravel CI / test (push) Has been cancelled
lotterLaravel E2E / e2e-api (push) Has been cancelled

- 新增 settlement-operations 合并列表 API,收付/调账接口改为标准分页
- actionable_only 流水在内存过滤后正确分页
- 已结账单流水不再展示补差/冲正快捷动作
- 坏账核销支持 idempotency_key 并写入 result_bill_id
- 补充操作记录、流水与坏账幂等相关测试
This commit is contained in:
2026-06-26 16:40:46 +08:00
parent 7ec8f4c5a2
commit c83343e989
20 changed files with 1045 additions and 212 deletions

View File

@@ -2,16 +2,19 @@
namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement;
use App\Http\Controllers\Controller;
use App\Support\AdminAgentSettlementScope;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Database\Query\Builder;
use App\Support\PaginationTrait;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use App\Http\Controllers\Controller;
use Illuminate\Database\Query\Builder;
use App\Support\AdminAgentSettlementScope;
final class AgentSettlementAdjustmentIndexController extends Controller
{
use PaginationTrait;
public function __invoke(Request $request): JsonResponse
{
$admin = $request->lotteryAdmin();
@@ -69,8 +72,20 @@ final class AgentSettlementAdjustmentIndexController extends Controller
});
}
$meta = $this->paginationMeta($request, defaultPerPage: 20, maxPerPage: 200);
$paginator = $query->paginate(
$meta['per_page'],
['sa.*', 'sp.period_start', 'sp.period_end', 'sp.admin_site_id', 'sb.bill_type as original_bill_type', 'sb.owner_type as original_owner_type', 'sb.owner_id as original_owner_id'],
'page',
$meta['page'],
);
return ApiResponse::success([
'items' => $query->limit(200)->get(),
'items' => $paginator->items(),
'total' => $paginator->total(),
'page' => $paginator->currentPage(),
'per_page' => $paginator->perPage(),
'last_page' => $paginator->lastPage(),
]);
}
}

View File

@@ -2,15 +2,15 @@
namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement;
use App\Support\ApiResponse;
use App\Services\AuditLogger;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use App\Http\Controllers\Controller;
use App\Support\AdminAgentSettlementScope;
use App\Http\Middleware\RecordAdminApiAudit;
use App\Http\Requests\Admin\AdminSettlementBillBadDebtRequest;
use App\Services\AgentSettlement\AgentSettlementBadDebtService;
use App\Services\AuditLogger;
use App\Support\AdminAgentSettlementScope;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
final class AgentSettlementBillBadDebtWriteOffController extends Controller
{
@@ -31,6 +31,7 @@ final class AgentSettlementBillBadDebtWriteOffController extends Controller
$settlement_bill,
$request->validated('reason'),
(int) $admin->id,
$request->validated('idempotency_key'),
);
$after = DB::table('settlement_bills')->where('id', $settlement_bill)->first();

View File

@@ -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,
]);
}
}

View File

@@ -2,15 +2,18 @@
namespace App\Http\Controllers\Api\V1\Admin\AgentSettlement;
use App\Support\ApiResponse;
use Illuminate\Http\Request;
use App\Support\PaginationTrait;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use App\Http\Controllers\Controller;
use App\Support\AdminAgentSettlementScope;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
final class AgentSettlementPaymentIndexController extends Controller
{
use PaginationTrait;
public function __invoke(Request $request): JsonResponse
{
$admin = $request->lotteryAdmin();
@@ -54,8 +57,15 @@ final class AgentSettlementPaymentIndexController extends Controller
AdminAgentSettlementScope::applyDirectEdgeScopeToBillsQuery($query, $admin, 'sb');
$meta = $this->paginationMeta($request, defaultPerPage: 20, maxPerPage: 200);
$paginator = $query->paginate($meta['per_page'], ['pr.*', 'sb.bill_type', 'sb.owner_type', 'sb.owner_id', 'sb.counterparty_type', 'sb.counterparty_id', 'sp.period_start', 'sp.period_end', 'sp.admin_site_id'], 'page', $meta['page']);
return ApiResponse::success([
'items' => $query->limit(200)->get(),
'items' => $paginator->items(),
'total' => $paginator->total(),
'page' => $paginator->currentPage(),
'per_page' => $paginator->perPage(),
'last_page' => $paginator->lastPage(),
]);
}
}

View File

@@ -2,10 +2,12 @@
namespace App\Http\Controllers\Api\V1\E2E;
use App\Services\AdminCaptchaService;
use App\Lottery\ErrorCode;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use App\Http\Controllers\Controller;
use App\Services\AdminCaptchaService;
/**
* E2E 辅助peek captcha bypass 码。
@@ -14,14 +16,15 @@ use Illuminate\Http\Request;
* 改用 e2e 约定captcha_code == "LOTTERY_E2E_BYPASS" LOTTERY_E2E=true 时视为通过。
* 本端点不返回任何"答案",仅返回约定码提示。
*/
final class E2ECaptchaPeekController extends \App\Http\Controllers\Controller
final class E2ECaptchaPeekController extends Controller
{
public function player(Request $request): JsonResponse
{
$key = (string) $request->input('captcha_key', '');
if ($key === '') {
return ApiResponse::error('captcha_key required', 'e2e_invalid_input', null, 422);
return ApiResponse::error('captcha_key required', ErrorCode::ValidationFailed->value, null, 422);
}
return $this->peek($key, AdminCaptchaService::SCOPE_PLAYER);
}
@@ -29,8 +32,9 @@ final class E2ECaptchaPeekController extends \App\Http\Controllers\Controller
{
$key = (string) $request->input('captcha_key', '');
if ($key === '') {
return ApiResponse::error('captcha_key required', 'e2e_invalid_input', null, 422);
return ApiResponse::error('captcha_key required', ErrorCode::ValidationFailed->value, null, 422);
}
return $this->peek($key, AdminCaptchaService::SCOPE_ADMIN);
}

View File

@@ -2,11 +2,14 @@
namespace App\Http\Controllers\Api\V1\E2E;
use App\Lottery\ErrorCode;
use App\Lottery\DrawStatus;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use App\Http\Controllers\Controller;
use Illuminate\Support\Facades\Artisan;
/**
* E2E 辅助:期号时间/状态快进。
@@ -19,13 +22,13 @@ use Illuminate\Support\Facades\DB;
*
* 严格仅在 LOTTERY_E2E=true 时由 E2EServiceProvider 注册。
*/
final class E2EDrawController extends \App\Http\Controllers\Controller
final class E2EDrawController extends Controller
{
public function closeNow(Request $request, string $drawNo): JsonResponse
{
$draw = DB::table('draws')->where('draw_no', $drawNo)->first();
if (! $draw) {
return ApiResponse::error('draw not found', 'e2e_draw_missing', null, 404);
return ApiResponse::error('draw not found', ErrorCode::NotFound->value, null, 404);
}
DB::table('draws')
@@ -44,7 +47,7 @@ final class E2EDrawController extends \App\Http\Controllers\Controller
{
$draw = DB::table('draws')->where('draw_no', $drawNo)->first();
if (! $draw) {
return ApiResponse::error('draw not found', 'e2e_draw_missing', null, 404);
return ApiResponse::error('draw not found', ErrorCode::NotFound->value, null, 404);
}
DB::table('draws')
@@ -62,7 +65,7 @@ final class E2EDrawController extends \App\Http\Controllers\Controller
{
$draw = DB::table('draws')->where('draw_no', $drawNo)->first();
if (! $draw) {
return ApiResponse::error('draw not found', 'e2e_draw_missing', null, 404);
return ApiResponse::error('draw not found', ErrorCode::NotFound->value, null, 404);
}
DB::table('draws')
@@ -82,8 +85,9 @@ final class E2EDrawController extends \App\Http\Controllers\Controller
{
$draw = DB::table('draws')->where('draw_no', $drawNo)->first();
if (! $draw) {
return ApiResponse::error('draw not found', 'e2e_draw_missing', null, 404);
return ApiResponse::error('draw not found', ErrorCode::NotFound->value, null, 404);
}
return $this->inspectById((int) $draw->id);
}
@@ -91,11 +95,11 @@ final class E2EDrawController extends \App\Http\Controllers\Controller
public function tick(Request $request): JsonResponse
{
try {
$exit = \Illuminate\Support\Facades\Artisan::call('lottery:draw-tick');
$exit = Artisan::call('lottery:draw-tick');
} catch (\Throwable $e) {
return ApiResponse::error(
'draw-tick failed: '.$e->getMessage(),
'e2e_tick_failed',
ErrorCode::InternalError->value,
['trace' => array_slice(explode("\n", $e->getTraceAsString()), 0, 5)],
500,
);
@@ -103,13 +107,14 @@ final class E2EDrawController extends \App\Http\Controllers\Controller
return ApiResponse::success([
'exit_code' => $exit,
'output' => \Illuminate\Support\Facades\Artisan::output(),
'output' => Artisan::output(),
]);
}
private function inspectById(int $id): JsonResponse
{
$d = DB::table('draws')->where('id', $id)->first();
return ApiResponse::success([
'id' => (int) $d->id,
'draw_no' => $d->draw_no,

View File

@@ -2,10 +2,12 @@
namespace App\Http\Controllers\Api\V1\E2E;
use App\Lottery\ErrorCode;
use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
use App\Http\Controllers\Controller;
/**
* E2E 辅助:重置/查询玩家状态。
@@ -17,7 +19,7 @@ use Illuminate\Support\Facades\DB;
*
* 严格仅在 LOTTERY_E2E=true 时由 E2EServiceProvider 注册。
*/
final class E2EPlayerStateController extends \App\Http\Controllers\Controller
final class E2EPlayerStateController extends Controller
{
public function reset(Request $request): JsonResponse
{
@@ -32,7 +34,7 @@ final class E2EPlayerStateController extends \App\Http\Controllers\Controller
->value('id');
if (! $playerId) {
return ApiResponse::error('e2e player not found', 'e2e_player_missing', null, 404);
return ApiResponse::error('e2e player not found', ErrorCode::NotFound->value, null, 404);
}
DB::transaction(function () use ($playerId, $balance, $currency) {
@@ -70,7 +72,7 @@ final class E2EPlayerStateController extends \App\Http\Controllers\Controller
$balance = (int) $request->input('balance', -1);
if ($balance < 0) {
return ApiResponse::error('balance must be >= 0', 'e2e_invalid_input', null, 422);
return ApiResponse::error('balance must be >= 0', ErrorCode::ValidationFailed->value, null, 422);
}
$playerId = DB::table('players')
@@ -79,7 +81,7 @@ final class E2EPlayerStateController extends \App\Http\Controllers\Controller
->value('id');
if (! $playerId) {
return ApiResponse::error('e2e player not found', 'e2e_player_missing', null, 404);
return ApiResponse::error('e2e player not found', ErrorCode::NotFound->value, null, 404);
}
DB::table('player_wallets')
@@ -130,7 +132,7 @@ final class E2EPlayerStateController extends \App\Http\Controllers\Controller
->first();
if (! $player) {
return ApiResponse::error('e2e player not found', 'e2e_player_missing', null, 404);
return ApiResponse::error('e2e player not found', ErrorCode::NotFound->value, null, 404);
}
$wallets = DB::table('player_wallets')

View File

@@ -2,27 +2,29 @@
namespace App\Http\Controllers\Api\V1\E2E;
use Firebase\JWT\JWT;
use App\Models\Player;
use App\Models\AdminSite;
use App\Models\AdminUser;
use App\Models\AgentNode;
use App\Models\Player;
use App\Lottery\ErrorCode;
use App\Models\PlayerWallet;
use App\Support\ApiResponse;
use Illuminate\Http\Request;
use App\Support\PlayerAuthSource;
use Illuminate\Http\JsonResponse;
use App\Support\SiteOperatorRoles;
use Illuminate\Support\Facades\DB;
use App\Http\Controllers\Controller;
use App\Support\PlatformSystemRoles;
use Illuminate\Support\Facades\Hash;
use App\Services\Agent\AgentNodeService;
use App\Services\Integration\PartnerSiteConfigResolver;
use App\Support\ApiResponse;
use App\Support\PlatformSystemRoles;
use App\Support\PlayerAuthSource;
use App\Support\SiteOperatorRoles;
use Firebase\JWT\JWT;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
/**
* E2E 辅助:信用玩家 / SSO JWT / 主站钱包 mock 配置。
*/
final class E2EProvisionController extends \App\Http\Controllers\Controller
final class E2EProvisionController extends Controller
{
public function setupCreditPlayer(Request $request): JsonResponse
{
@@ -34,7 +36,7 @@ final class E2EProvisionController extends \App\Http\Controllers\Controller
$site = AdminSite::query()->where('code', $siteCode)->first();
if ($site === null) {
return ApiResponse::error('site not found', 'e2e_site_missing', null, 404);
return ApiResponse::error('site not found', ErrorCode::NotFound->value, null, 404);
}
$extra = $site->extra_json ?? [];
@@ -54,7 +56,7 @@ final class E2EProvisionController extends \App\Http\Controllers\Controller
->value('id');
if ($rootId <= 0) {
return ApiResponse::error('root agent missing', 'e2e_agent_missing', null, 500);
return ApiResponse::error('root agent missing', ErrorCode::InternalError->value, null, 500);
}
$leafCode = (string) $request->input('agent_code', 'e2e_leaf');
@@ -64,9 +66,9 @@ final class E2EProvisionController extends \App\Http\Controllers\Controller
->first();
if ($leaf === null) {
$super = \App\Models\AdminUser::query()->where('username', 'admin')->first();
$super = AdminUser::query()->where('username', 'admin')->first();
if ($super === null) {
return ApiResponse::error('admin user missing', 'e2e_admin_missing', null, 500);
return ApiResponse::error('admin user missing', ErrorCode::InternalError->value, null, 500);
}
$leaf = app(AgentNodeService::class)->createChild($super, [
'parent_id' => $rootId,
@@ -153,12 +155,12 @@ final class E2EProvisionController extends \App\Http\Controllers\Controller
$site = AdminSite::query()->where('code', $siteCode)->first();
if ($site === null) {
return ApiResponse::error('site not found', 'e2e_site_missing', null, 404);
return ApiResponse::error('site not found', ErrorCode::NotFound->value, null, 404);
}
$roleId = (int) DB::table('admin_roles')->where('slug', $roleSlug)->value('id');
if ($roleId <= 0) {
return ApiResponse::error('role not found', 'e2e_role_missing', null, 500);
return ApiResponse::error('role not found', ErrorCode::InternalError->value, null, 500);
}
/** @var AdminUser $user */
@@ -192,7 +194,7 @@ final class E2EProvisionController extends \App\Http\Controllers\Controller
$siteCode = (string) ($request->input('site_code') ?: env('E2E_PLAYER_SITE_CODE', 'demo'));
$site = AdminSite::query()->where('code', $siteCode)->first();
if ($site === null) {
return ApiResponse::error('site not found', 'e2e_site_missing', null, 404);
return ApiResponse::error('site not found', ErrorCode::NotFound->value, null, 404);
}
$siteId = (int) $site->id;
@@ -230,12 +232,12 @@ final class E2EProvisionController extends \App\Http\Controllers\Controller
$site = AdminSite::query()->where('code', $siteCode)->first();
if ($site === null) {
return ApiResponse::error('site not found', 'e2e_site_missing', null, 404);
return ApiResponse::error('site not found', ErrorCode::NotFound->value, null, 404);
}
$secret = $site->decryptedSsoJwtSecret();
if (! is_string($secret) || $secret === '') {
return ApiResponse::error('sso secret missing on site', 'e2e_sso_secret_missing', null, 500);
return ApiResponse::error('sso secret missing on site', ErrorCode::InternalError->value, null, 500);
}
$now = time();
@@ -258,12 +260,12 @@ final class E2EProvisionController extends \App\Http\Controllers\Controller
$siteCode = (string) ($request->input('site_code') ?: env('E2E_PLAYER_SITE_CODE', 'demo'));
$baseUrl = rtrim((string) $request->input('base_url', ''), '/');
if ($baseUrl === '') {
return ApiResponse::error('base_url required', 'e2e_invalid_input', null, 422);
return ApiResponse::error('base_url required', ErrorCode::ValidationFailed->value, null, 422);
}
$site = AdminSite::query()->where('code', $siteCode)->first();
if ($site === null) {
return ApiResponse::error('site not found', 'e2e_site_missing', null, 404);
return ApiResponse::error('site not found', ErrorCode::NotFound->value, null, 404);
}
$site->wallet_api_url = $baseUrl;
@@ -286,7 +288,7 @@ final class E2EProvisionController extends \App\Http\Controllers\Controller
$site = AdminSite::query()->where('code', $siteCode)->first();
if ($site === null) {
return ApiResponse::error('site not found', 'e2e_site_missing', null, 404);
return ApiResponse::error('site not found', ErrorCode::NotFound->value, null, 404);
}
$site->wallet_api_url = null;

View File

@@ -15,6 +15,7 @@ final class AdminSettlementBillBadDebtRequest extends ApiFormRequest
{
return [
'reason' => ['sometimes', 'nullable', 'string', 'max:255'],
'idempotency_key' => ['sometimes', 'nullable', 'string', 'max:64'],
];
}
}

View File

@@ -3,8 +3,10 @@
namespace App\Services\AgentSettlement;
use App\Models\Player;
use App\Services\Player\PlayerCreditService;
use Illuminate\Support\Facades\DB;
use Illuminate\Database\QueryException;
use App\Support\DatabaseUniqueViolation;
use App\Services\Player\PlayerCreditService;
use Illuminate\Validation\ValidationException;
/** 坏账核销:原账单保留,记 bad_debt 调整与归档单§2、§21.1)。 */
@@ -15,112 +17,167 @@ final class AgentSettlementBadDebtService
private readonly PlayerCreditService $playerCreditService,
) {}
public function writeOff(int $originalBillId, ?string $reason, int $adminUserId): int
{
return (int) DB::transaction(function () use ($originalBillId, $reason, $adminUserId): int {
/** @var object|null $original */
$original = DB::table('settlement_bills')->where('id', $originalBillId)->lockForUpdate()->first();
if ($original === null) {
throw new \InvalidArgumentException('bill_not_found');
}
$meta = $this->decodeMeta($original->meta_json);
$existingArchiveId = (int) ($meta['bad_debt_bill_id'] ?? 0);
if ($existingArchiveId > 0) {
public function writeOff(
int $originalBillId,
?string $reason,
int $adminUserId,
?string $idempotencyKey = null,
): int {
if ($idempotencyKey !== null && $idempotencyKey !== '') {
$existingArchiveId = $this->findArchiveBillByIdempotency($originalBillId, $idempotencyKey);
if ($existingArchiveId !== null) {
return $existingArchiveId;
}
}
if ($this->periodCompletion->isPeriodReadOnly((int) $original->settlement_period_id)) {
throw ValidationException::withMessages([
'period' => ['completed'],
]);
}
if (! in_array((string) $original->status, ['confirmed', 'partial_paid', 'overdue'], true)) {
throw ValidationException::withMessages([
'bill' => ['not_eligible'],
]);
}
$unpaid = (int) $original->unpaid_amount;
if ($unpaid <= 0) {
throw ValidationException::withMessages([
'bill' => ['no_unpaid'],
]);
}
if (in_array((string) $original->bill_type, ['adjustment', 'reversal', 'bad_debt'], true)) {
throw ValidationException::withMessages([
'bill' => ['not_eligible'],
]);
}
$now = now();
$periodId = (int) $original->settlement_period_id;
$archiveBillId = (int) DB::table('settlement_bills')->insertGetId([
'settlement_period_id' => $periodId,
'bill_type' => 'bad_debt',
'owner_type' => (string) $original->owner_type,
'owner_id' => (int) $original->owner_id,
'counterparty_type' => (string) $original->counterparty_type,
'counterparty_id' => (int) $original->counterparty_id,
'gross_win_loss' => 0,
'rebate_amount' => 0,
'adjustment_amount' => -$unpaid,
'platform_rounding_adjustment' => 0,
'net_amount' => 0,
'paid_amount' => 0,
'unpaid_amount' => 0,
'status' => 'settled',
'reversed_bill_id' => $originalBillId,
'meta_json' => json_encode([
'original_bill_id' => $originalBillId,
'written_off_amount' => $unpaid,
'original_net_amount' => (int) $original->net_amount,
]),
'locked_at' => $now,
'confirmed_at' => $now,
'created_at' => $now,
'updated_at' => $now,
]);
DB::table('settlement_adjustments')->insert([
'settlement_period_id' => $periodId,
'original_bill_id' => $originalBillId,
'adjustment_type' => 'bad_debt',
'amount' => $unpaid,
'reason' => $reason,
'created_by' => $adminUserId > 0 ? $adminUserId : null,
'created_at' => $now,
'updated_at' => $now,
]);
DB::table('settlement_bills')->where('id', $originalBillId)->update([
'unpaid_amount' => 0,
'status' => 'settled',
'meta_json' => json_encode(array_merge(
$meta,
[
'bad_debt_bill_id' => $archiveBillId,
'written_off_amount' => $unpaid,
],
)),
'updated_at' => $now,
]);
if ((string) $original->owner_type === 'player' && (int) $original->owner_id > 0 && (int) $original->net_amount > 0) {
$player = Player::query()->find((int) $original->owner_id);
if ($player !== null) {
$cumulativePaid = (int) $original->paid_amount + $unpaid;
$this->playerCreditService->releaseFromSettlement($player, $cumulativePaid, $originalBillId);
try {
return (int) DB::transaction(function () use ($originalBillId, $reason, $adminUserId, $idempotencyKey): int {
return $this->insertBadDebtWriteOff($originalBillId, $reason, $adminUserId, $idempotencyKey);
});
} catch (QueryException $e) {
if (
$idempotencyKey !== null
&& $idempotencyKey !== ''
&& DatabaseUniqueViolation::matches($e)
) {
$existingArchiveId = $this->findArchiveBillByIdempotency($originalBillId, $idempotencyKey);
if ($existingArchiveId !== null) {
return $existingArchiveId;
}
}
$this->periodCompletion->syncIfReady($periodId);
throw $e;
}
}
return $archiveBillId;
});
private function insertBadDebtWriteOff(
int $originalBillId,
?string $reason,
int $adminUserId,
?string $idempotencyKey,
): int {
/** @var object|null $original */
$original = DB::table('settlement_bills')->where('id', $originalBillId)->lockForUpdate()->first();
if ($original === null) {
throw new \InvalidArgumentException('bill_not_found');
}
$meta = $this->decodeMeta($original->meta_json);
$existingArchiveId = (int) ($meta['bad_debt_bill_id'] ?? 0);
if ($existingArchiveId > 0) {
return $existingArchiveId;
}
if ($idempotencyKey !== null && $idempotencyKey !== '') {
$existingArchiveId = $this->findArchiveBillByIdempotency($originalBillId, $idempotencyKey);
if ($existingArchiveId !== null) {
return $existingArchiveId;
}
}
if ($this->periodCompletion->isPeriodReadOnly((int) $original->settlement_period_id)) {
throw ValidationException::withMessages([
'period' => ['completed'],
]);
}
if (! in_array((string) $original->status, ['confirmed', 'partial_paid', 'overdue'], true)) {
throw ValidationException::withMessages([
'bill' => ['not_eligible'],
]);
}
$unpaid = (int) $original->unpaid_amount;
if ($unpaid <= 0) {
throw ValidationException::withMessages([
'bill' => ['no_unpaid'],
]);
}
if (in_array((string) $original->bill_type, ['adjustment', 'reversal', 'bad_debt'], true)) {
throw ValidationException::withMessages([
'bill' => ['not_eligible'],
]);
}
$now = now();
$periodId = (int) $original->settlement_period_id;
$archiveBillId = (int) DB::table('settlement_bills')->insertGetId([
'settlement_period_id' => $periodId,
'bill_type' => 'bad_debt',
'owner_type' => (string) $original->owner_type,
'owner_id' => (int) $original->owner_id,
'counterparty_type' => (string) $original->counterparty_type,
'counterparty_id' => (int) $original->counterparty_id,
'gross_win_loss' => 0,
'rebate_amount' => 0,
'adjustment_amount' => -$unpaid,
'platform_rounding_adjustment' => 0,
'net_amount' => 0,
'paid_amount' => 0,
'unpaid_amount' => 0,
'status' => 'settled',
'reversed_bill_id' => $originalBillId,
'meta_json' => json_encode([
'original_bill_id' => $originalBillId,
'written_off_amount' => $unpaid,
'original_net_amount' => (int) $original->net_amount,
]),
'locked_at' => $now,
'confirmed_at' => $now,
'created_at' => $now,
'updated_at' => $now,
]);
DB::table('settlement_adjustments')->insert([
'settlement_period_id' => $periodId,
'original_bill_id' => $originalBillId,
'result_bill_id' => $archiveBillId,
'adjustment_type' => 'bad_debt',
'amount' => $unpaid,
'reason' => $reason,
'idempotency_key' => $idempotencyKey,
'created_by' => $adminUserId > 0 ? $adminUserId : null,
'created_at' => $now,
'updated_at' => $now,
]);
DB::table('settlement_bills')->where('id', $originalBillId)->update([
'unpaid_amount' => 0,
'status' => 'settled',
'meta_json' => json_encode(array_merge(
$meta,
[
'bad_debt_bill_id' => $archiveBillId,
'written_off_amount' => $unpaid,
],
)),
'updated_at' => $now,
]);
if ((string) $original->owner_type === 'player' && (int) $original->owner_id > 0 && (int) $original->net_amount > 0) {
$player = Player::query()->find((int) $original->owner_id);
if ($player !== null) {
$cumulativePaid = (int) $original->paid_amount + $unpaid;
$this->playerCreditService->releaseFromSettlement($player, $cumulativePaid, $originalBillId);
}
}
$this->periodCompletion->syncIfReady($periodId);
return $archiveBillId;
}
private function findArchiveBillByIdempotency(int $originalBillId, string $idempotencyKey): ?int
{
$resultBillId = DB::table('settlement_adjustments')
->where('original_bill_id', $originalBillId)
->where('adjustment_type', 'bad_debt')
->where('idempotency_key', $idempotencyKey)
->value('result_bill_id');
return $resultBillId !== null ? (int) $resultBillId : null;
}
/**
@@ -140,4 +197,4 @@ final class AgentSettlementBadDebtService
return is_array($decoded) ? $decoded : [];
}
}
}

View File

@@ -2,14 +2,16 @@
namespace App\Services\AgentSettlement;
use Carbon\Carbon;
use App\Models\AdminUser;
use App\Support\LimitedQuery;
use App\Support\CurrencyFormatter;
use App\Support\PlayerFundingMode;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Database\Query\Builder;
use App\Support\AdminAgentSettlementScope;
use App\Support\AgentSettlementPeriodWindow;
use App\Support\CurrencyFormatter;
use App\Support\LimitedQuery;
use App\Support\PlayerFundingMode;
use Carbon\Carbon;
use Illuminate\Support\Facades\DB;
/** 结算中心统一账务流水credit_ledger + 收付 + 调账)。 */
final class SettlementCenterLedgerService
@@ -36,6 +38,7 @@ final class SettlementCenterLedgerService
private readonly SettlementPartyEnrichment $partyEnrichment,
private readonly CreditLedgerBetFlowPresenter $betFlowPresenter,
) {}
/**
* @return array{
* items: list<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);
if (count($stubQueries) === 1) {
$base = $stubQueries[0];
@@ -122,11 +138,6 @@ final class SettlementCenterLedgerService
$items = $this->hydrateLedgerStubs($admin, $siteCode, $stubs, $playerBills);
$items = $this->applyFilters($items, $filters);
$filteredCount = count($items);
if ($filteredCount !== count($stubs)) {
$total = $filteredCount;
}
return [
'items' => array_values($items),
@@ -248,7 +259,7 @@ final class SettlementCenterLedgerService
string $siteCode,
?array $range,
SettlementLedgerListFilters $filters,
): ?\Illuminate\Database\Query\Builder {
): ?Builder {
$query = DB::table('share_ledger as sl')
->join('players as p', 'p.id', '=', 'sl.player_id')
->where('p.site_code', $siteCode)
@@ -275,7 +286,7 @@ final class SettlementCenterLedgerService
string $siteCode,
?array $range,
SettlementLedgerListFilters $filters,
): ?\Illuminate\Database\Query\Builder {
): ?Builder {
$query = DB::table('credit_ledger as cl')
->join('players as p', function ($join): void {
$join->on('p.id', '=', 'cl.owner_id')
@@ -313,7 +324,7 @@ final class SettlementCenterLedgerService
string $siteCode,
?int $periodId,
SettlementLedgerListFilters $filters,
): ?\Illuminate\Database\Query\Builder {
): ?Builder {
$adminSiteId = (int) DB::table('admin_sites')->where('code', $siteCode)->value('id');
if ($adminSiteId <= 0) {
return null;
@@ -347,7 +358,7 @@ final class SettlementCenterLedgerService
}
private function applyPaymentPlayerFilters(
\Illuminate\Database\Query\Builder $query,
Builder $query,
SettlementLedgerListFilters $filters,
): void {
if ($filters->playerId !== null && $filters->playerId > 0) {
@@ -366,7 +377,7 @@ final class SettlementCenterLedgerService
string $siteCode,
?int $periodId,
SettlementLedgerListFilters $filters,
): ?\Illuminate\Database\Query\Builder {
): ?Builder {
$adminSiteId = (int) DB::table('admin_sites')->where('code', $siteCode)->value('id');
if ($adminSiteId <= 0) {
return null;
@@ -409,14 +420,14 @@ final class SettlementCenterLedgerService
}
private function applyAdjustmentPlayerScope(
\Illuminate\Database\Query\Builder $query,
Builder $query,
AdminUser $admin,
string $siteCode,
SettlementLedgerListFilters $filters,
): void {
$query->where(function (\Illuminate\Database\Query\Builder $outer) use ($admin, $siteCode, $filters): void {
$query->where(function (Builder $outer) use ($admin, $siteCode, $filters): void {
$outer->whereNull('p.id')
->orWhere(function (\Illuminate\Database\Query\Builder $scoped) use ($admin, $siteCode, $filters): void {
->orWhere(function (Builder $scoped) use ($admin, $siteCode, $filters): void {
$scoped->where('p.site_code', $siteCode);
AdminAgentSettlementScope::applyDirectPlayersToAlias($scoped, $admin, 'p');
$this->applyLedgerPlayerFilters($scoped, 'p', $filters);
@@ -425,7 +436,7 @@ final class SettlementCenterLedgerService
}
private function applyTxnNoStubFilter(
\Illuminate\Database\Query\Builder $query,
Builder $query,
string $idColumn,
string $prefix,
?string $txnNo,
@@ -435,7 +446,7 @@ final class SettlementCenterLedgerService
}
$needle = strtolower(trim($txnNo));
$query->where(function (\Illuminate\Database\Query\Builder $match) use ($idColumn, $prefix, $needle): void {
$query->where(function (Builder $match) use ($idColumn, $prefix, $needle): void {
if (ctype_digit($needle)) {
$match->where($idColumn, (int) $needle);
}
@@ -447,7 +458,7 @@ final class SettlementCenterLedgerService
});
}
private function applyLedgerSiteScope(\Illuminate\Database\Query\Builder $query, AdminUser $admin, string $periodsAlias): void
private function applyLedgerSiteScope(Builder $query, AdminUser $admin, string $periodsAlias): void
{
$siteIds = $admin->accessibleAdminSiteIds();
if ($siteIds === null) {
@@ -464,7 +475,7 @@ final class SettlementCenterLedgerService
}
private function applyLedgerPlayerFilters(
\Illuminate\Database\Query\Builder $query,
Builder $query,
string $playerAlias,
SettlementLedgerListFilters $filters,
): void {
@@ -474,7 +485,7 @@ final class SettlementCenterLedgerService
if ($filters->playerAccount !== null && $filters->playerAccount !== '') {
$like = '%'.addcslashes($filters->playerAccount, '%_\\').'%';
$query->where(function (\Illuminate\Database\Query\Builder $match) use ($playerAlias, $like): void {
$query->where(function (Builder $match) use ($playerAlias, $like): void {
$match->where("{$playerAlias}.username", 'like', $like)
->orWhere("{$playerAlias}.site_player_id", 'like', $like)
->orWhere("{$playerAlias}.nickname", 'like', $like);
@@ -483,14 +494,14 @@ final class SettlementCenterLedgerService
}
/**
* @param \Illuminate\Support\Collection<int, object> $stubs
* @param Collection<int, object> $stubs
* @param array<int, object> $playerBills
* @return list<array<string, mixed>>
*/
private function hydrateLedgerStubs(
AdminUser $admin,
string $siteCode,
\Illuminate\Support\Collection $stubs,
Collection $stubs,
array $playerBills,
): array {
if ($stubs->isEmpty()) {
@@ -641,6 +652,71 @@ final class SettlementCenterLedgerService
return $selected === $kind;
}
/**
* actionable_only 依赖 hydrated 行的 available_actions需在内存中过滤后再分页。
*
* @param list<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
* @return list<array<string, mixed>>
@@ -1541,7 +1617,7 @@ final class SettlementCenterLedgerService
}
if ($billStatus !== null
&& in_array($billStatus, ['confirmed', 'partial_paid', 'settled', 'overdue'], true)
&& in_array($billStatus, ['confirmed', 'partial_paid', 'overdue'], true)
&& ! in_array((string) $billType, ['adjustment', 'reversal', 'bad_debt'], true)) {
$actions[] = 'adjustment';
$actions[] = 'reversal';

View File

@@ -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;
}
}

View 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,
];
}
}

View File

@@ -451,6 +451,7 @@ final class AdminAuthorizationRegistry
['code' => 'admin.settlement-periods.close', 'module_code' => 'settlement', 'name' => '关闭代理账期', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/settlement-periods/{settlement_period}/close', 'route_name' => 'api.v1.admin.settlement-periods.close', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.manage']],
['code' => 'admin.credit-ledger.index', 'module_code' => 'settlement', 'name' => '信用流水查询', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/credit-ledger', 'route_name' => 'api.v1.admin.credit-ledger.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['settlement.agent.view', 'settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.view', 'prd.settlement.agent.manage']],
['code' => 'admin.settlement-bills.index', 'module_code' => 'settlement', 'name' => '代理账单列表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-bills', 'route_name' => 'api.v1.admin.settlement-bills.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['settlement.agent.view', 'settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.view', 'prd.settlement.agent.manage']],
['code' => 'admin.settlement-operations.index', 'module_code' => 'settlement', 'name' => '结算操作记录', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-operations', 'route_name' => 'api.v1.admin.settlement-operations.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['settlement.agent.view', 'settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.view', 'prd.settlement.agent.manage']],
['code' => 'admin.settlement-payments.index', 'module_code' => 'settlement', 'name' => '代理账单收付记录', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-payments', 'route_name' => 'api.v1.admin.settlement-payments.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['settlement.agent.view', 'settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.view', 'prd.settlement.agent.manage']],
['code' => 'admin.settlement-adjustments.index', 'module_code' => 'settlement', 'name' => '代理账单调账记录', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-adjustments', 'route_name' => 'api.v1.admin.settlement-adjustments.index', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['settlement.agent.view', 'settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.view', 'prd.settlement.agent.manage']],
['code' => 'admin.settlement-bills.show', 'module_code' => 'settlement', 'name' => '代理账单详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/settlement-bills/{settlement_bill}', 'route_name' => 'api.v1.admin.settlement-bills.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['settlement.agent.view', 'settlement.agent.manage'], 'legacy_permission_slugs' => ['prd.settlement.agent.view', 'prd.settlement.agent.manage']],
@@ -569,7 +570,6 @@ final class AdminAuthorizationRegistry
['code' => 'admin.report-jobs.store', 'module_code' => 'report', 'name' => '创建报表任务', 'http_method' => 'POST', 'uri_pattern' => '/api/v1/admin/report-jobs', 'route_name' => 'api.v1.admin.report-jobs.store', 'auth_mode' => 'permission_required', 'is_audit_required' => true, 'permission_codes' => ['service.report.export']],
['code' => 'admin.report-jobs.show', 'module_code' => 'report', 'name' => '报表任务详情', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/report-jobs/{report_job}', 'route_name' => 'api.v1.admin.report-jobs.show', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.view', 'service.report.export']],
['code' => 'admin.report-jobs.download', 'module_code' => 'report', 'name' => '下载报表', 'http_method' => 'GET', 'uri_pattern' => '/api/v1/admin/report-jobs/{report_job}/download', 'route_name' => 'api.v1.admin.report-jobs.download', 'auth_mode' => 'permission_required', 'is_audit_required' => false, 'permission_codes' => ['service.report.export']],
];
}
}

View File

@@ -8,8 +8,10 @@ export default async function globalSetup(): Promise<void> {
const mockPort = process.env.E2E_MOCK_WALLET_PORT ?? '5555';
const ctx = await pwRequest.newContext({ baseURL: api });
const siteCode = process.env.E2E_PLAYER_SITE_CODE ?? 'demo';
const walletResp = await ctx.post('/api/v1/_e2e/site/wallet-api', {
data: {
site_code: siteCode,
base_url: `http://127.0.0.1:${mockPort}`,
wallet_api_key: 'e2e-mock-key',
},

View File

@@ -22,7 +22,8 @@ test('超管进入结算中心 → 账期管理页可见', async ({ page }) => {
timeout: 60_000,
});
await expect(page.getByText(/账期管理|Period/i).first()).toBeVisible({ timeout: 30_000 });
await expect(page.getByRole('button', { name: /开账|Open period/i }).first()).toBeVisible({
timeout: 30_000,
});
const periodAction = page
.getByRole('button', { name: /开账|Open period|关账|Close period/i })
.first();
await expect(periodAction).toBeVisible({ timeout: 30_000 });
});

View File

@@ -1,21 +1,22 @@
<?php
use Illuminate\Support\Facades\Route;
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\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\AgentSettlementPeriodOpenHintsController;
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\AgentSettlementReportIndexController;
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementReportShowController;
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Api\V1\Admin\AgentSettlement\AgentSettlementPaymentIndexController;
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')
->group(function (): void {
@@ -31,6 +32,8 @@ Route::middleware('admin.api-resource')
->name('api.v1.admin.credit-ledger.index');
Route::get('settlement-bills', AgentSettlementBillIndexController::class)
->name('api.v1.admin.settlement-bills.index');
Route::get('settlement-operations', AgentSettlementOperationsIndexController::class)
->name('api.v1.admin.settlement-operations.index');
Route::get('settlement-payments', AgentSettlementPaymentIndexController::class)
->name('api.v1.admin.settlement-payments.index');
Route::get('settlement-adjustments', AgentSettlementAdjustmentIndexController::class)

View File

@@ -1,11 +1,13 @@
<?php
use App\Models\AdminUser;
use App\Models\Draw;
use App\Models\Player;
use App\Models\AdminUser;
use App\Lottery\DrawStatus;
use App\Support\PlayerFundingMode;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Foundation\Testing\RefreshDatabase;
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);
});
test('credit ledger settled bill payment row omits adjustment actions', function (): void {
$site = DB::table('admin_sites')->where('is_default', true)->first();
$siteId = (int) $site->id;
$siteCode = (string) $site->code;
$periodId = (int) DB::table('settlement_periods')->insertGetId([
'admin_site_id' => $siteId,
'period_start' => now()->subDay(),
'period_end' => now()->addDay(),
'status' => 'closed',
'created_at' => now(),
'updated_at' => now(),
]);
$player = Player::query()->create([
'site_code' => $siteCode,
'site_player_id' => 'native:ledger-settled-actions',
'funding_mode' => PlayerFundingMode::CREDIT,
'username' => 'ledger_settled_actions',
'default_currency' => 'NPR',
'status' => 0,
]);
$billId = (int) DB::table('settlement_bills')->insertGetId([
'settlement_period_id' => $periodId,
'bill_type' => 'player',
'owner_type' => 'player',
'owner_id' => $player->id,
'counterparty_type' => 'agent',
'counterparty_id' => 1,
'net_amount' => 100,
'unpaid_amount' => 0,
'paid_amount' => 100,
'status' => 'settled',
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('payment_records')->insert([
'settlement_bill_id' => $billId,
'payer_type' => 'player',
'payer_id' => $player->id,
'payee_type' => 'agent',
'payee_id' => 1,
'amount' => 100,
'status' => 'confirmed',
'created_at' => now(),
'updated_at' => now(),
]);
$admin = AdminUser::query()->create([
'username' => 'ledger_settled_actions_super',
'name' => 'Ledger Settled Actions',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($admin);
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/credit-ledger?admin_site_id='.$siteId.'&settlement_period_id='.$periodId.'&reason=payment_record')
->assertOk()
->assertJsonPath('data.items.0.available_actions', ['view_player', 'view_bill']);
});
test('credit ledger actionable_only paginates after hydration filter', function (): void {
$site = DB::table('admin_sites')->where('is_default', true)->first();
$siteId = (int) $site->id;
$siteCode = (string) $site->code;
$agentId = (int) DB::table('agent_nodes')->where('admin_site_id', $siteId)->where('depth', 0)->value('id');
$periodId = (int) DB::table('settlement_periods')->insertGetId([
'admin_site_id' => $siteId,
'period_start' => now()->subDay(),
'period_end' => now()->addDay(),
'status' => 'closed',
'created_at' => now(),
'updated_at' => now(),
]);
$player = Player::query()->create([
'site_code' => $siteCode,
'site_player_id' => 'native:ledger-actionable',
'funding_mode' => PlayerFundingMode::CREDIT,
'username' => 'ledger_actionable_user',
'default_currency' => 'NPR',
'status' => 0,
]);
DB::table('credit_ledger')->insert([
'owner_type' => 'player',
'owner_id' => $player->id,
'amount' => -100,
'reason' => 'bet_hold',
'created_at' => now()->subMinute(),
'updated_at' => now()->subMinute(),
]);
$actionableBillId = (int) DB::table('settlement_bills')->insertGetId([
'settlement_period_id' => $periodId,
'bill_type' => 'player',
'owner_type' => 'player',
'owner_id' => $player->id,
'counterparty_type' => 'agent',
'counterparty_id' => $agentId,
'net_amount' => 200,
'unpaid_amount' => 200,
'paid_amount' => 0,
'status' => 'confirmed',
'created_at' => now(),
'updated_at' => now(),
]);
$actionablePaymentId = (int) DB::table('payment_records')->insertGetId([
'settlement_bill_id' => $actionableBillId,
'payer_type' => 'player',
'payer_id' => $player->id,
'payee_type' => 'agent',
'payee_id' => $agentId,
'amount' => 50,
'status' => 'confirmed',
'created_at' => now(),
'updated_at' => now(),
]);
$admin = AdminUser::query()->create([
'username' => 'ledger_actionable_super',
'name' => 'Ledger Actionable',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($admin);
$token = $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/credit-ledger?admin_site_id='.$siteId.'&settlement_period_id='.$periodId.'&actionable_only=1')
->assertOk()
->assertJsonPath('data.total', 1)
->assertJsonPath('data.items.0.entry_kind', 'payment')
->assertJsonPath('data.items.0.id', $actionablePaymentId);
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/credit-ledger?admin_site_id='.$siteId.'&settlement_period_id='.$periodId.'&actionable_only=1&per_page=1&page=2')
->assertOk()
->assertJsonPath('data.total', 1)
->assertJsonPath('data.items', []);
});
test('credit ledger entry_kind share returns share ledger rows', function (): void {
$site = DB::table('admin_sites')->where('is_default', true)->first();
$siteId = (int) $site->id;
@@ -301,11 +453,11 @@ test('credit ledger entry_kind share returns share ledger rows', function (): vo
function createShareLedgerTicketItem(Player $player): int
{
$draw = \App\Models\Draw::query()->create([
$draw = Draw::query()->create([
'draw_no' => 'DRAW-SHARE-LEDGER',
'business_date' => now()->toDateString(),
'sequence_no' => random_int(1, 9999),
'status' => \App\Lottery\DrawStatus::Open->value,
'status' => DrawStatus::Open->value,
'current_result_version' => 0,
'settle_version' => 0,
'is_reopened' => false,

View File

@@ -1,9 +1,9 @@
<?php
use App\Models\AdminUser;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
@@ -87,12 +87,30 @@ test('settlement payments and adjustments index return items', function (): void
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/settlement-payments?admin_site_id='.$siteId)
->assertOk()
->assertJsonPath('data.items.0.settlement_bill_id', $billId);
->assertJsonPath('data.items.0.settlement_bill_id', $billId)
->assertJsonPath('data.total', 1)
->assertJsonPath('data.page', 1);
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/settlement-adjustments?admin_site_id='.$siteId)
->assertOk()
->assertJsonPath('data.items.0.original_bill_id', $billId);
->assertJsonPath('data.items.0.original_bill_id', $billId)
->assertJsonPath('data.total', 1);
$operations = $this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/settlement-operations?admin_site_id='.$siteId.'&settlement_period_id='.$periodId)
->assertOk()
->assertJsonPath('data.total', 2)
->json('data.items');
expect(collect($operations)->pluck('kind')->sort()->values()->all())
->toBe(['adjustment', 'payment']);
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/settlement-operations?admin_site_id='.$siteId.'&settlement_period_id='.$periodId.'&operation_type=payment')
->assertOk()
->assertJsonPath('data.total', 1)
->assertJsonPath('data.items.0.kind', 'payment');
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/settlement-bills?admin_site_id='.$siteId.'&bill_type=player')

View File

@@ -1,15 +1,15 @@
<?php
use App\Models\AdminUser;
use App\Models\Player;
use App\Models\AdminUser;
use App\Models\PlayerWallet;
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\Hash;
use App\Services\Wallet\LotteryTransferService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Services\AgentSettlement\SettlementPaymentService;
use App\Services\AgentSettlement\AgentSettlementBadDebtService;
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);
});
test('bad debt write off idempotency key prevents duplicate records', function (): void {
$site = DB::table('admin_sites')->where('is_default', true)->first();
$agentId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id');
$periodId = (int) DB::table('settlement_periods')->insertGetId([
'admin_site_id' => (int) $site->id,
'period_start' => now()->subDays(7),
'period_end' => now(),
'status' => 'closed',
'created_at' => now(),
'updated_at' => now(),
]);
$player = Player::query()->create([
'site_code' => (string) $site->code,
'agent_node_id' => $agentId,
'site_player_id' => 'bd-idem-key',
'auth_source' => 'lottery_native',
'funding_mode' => 'credit',
'username' => 'bdidemkey',
'nickname' => null,
'default_currency' => 'NPR',
'status' => 0,
]);
$billId = (int) DB::table('settlement_bills')->insertGetId([
'settlement_period_id' => $periodId,
'bill_type' => 'player',
'owner_type' => 'player',
'owner_id' => $player->id,
'counterparty_type' => 'agent',
'counterparty_id' => $agentId,
'gross_win_loss' => 5000,
'rebate_amount' => 0,
'adjustment_amount' => 0,
'net_amount' => 5000,
'paid_amount' => 0,
'unpaid_amount' => 5000,
'status' => 'overdue',
'confirmed_at' => now(),
'locked_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
$admin = AdminUser::query()->create([
'username' => 'bad_debt_key_admin',
'name' => 'Bad Debt Key',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
$service = app(AgentSettlementBadDebtService::class);
$key = 'bd-idem-key-1';
$first = $service->writeOff($billId, 'uncollectible', (int) $admin->id, $key);
$second = $service->writeOff($billId, 'uncollectible', (int) $admin->id, $key);
expect($second)->toBe($first)
->and(DB::table('settlement_adjustments')->where('original_bill_id', $billId)->count())->toBe(1)
->and(DB::table('settlement_adjustments')->where('original_bill_id', $billId)->value('idempotency_key'))
->toBe($key);
});
test('out pending reconcile reverse still credits lottery wallet once', function (): void {
$player = Player::query()->create([
'site_code' => 'main',
@@ -258,4 +322,4 @@ test('out pending reconcile reverse still credits lottery wallet once', function
expect((int) $wallet->balance)->toBe(1_400)
->and($order->status)->toBe('reversed');
});
});