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

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