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;
|
||||
|
||||
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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -15,6 +15,7 @@ final class AdminSettlementBillBadDebtRequest extends ApiFormRequest
|
||||
{
|
||||
return [
|
||||
'reason' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||
'idempotency_key' => ['sometimes', 'nullable', 'string', 'max:64'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 : [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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.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']],
|
||||
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user