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

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