- 新增 settlement-operations 合并列表 API,收付/调账接口改为标准分页 - actionable_only 流水在内存过滤后正确分页 - 已结账单流水不再展示补差/冲正快捷动作 - 坏账核销支持 idempotency_key 并写入 result_bill_id - 补充操作记录、流水与坏账幂等相关测试
316 lines
11 KiB
PHP
316 lines
11 KiB
PHP
<?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,
|
||
];
|
||
}
|
||
}
|