Files
lotteryLaravel/app/Services/AgentSettlement/SettlementOperationsListService.php
kang c83343e989
Some checks failed
lotterLaravel CI / test (push) Has been cancelled
lotterLaravel E2E / e2e-api (push) Has been cancelled
fix(settlement): 操作记录分页、流水动作与坏账幂等加固
- 新增 settlement-operations 合并列表 API,收付/调账接口改为标准分页
- actionable_only 流水在内存过滤后正确分页
- 已结账单流水不再展示补差/冲正快捷动作
- 坏账核销支持 idempotency_key 并写入 result_bill_id
- 补充操作记录、流水与坏账幂等相关测试
2026-06-26 16:40:46 +08:00

316 lines
11 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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,
];
}
}