财务台账:新增账户异常待办工作台与汇总卡片优化 #434

Merged
shanshi merged 7 commits from feature/shanshi-ledger-summary-cards into main 2026-10-21 16:11:56 +08:00
7 changed files with 1080 additions and 7 deletions

View File

@ -9,6 +9,7 @@ use App\Utilities\FinanceLedgerAccess;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
use Modules\EcommerceFinance\Services\FinanceLedgerAccountTemplate;
@ -24,6 +25,252 @@ class FinanceLedger extends Controller
return $template->download();
}
public function unavailableBalanceTasks(Request $request)
{
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
$abnormalStatuses = array_values(array_diff(
array_keys(FinanceLedgerAccountStatus::OPTIONS),
[FinanceLedgerAccountStatus::NORMAL],
));
$handlingMethods = [
'online' => '线上',
'offline' => '线下',
];
$completionStatuses = [
'completed' => '已完成',
'in_progress' => '进行中',
'not_started' => '待开始',
'incomplete' => '未完成',
];
$filters = [
'pending_short' => trim((string) $request->query('pending_short', '')),
'pending_type' => trim((string) $request->query('pending_type', '')),
'pending_status' => trim((string) $request->query('pending_status', '')),
'pending_reason' => trim((string) $request->query('pending_reason', '')),
'pending_method' => trim((string) $request->query('pending_method', '')),
'pending_owner' => trim((string) $request->query('pending_owner', '')),
];
$query = $connection->table('fin_accounts as accounts')
->join('fin_account_statuses as account_status', 'account_status.account_short', '=', 'accounts.account_short')
->leftJoin('fin_account_type_overrides as type_override', 'type_override.account_short', '=', 'accounts.account_short')
->leftJoin('fin_account_todos as account_todo', 'account_todo.account_short', '=', 'accounts.account_short')
->whereIn('account_status.status', $abnormalStatuses);
if ($filters['pending_short'] !== '') {
$query->where('accounts.account_short', 'like', '%' . $filters['pending_short'] . '%');
}
if ($filters['pending_type'] !== '') {
$query->where(function ($effectiveType) use ($filters): void {
$effectiveType->where(function ($baseType) use ($filters): void {
$baseType->whereNull('type_override.id');
if ($filters['pending_type'] === '__empty__') {
$baseType->where(function ($blank): void {
$blank->whereNull('accounts.account_type')->orWhere('accounts.account_type', '');
});
} else {
$baseType->where('accounts.account_type', $filters['pending_type']);
}
})->orWhere(function ($overrideType) use ($filters): void {
$overrideType->whereNotNull('type_override.id');
if ($filters['pending_type'] === '__empty__') {
$overrideType->where(function ($blank): void {
$blank->whereNull('type_override.account_type')->orWhere('type_override.account_type', '');
});
} else {
$overrideType->where('type_override.account_type', $filters['pending_type']);
}
});
});
}
if ($filters['pending_status'] !== '' && array_key_exists($filters['pending_status'], FinanceLedgerAccountStatus::OPTIONS)) {
$query->where('account_status.status', $filters['pending_status']);
}
if ($filters['pending_reason'] !== '') {
$query->where('account_todo.abnormal_reason', 'like', '%' . $filters['pending_reason'] . '%');
}
if ($filters['pending_method'] !== '' && array_key_exists($filters['pending_method'], $handlingMethods)) {
$query->where('account_todo.handling_method', $filters['pending_method']);
}
if ($filters['pending_owner'] !== '') {
$query->where('account_todo.owner_name', 'like', '%' . $filters['pending_owner'] . '%');
}
$summaryQuery = clone $query;
$summary = [
'count' => (clone $query)->count(),
'balance' => round((float) (clone $summaryQuery)->sum('accounts.current_balance'), 2),
'blocked' => round((float) (clone $summaryQuery)->sum('accounts.blocked_amount'), 2),
'daily_available' => round((float) (clone $summaryQuery)->sum('accounts.daily_available'), 2),
];
$sortField = (string) $request->query('pending_sort', '');
$sortField = in_array($sortField, ['started_at', 'completed_at'], true) ? $sortField : '';
$sortDirection = strtolower((string) $request->query('pending_direction', 'asc'));
$sortDirection = $sortDirection === 'desc' ? 'desc' : 'asc';
$query->select([
'accounts.*',
'account_status.status as effective_account_status',
'type_override.id as type_override_id',
'type_override.account_type as effective_account_type',
'account_todo.abnormal_reason as todo_abnormal_reason',
'account_todo.handling_method as todo_handling_method',
'account_todo.owner_name as todo_owner_name',
'account_todo.started_at as todo_started_at',
'account_todo.completed_at as todo_completed_at',
'account_todo.current_progress as todo_current_progress',
'account_todo.completion_status as todo_completion_status',
]);
if ($sortField !== '') {
$query->orderBy('account_todo.' . $sortField, $sortDirection);
} else {
$query->orderByDesc('accounts.blocked_amount');
}
$rows = $query
->orderBy('accounts.company_name')
->orderBy('accounts.account_short')
->paginate(50)
->appends($filters)
->appends(array_filter([
'pending_sort' => $sortField,
'pending_direction' => $sortField !== '' ? $sortDirection : '',
]))
->withQueryString();
$rows->getCollection()->transform(static function ($row) {
$row->account_status = FinanceLedgerAccountStatus::normalize($row->effective_account_status ?? null);
if ($row->type_override_id !== null) {
$row->account_type = (string) ($row->effective_account_type ?? '');
}
$row->todo_completion_status = $row->todo_completion_status ?: 'not_started';
return $row;
});
$typeOptions = FinanceLedgerAccountType::options(
$connection->table('fin_accounts')
->whereNotNull('account_type')->where('account_type', '<>', '')
->distinct()->orderBy('account_type')->pluck('account_type')->all(),
$connection->table('fin_account_type_overrides')
->whereNotNull('account_type')->where('account_type', '<>', '')
->distinct()->orderBy('account_type')->pluck('account_type')->all(),
);
$reasonOptions = $connection->table('fin_account_todos')
->whereNotNull('abnormal_reason')->where('abnormal_reason', '<>', '')
->distinct()->orderBy('abnormal_reason')->pluck('abnormal_reason')->all();
$ownerOptions = $connection->table('fin_account_todos')
->whereNotNull('owner_name')->where('owner_name', '<>', '')
->distinct()->orderBy('owner_name')->pluck('owner_name')->all();
return view('business.pages.financial-ledger.unavailable-balance-tasks', [
'rows' => $rows,
'summary' => $summary,
'filters' => $filters,
'sortField' => $sortField,
'sortDirection' => $sortDirection,
'backUrl' => route('business.pages.show', [
'section' => 'financial-operations',
'page' => 'ledger',
]) . '?tab=accounts',
'canEdit' => FinanceLedgerAccess::canEditAccountStatus(user()),
'handlingMethods' => $handlingMethods,
'completionStatuses' => $completionStatuses,
'accountStatusOptions' => array_intersect_key(
FinanceLedgerAccountStatus::OPTIONS,
array_flip($abnormalStatuses),
),
'accountTypeOptions' => $typeOptions,
'reasonOptions' => $reasonOptions,
'ownerOptions' => $ownerOptions,
]);
}
public function updateAccountTodo(Request $request)
{
abort_unless(FinanceLedgerAccess::canEditAccountStatus(user()), 403, '只有财务台账管理员可以修改待办事项。');
$data = $request->validate([
'account_short' => ['required', 'string', 'max:100'],
'abnormal_reason' => ['nullable', 'string', 'max:1000'],
'handling_method' => ['nullable', 'string', Rule::in(['online', 'offline'])],
'owner_name' => ['nullable', 'string', 'max:100'],
'started_at' => ['nullable', 'date'],
'completed_at' => ['nullable', 'date'],
'current_progress' => ['nullable', 'string', 'max:100'],
'completion_status' => ['nullable', 'string', Rule::in(['completed', 'in_progress', 'not_started', 'incomplete'])],
]);
$accountShort = trim((string) $data['account_short']);
$connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
abort_unless(
$connection->table('fin_accounts')->where('account_short', $accountShort)->exists(),
422,
'账户简称不存在,请刷新待办清单后重试。',
);
$abnormalStatuses = array_values(array_diff(
array_keys(FinanceLedgerAccountStatus::OPTIONS),
[FinanceLedgerAccountStatus::NORMAL],
));
$status = $connection->table('fin_account_statuses')
->where('account_short', $accountShort)
->value('status');
abort_unless(
$status !== null && in_array(FinanceLedgerAccountStatus::normalize((string) $status), $abnormalStatuses, true),
422,
'账户当前已不是异常状态,无需继续填写待办。',
);
$now = now();
$completionStatus = (string) ($data['completion_status'] ?? 'not_started');
$values = [
'abnormal_reason' => $data['abnormal_reason'] ?? null,
'handling_method' => $data['handling_method'] ?? null,
'owner_name' => $data['owner_name'] ?? null,
'started_at' => filled($data['started_at'] ?? null)
? Carbon::parse($data['started_at'])->format('Y-m-d H:i:s')
: null,
'completed_at' => filled($data['completed_at'] ?? null)
? Carbon::parse($data['completed_at'])->format('Y-m-d H:i:s')
: null,
'current_progress' => $data['current_progress'] ?? null,
'completion_status' => $completionStatus,
'updated_by' => (string) user()->name,
'updated_at' => $now,
];
$connection->transaction(function () use ($connection, $accountShort, $values, $now, $completionStatus): void {
$todos = $connection->table('fin_account_todos');
$existing = $todos->where('account_short', $accountShort)->exists();
if ($existing) {
$todos->where('account_short', $accountShort)->update($values);
} else {
$todos->insert($values + [
'account_short' => $accountShort,
'created_at' => $now,
]);
}
if ($completionStatus === 'completed') {
$connection->table('fin_account_statuses')
->where('account_short', $accountShort)
->update([
'status' => FinanceLedgerAccountStatus::NORMAL,
'updated_by' => (string) user()->name,
'updated_at' => $now,
]);
}
});
return redirect()->back()->with(
'success',
$completionStatus === 'completed'
? '待办已完成,账户状态已自动设为正常'
: '待办事项已保存',
);
}
public function updateMapping(Request $request, int $id, FinanceLedgerCalculator $calculator): JsonResponse
{
abort_unless(FinanceLedgerAccess::canEditMapping(user()), 403, '只有财务台账管理员可以修改账户映射。');

View File

@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
$schema = Schema::connection($this->connection());
if (! $schema->hasTable('fin_accounts') || $schema->hasTable('fin_account_todos')) {
return;
}
$schema->create('fin_account_todos', function (Blueprint $table): void {
$table->bigIncrements('id');
$table->string('account_short', 100)->unique('fin_account_todos_account_unique');
$table->text('abnormal_reason')->nullable();
$table->string('handling_method', 20)->nullable();
$table->string('owner_name', 100)->nullable();
$table->dateTime('started_at')->nullable();
$table->dateTime('completed_at')->nullable();
$table->string('current_progress', 100)->nullable();
$table->string('updated_by', 100)->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::connection($this->connection())->dropIfExists('fin_account_todos');
}
};

View File

@ -0,0 +1,39 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
$schema = Schema::connection($this->connection());
if (! $schema->hasTable('fin_account_todos') || $schema->hasColumn('fin_account_todos', 'completion_status')) {
return;
}
$schema->table('fin_account_todos', function (Blueprint $table): void {
$table->string('completion_status', 20)->default('not_started')->after('current_progress');
$table->index(['completion_status'], 'fin_account_todos_completion_status_index');
});
}
public function down(): void
{
$schema = Schema::connection($this->connection());
if (! $schema->hasTable('fin_account_todos') || ! $schema->hasColumn('fin_account_todos', 'completion_status')) {
return;
}
$schema->table('fin_account_todos', function (Blueprint $table): void {
$table->dropIndex('fin_account_todos_completion_status_index');
$table->dropColumn('completion_status');
});
}
};

View File

@ -84,10 +84,15 @@
.ledger-mapping-bulk-actions { display:flex; flex-wrap:wrap; gap:.45rem; }
.ledger-mapping-bulk .ledger-btn { height:1.95rem; }
.ledger-download-status { flex-basis:100%; color:var(--vip-success, #53793E); font-size:.68rem; font-weight:600; line-height:1.4; text-align:right; }
.ledger-summary { display:flex; flex-wrap:wrap; gap:.6rem 1.5rem; padding:.7rem 1rem; margin-bottom:.85rem; border:1px solid var(--vip-border, #D8D2C7); border-radius:.65rem; background:var(--vip-content-accent-soft, #F7F0DE); }
.ledger-summary__item { display:flex; flex-direction:column; gap:.12rem; min-width:7rem; }
.ledger-summary__item span { color:var(--vip-content-muted, #595959); font-size:.68rem; }
.ledger-summary__item b { color:var(--vip-content-ink, #171717); font-size:.92rem; font-variant-numeric:tabular-nums; }
.ledger-summary { display:grid; grid-template-columns:repeat(4, minmax(0, 1fr)); gap:.65rem; margin-bottom:.85rem; }
.ledger-summary__item { display:flex; flex-direction:column; justify-content:center; gap:.2rem; min-width:0; min-height:3.4rem; padding:.6rem .8rem; border:1px solid var(--vip-border, #D8D2C7); border-radius:.55rem; background:var(--vip-content-accent-soft, #F7F0DE); box-shadow:0 1px 2px rgba(16,24,40,.04); }
.ledger-summary__item--available { background:#f3f7ee; }
.ledger-summary__item--blocked { background:#fff5f5; }
.ledger-summary__item--link { color:inherit; text-decoration:none; transition:border-color .15s ease, box-shadow .15s ease; }
.ledger-summary__item--link:hover { border-color:rgba(184,0,0,.42); box-shadow:0 2px 6px rgba(184,0,0,.08); }
.ledger-summary__item--link:focus-visible { outline:2px solid var(--vip-danger, #B80000); outline-offset:2px; }
.ledger-summary__item span { overflow:hidden; color:var(--vip-content-muted, #595959); font-size:.68rem; line-height:1.35; text-overflow:ellipsis; white-space:nowrap; }
.ledger-summary__item b { overflow:hidden; color:var(--vip-content-ink, #171717); font-size:.92rem; font-variant-numeric:tabular-nums; line-height:1.35; text-overflow:ellipsis; white-space:nowrap; }
.ledger-note { padding:.65rem .9rem; margin-bottom:.85rem; border:1px solid var(--vip-border, #D8D2C7); border-radius:.55rem; background:#fff; color:var(--vip-content-muted, #595959); font-size:.72rem; line-height:1.6; }
.ledger-table-wrap { overflow:auto; border:1px solid var(--vip-border, #D8D2C7); border-radius:.65rem; background:#fff; }
.ledger-table { width:100%; min-width:82rem; border-collapse:separate; border-spacing:0; font-size:.72rem; }
@ -165,7 +170,8 @@
.ledger-toast.is-error { border-color:var(--vip-danger, #B80000); color:var(--vip-danger, #B80000); }
.ledger-source-tag { display:inline-flex; align-items:center; padding:.1rem .4rem; border-radius:.25rem; background:#f4f1eb; color:var(--vip-content-muted, #595959); font-size:.68rem; }
.ledger-mono { font-variant-numeric:tabular-nums; }
@media (max-width: 900px) { .ledger-hero { align-items:flex-start; flex-direction:column; } .ledger-field { min-width:calc(50% - .5rem); } }
@media (max-width: 900px) { .ledger-hero { align-items:flex-start; flex-direction:column; } .ledger-field { min-width:calc(50% - .5rem); } .ledger-summary { grid-template-columns:repeat(2, minmax(0, 1fr)); } }
@media (max-width: 560px) { .ledger-summary { grid-template-columns:1fr; } }
</style>
<div class="ledger-hero">
@ -224,8 +230,8 @@
</form>
<div class="ledger-summary">
<div class="ledger-summary__item"><span>账户数</span><b>{{ number_format((int) ($ledgerAccountSummary['count'] ?? 0)) }}</b></div>
<div class="ledger-summary__item"><span>可用金额合计</span><b>{{ $money($ledgerAccountSummary['balance'] ?? 0) }}</b></div>
<div class="ledger-summary__item"><span>不可用金额合计</span><b>{{ $money($ledgerAccountSummary['blocked'] ?? 0) }}</b></div>
<div class="ledger-summary__item ledger-summary__item--available"><span>可用金额合计</span><b>{{ $money($ledgerAccountSummary['balance'] ?? 0) }}</b></div>
<a class="ledger-summary__item ledger-summary__item--blocked ledger-summary__item--link" href="{{ route('business.financial-ledger.unavailable-balances') }}" aria-label="查看账户异常待办"><span>不可用金额合计</span><b>{{ $money($ledgerAccountSummary['blocked'] ?? 0) }}</b></a>
<div class="ledger-summary__item"><span>每日可出金额口径</span><b>可用金额 − 不可用金额</b></div>
</div>
<div class="ledger-table-wrap ledger-accounts-table-wrap">

View File

@ -0,0 +1,525 @@
@php
$money = static fn ($value): string => $value === null || $value === '' ? '' : number_format((float) $value, 2);
@endphp
<x-layouts.admin>
<x-slot name="title">账户异常待办</x-slot>
<x-slot name="content">
<style>
.pending-balance-page { margin:1.5rem 1.5rem 2rem; color:var(--vip-content-ink, #171717); font-size:.78rem; }
.pending-balance-hero { display:flex; align-items:flex-start; justify-content:space-between; flex-wrap:wrap; gap:1rem; padding:1rem 1.15rem; margin-bottom:.9rem; border:1px solid var(--vip-border, #D8D2C7); border-radius:.75rem; background:#fff; }
.pending-balance-hero h2 { margin:0 0 .3rem; font-size:.95rem; font-weight:700; }
.pending-balance-hero p { margin:0; color:var(--vip-content-muted, #595959); line-height:1.55; }
.pending-balance-back { display:inline-flex; align-items:center; gap:.35rem; color:var(--vip-content-accent-dark, #856632); font-weight:600; text-decoration:none; }
.pending-balance-back:hover { color:#6A5129; }
.pending-balance-summary { display:grid; grid-template-columns:repeat(4, minmax(0, 1fr)); gap:.65rem; margin-bottom:.9rem; }
.pending-balance-card { min-width:0; padding:.6rem .8rem; border:1px solid var(--vip-border, #D8D2C7); border-radius:.55rem; background:var(--vip-content-accent-soft, #F7F0DE); box-shadow:0 1px 2px rgba(16,24,40,.04); }
.pending-balance-card.is-available { background:#f3f7ee; }
.pending-balance-card.is-blocked { background:#fff5f5; }
.pending-balance-card span { display:block; overflow:hidden; color:var(--vip-content-muted, #595959); font-size:.68rem; line-height:1.35; text-overflow:ellipsis; white-space:nowrap; }
.pending-balance-card b { display:block; margin-top:.2rem; overflow:hidden; font-size:.92rem; font-variant-numeric:tabular-nums; line-height:1.35; text-overflow:ellipsis; white-space:nowrap; }
.pending-balance-alert { display:flex; align-items:center; gap:.35rem; padding:.65rem .8rem; margin-bottom:.75rem; border:1px solid #bbf7d0; border-radius:.5rem; background:#f0fdf4; color:var(--vip-success, #53793E); font-size:.72rem; }
.pending-balance-alert.is-error { border-color:rgba(184,0,0,.24); background:#fff5f5; color:var(--vip-danger, #B80000); }
.pending-balance-toolbar { padding:.75rem 1rem; margin-bottom:.75rem; border:1px solid var(--vip-border, #D8D2C7); border-radius:.65rem; background:#fff; }
.pending-balance-filter-grid { display:grid; grid-template-columns:repeat(6, minmax(0, 1fr)); gap:.55rem; }
.pending-balance-field { display:flex; flex-direction:column; gap:.3rem; min-width:0; color:var(--vip-content-muted, #595959); font-size:.68rem; }
.pending-balance-field input,
.pending-balance-field select { width:100%; min-width:0; height:2.15rem; padding:0 .55rem; border:1px solid var(--vip-border, #D8D2C7); border-radius:.4rem; background:#fff; color:var(--vip-content-ink, #171717); font:inherit; }
.pending-balance-field input:focus,
.pending-balance-field select:focus { border-color:var(--vip-content-accent-dark, #856632); outline:none; box-shadow:0 0 0 3px rgba(133,102,50,.16); }
.pending-balance-toolbar-actions { display:flex; justify-content:flex-end; gap:.55rem; margin-top:.65rem; }
.pending-balance-button { display:inline-flex; align-items:center; justify-content:center; gap:.35rem; height:2.15rem; padding:0 .8rem; border:1px solid var(--vip-border, #D8D2C7); border-radius:.4rem; background:#fff; color:var(--vip-content-accent-dark, #856632); font:inherit; font-weight:600; text-decoration:none; cursor:pointer; }
.pending-balance-button.is-primary { border-color:var(--vip-content-accent-dark, #856632); background:var(--vip-content-accent-dark, #856632); color:#fff; }
.pending-balance-table-hint { margin:0 0 .35rem; color:var(--vip-content-muted, #595959); font-size:.66rem; text-align:right; }
.pending-balance-table-wrap { max-height:calc(100vh - 360px); min-height:20rem; overflow:auto; border:1px solid var(--vip-border, #D8D2C7); border-radius:.65rem; background:#fff; }
.pending-balance-table { width:100%; min-width:72rem; border-collapse:separate; border-spacing:0; font-size:.72rem; }
.pending-balance-table th,
.pending-balance-table td { padding:.32rem .52rem; border-right:1px solid #e8e3da; border-bottom:1px solid #e8e3da; line-height:1.4; text-align:left; vertical-align:middle; white-space:nowrap; }
.pending-balance-table tbody td { overflow:hidden; text-overflow:ellipsis; }
.pending-balance-table th { position:sticky; top:0; z-index:4; background:var(--vip-content-accent-soft, #F7F0DE); box-shadow:0 1px 0 var(--vip-border, #D8D2C7); font-weight:700; text-align:center; }
.pending-balance-table th:last-child,
.pending-balance-table td:last-child { border-right:0; }
.pending-balance-table tbody tr:last-child td { border-bottom:0; }
.pending-balance-table tbody tr:hover td { background:#fcfaf4; }
.pending-balance-table .num { font-variant-numeric:tabular-nums; text-align:right; }
.pending-balance-sort { display:inline-flex; align-items:center; justify-content:center; gap:.2rem; color:inherit; text-decoration:none; }
.pending-balance-sort:hover { color:var(--vip-content-accent-dark, #856632); }
.pending-balance-sort .material-icons-outlined { font-size:.9rem; }
.pending-balance-task-input { width:100%; min-width:0; height:1.75rem; padding:0 .42rem; border:1px solid var(--vip-border, #D8D2C7); border-radius:.35rem; background:#fff; color:var(--vip-content-ink, #171717); font:inherit; }
.pending-balance-task-input:focus { border-color:var(--vip-content-accent-dark, #856632); outline:none; box-shadow:0 0 0 3px rgba(133,102,50,.14); }
.pending-balance-task-input:disabled { background:#f6f4ef; color:var(--vip-content-muted, #595959); cursor:not-allowed; }
.pending-balance-progress { display:flex; align-items:center; gap:.3rem; min-width:0; }
.pending-balance-save { display:inline-grid; flex:0 0 auto; place-items:center; width:1.75rem; height:1.75rem; padding:0; border:1px solid var(--vip-content-accent-dark, #856632); border-radius:.35rem; background:var(--vip-content-accent-dark, #856632); color:#fff; cursor:pointer; }
.pending-balance-save:hover { border-color:#6A5129; background:#6A5129; }
.pending-balance-status { display:inline-flex; align-items:center; justify-content:center; min-height:1.45rem; padding:.08rem .35rem; border:1px solid transparent; border-radius:999px; font-size:.68rem; font-weight:700; }
.pending-column-resizer { position:absolute; top:0; right:0; z-index:6; width:.45rem; height:100%; cursor:col-resize; touch-action:none; }
.pending-column-resizer::after { content:""; position:absolute; top:20%; bottom:20%; left:50%; width:1px; background:transparent; transition:background .15s ease; }
.pending-column-resizer:hover::after,
.pending-column-resizer.is-active::after { background:var(--vip-content-accent-dark, #856632); }
.pending-column-resizer:focus { outline:none; }
.pending-column-resizer:focus-visible::after { width:2px; background:var(--vip-content-accent-dark, #856632); }
body.is-resizing-pending-column { cursor:col-resize; user-select:none; }
.pending-balance-status.is-normal { border-color:rgba(83,121,62,.3); background:#f3f7ee; color:var(--vip-success, #53793E); }
.pending-balance-status.is-online_banking_abnormal { border-color:rgba(184,0,0,.3); background:#fff5f5; color:var(--vip-danger, #B80000); }
.pending-balance-status.is-limited { border-color:rgba(147,95,7,.32); background:#fff8eb; color:var(--vip-warning, #935F07); }
.pending-balance-status.is-business_deregistered { border-color:rgba(89,89,89,.28); background:#f3f1ec; color:var(--vip-content-muted, #595959); }
.pending-balance-empty { padding:3rem 1rem; color:var(--vip-content-muted, #595959); text-align:center; }
.pending-balance-page.is-paginating .pending-balance-table-wrap { opacity:.58; pointer-events:none; }
.ledger-pagination { display:flex; align-items:center; justify-content:space-between; gap:.75rem; padding:.65rem .9rem; color:var(--vip-content-muted, #595959); font-size:.72rem; }
.ledger-pagination a { margin-left:.75rem; color:var(--vip-content-accent-dark, #856632); font-weight:600; text-decoration:none; }
.ledger-pagination__controls { display:flex; align-items:center; justify-content:flex-end; gap:.45rem; margin-left:auto; }
.ledger-page-box { display:inline-flex; align-items:center; gap:.1rem; height:1.7rem; }
.ledger-page-box__arrow { display:inline-grid; place-items:center; width:1.2rem; height:1.5rem; padding:0; border:0; background:transparent; color:var(--vip-content-accent-dark, #856632); font:inherit; text-decoration:none; }
.ledger-page-box__arrow:hover { color:#6A5129; }
.ledger-page-box__arrow:focus:not(:focus-visible) { outline:none; }
.ledger-page-box__arrow:focus-visible { outline:1px solid var(--vip-content-accent-dark, #856632); outline-offset:0; border-radius:2px; }
.ledger-page-box__triangle { width:0; height:0; border-top:5px solid transparent; border-bottom:5px solid transparent; }
.ledger-page-box__triangle.is-previous { border-right:8px solid currentColor; }
.ledger-page-box__triangle.is-next { border-left:8px solid currentColor; }
.ledger-page-box__arrow.is-disabled { color:#b9b3a9; cursor:not-allowed; }
.ledger-page-box__current { min-width:2.2rem; padding:0 .15rem; color:var(--vip-content-ink, #171717); font-size:.7rem; font-variant-numeric:tabular-nums; font-weight:700; text-align:center; white-space:nowrap; }
.pending-balance-page__status { color:var(--vip-content-muted, #595959); font-size:.68rem; }
@media (max-width:1100px) { .pending-balance-filter-grid { grid-template-columns:repeat(3, minmax(0, 1fr)); } }
@media (max-width:900px) { .pending-balance-summary { grid-template-columns:repeat(2, minmax(0, 1fr)); } }
@media (max-width:700px) { .pending-balance-filter-grid { grid-template-columns:repeat(2, minmax(0, 1fr)); } }
@media (max-width:560px) { .pending-balance-page { margin:1rem .75rem 1.5rem; } .pending-balance-summary, .pending-balance-filter-grid { grid-template-columns:1fr; } }
</style>
<section class="pending-balance-page" aria-labelledby="pending-balance-title">
<header class="pending-balance-hero">
<div>
<h2 id="pending-balance-title">账户异常待办</h2>
<p>账户状态为“网银异常”“限额”或“工商注销”时自动进入本清单;不与其他页面的待办事项合并。</p>
</div>
<a class="pending-balance-back" href="{{ $backUrl }}">
<span class="material-icons-outlined" aria-hidden="true">arrow_back</span>
返回账户总表
</a>
</header>
<div class="pending-balance-summary">
<div class="pending-balance-card"><span>待处理账户</span><b>{{ number_format((int) ($summary['count'] ?? 0)) }}</b></div>
<div class="pending-balance-card is-blocked"><span>不可用金额合计</span><b>{{ $money($summary['blocked'] ?? 0) }}</b></div>
<div class="pending-balance-card is-available"><span>可用金额合计</span><b>{{ $money($summary['balance'] ?? 0) }}</b></div>
<div class="pending-balance-card"><span>每日可出金额合计</span><b>{{ $money($summary['daily_available'] ?? 0) }}</b></div>
</div>
@if (session('success'))
<div class="pending-balance-alert" role="status"><span class="material-icons-outlined" aria-hidden="true">check_circle</span>{{ session('success') }}</div>
@endif
@if ($errors->any())
<div class="pending-balance-alert is-error" role="alert"><span class="material-icons-outlined" aria-hidden="true">error</span>{{ $errors->first() }}</div>
@endif
<form class="pending-balance-toolbar" method="GET" action="{{ route('business.financial-ledger.unavailable-balances') }}" aria-label="筛选账户异常待办">
@if ($sortField !== '')
<input type="hidden" name="pending_sort" value="{{ $sortField }}">
<input type="hidden" name="pending_direction" value="{{ $sortDirection }}">
@endif
<div class="pending-balance-filter-grid">
<label class="pending-balance-field">
<span>账户简称</span>
<input name="pending_short" value="{{ $filters['pending_short'] }}" placeholder="输入账户简称">
</label>
<label class="pending-balance-field">
<span>账户类型</span>
<select name="pending_type">
<option value="">全部类型</option>
@foreach ($accountTypeOptions as $accountType)
<option value="{{ $accountType }}" @selected($filters['pending_type'] === $accountType)>{{ $accountType }}</option>
@endforeach
<option value="__empty__" @selected($filters['pending_type'] === '__empty__')>未填写</option>
</select>
</label>
<label class="pending-balance-field">
<span>账户状态</span>
<select name="pending_status">
<option value="">全部状态</option>
@foreach ($accountStatusOptions as $statusCode => $statusLabel)
<option value="{{ $statusCode }}" @selected($filters['pending_status'] === $statusCode)>{{ $statusLabel }}</option>
@endforeach
</select>
</label>
<label class="pending-balance-field">
<span>异常原因</span>
<select name="pending_reason">
<option value="">全部原因</option>
@foreach ($reasonOptions as $reason)
<option value="{{ $reason }}" @selected($filters['pending_reason'] === $reason)>{{ $reason }}</option>
@endforeach
</select>
</label>
<label class="pending-balance-field">
<span>办理方式</span>
<select name="pending_method">
<option value="">全部方式</option>
@foreach ($handlingMethods as $methodValue => $methodLabel)
<option value="{{ $methodValue }}" @selected($filters['pending_method'] === $methodValue)>{{ $methodLabel }}</option>
@endforeach
</select>
</label>
<label class="pending-balance-field">
<span>责任人员</span>
<select name="pending_owner">
<option value="">全部人员</option>
@foreach ($ownerOptions as $owner)
<option value="{{ $owner }}" @selected($filters['pending_owner'] === $owner)>{{ $owner }}</option>
@endforeach
</select>
</label>
</div>
<div class="pending-balance-toolbar-actions">
<button class="pending-balance-button is-primary" type="submit">
<span class="material-icons-outlined" aria-hidden="true">search</span>
查询
</button>
<a class="pending-balance-button" href="{{ route('business.financial-ledger.unavailable-balances') }}">
<span class="material-icons-outlined" aria-hidden="true">refresh</span>
重置
</a>
</div>
</form>
@php
$sortQuery = request()->query();
unset($sortQuery['pending_page']);
$sortUrl = static fn (string $field): string => route(
'business.financial-ledger.unavailable-balances',
array_merge($sortQuery, [
'pending_sort' => $field,
'pending_direction' => $sortField === $field && $sortDirection === 'asc' ? 'desc' : 'asc',
]),
);
@endphp
<p class="pending-balance-table-hint">拖动表头右侧边界可调整列宽,双击边界恢复默认</p>
<div class="pending-balance-table-wrap">
<table class="pending-balance-table" data-pending-table data-column-storage-key="caiwu.financial-ledger.pending-columns.v1.{{ auth()->id() }}.{{ company_id() }}">
<thead>
<tr>
<th data-pending-column-key="company_name">公司名称</th>
<th data-pending-column-key="account_short">账户简称</th>
<th data-pending-column-key="account_type">账户类型</th>
<th data-pending-column-key="account_status">账户状态</th>
<th data-pending-column-key="account_no">账号</th>
<th data-pending-column-key="bank_name">开户行</th>
<th data-pending-column-key="bank_city_branch">开户城市/网点</th>
<th data-pending-column-key="abnormal_reason">异常原因</th>
<th data-pending-column-key="handling_method">办理方式</th>
<th data-pending-column-key="owner_name">责任人员</th>
<th data-pending-column-key="started_at" @if ($sortField === 'started_at') aria-sort="{{ $sortDirection === 'asc' ? 'ascending' : 'descending' }}" @endif>
<a class="pending-balance-sort" href="{{ $sortUrl('started_at') }}" title="按开始时间排序">
开始时间
<span class="material-icons-outlined" aria-hidden="true">{{ $sortField === 'started_at' ? ($sortDirection === 'asc' ? 'arrow_upward' : 'arrow_downward') : 'unfold_more' }}</span>
</a>
</th>
<th data-pending-column-key="completed_at" @if ($sortField === 'completed_at') aria-sort="{{ $sortDirection === 'asc' ? 'ascending' : 'descending' }}" @endif>
<a class="pending-balance-sort" href="{{ $sortUrl('completed_at') }}" title="按完成时间排序">
完成时间
<span class="material-icons-outlined" aria-hidden="true">{{ $sortField === 'completed_at' ? ($sortDirection === 'asc' ? 'arrow_upward' : 'arrow_downward') : 'unfold_more' }}</span>
</a>
</th>
<th data-pending-column-key="current_progress">当前进度</th>
<th data-pending-column-key="completion_status">是否完成</th>
</tr>
</thead>
<tbody data-pending-tbody>
@forelse ($rows as $row)
<tr data-pending-account="{{ $row->account_short }}">
<td>
<form id="pending-task-form-{{ $loop->index }}" method="POST" action="{{ route('business.financial-ledger.accounts.todo') }}">
@csrf
<input type="hidden" name="account_short" value="{{ $row->account_short }}">
</form>
{{ $row->company_name ?: '未填写' }}
</td>
<td>{{ $row->account_short ?: '未填写' }}</td>
<td>{{ $row->account_type ?: '未填写' }}</td>
<td><span class="pending-balance-status is-{{ $row->account_status }}">{{ \App\Utilities\FinanceLedgerAccountStatus::label($row->account_status) }}</span></td>
<td>{{ $row->account_no ?: '未填写' }}</td>
<td>{{ $row->bank_name ?: '未填写' }}</td>
<td>{{ $row->bank_city_branch ?: '未填写' }}</td>
<td><input class="pending-balance-task-input" form="pending-task-form-{{ $loop->index }}" name="abnormal_reason" value="{{ $row->todo_abnormal_reason }}" placeholder="填写异常原因" @disabled(! $canEdit)></td>
<td>
<select class="pending-balance-task-input is-compact" form="pending-task-form-{{ $loop->index }}" name="handling_method" aria-label="办理方式" @disabled(! $canEdit)>
<option value="">未选择</option>
@foreach ($handlingMethods as $methodValue => $methodLabel)
<option value="{{ $methodValue }}" @selected(($row->todo_handling_method ?? '') === $methodValue)>{{ $methodLabel }}</option>
@endforeach
</select>
</td>
<td><input class="pending-balance-task-input is-compact" form="pending-task-form-{{ $loop->index }}" name="owner_name" value="{{ $row->todo_owner_name }}" placeholder="填写责任人员" @disabled(! $canEdit)></td>
<td><input class="pending-balance-task-input is-compact" type="datetime-local" form="pending-task-form-{{ $loop->index }}" name="started_at" value="{{ $row->todo_started_at ? \Illuminate\Support\Carbon::parse($row->todo_started_at)->format('Y-m-d\TH:i') : '' }}" @disabled(! $canEdit)></td>
<td><input class="pending-balance-task-input is-compact" type="datetime-local" form="pending-task-form-{{ $loop->index }}" name="completed_at" value="{{ $row->todo_completed_at ? \Illuminate\Support\Carbon::parse($row->todo_completed_at)->format('Y-m-d\TH:i') : '' }}" @disabled(! $canEdit)></td>
<td>
<div class="pending-balance-progress">
<input class="pending-balance-task-input is-compact" form="pending-task-form-{{ $loop->index }}" name="current_progress" value="{{ $row->todo_current_progress }}" placeholder="填写当前进度" @disabled(! $canEdit)>
@if ($canEdit)
<button class="pending-balance-save" form="pending-task-form-{{ $loop->index }}" type="submit" title="保存待办" aria-label="保存 {{ $row->account_short }} 的待办">
<span class="material-icons-outlined" aria-hidden="true">save</span>
</button>
@endif
</div>
</td>
<td>
<select class="pending-balance-task-input is-compact" form="pending-task-form-{{ $loop->index }}" name="completion_status" aria-label="是否完成" @disabled(! $canEdit)>
@foreach ($completionStatuses as $completionValue => $completionLabel)
<option value="{{ $completionValue }}" @selected(($row->todo_completion_status ?? 'not_started') === $completionValue)>{{ $completionLabel }}</option>
@endforeach
</select>
</td>
</tr>
@empty
<tr><td colspan="14" class="pending-balance-empty">当前没有账户异常待办</td></tr>
@endforelse
</tbody>
</table>
</div>
@if ($rows->total() > 0)
<div class="ledger-pagination" data-pending-pagination>
<span>共 {{ number_format($rows->total()) }} 条 · 每页 {{ number_format($rows->perPage()) }} 条</span>
<div class="ledger-pagination__controls">
<span class="pending-balance-page__status" data-pending-page-status role="status" aria-live="polite" hidden></span>
<nav class="ledger-page-box" data-pending-page-box aria-label="账户异常待办分页">
@if ($rows->onFirstPage())
<span class="ledger-page-box__arrow is-disabled" aria-disabled="true" title="已经是第一页"><span class="ledger-page-box__triangle is-previous" aria-hidden="true"></span></span>
@else
<a class="ledger-page-box__arrow" href="{{ $rows->previousPageUrl() }}" aria-label="上一页" title="上一页"><span class="ledger-page-box__triangle is-previous" aria-hidden="true"></span></a>
@endif
<span class="ledger-page-box__current">{{ $rows->currentPage() }} / {{ $rows->lastPage() }}</span>
@if ($rows->hasMorePages())
<a class="ledger-page-box__arrow" href="{{ $rows->nextPageUrl() }}" aria-label="下一页" title="下一页"><span class="ledger-page-box__triangle is-next" aria-hidden="true"></span></a>
@else
<span class="ledger-page-box__arrow is-disabled" aria-disabled="true" title="已经是最后一页"><span class="ledger-page-box__triangle is-next" aria-hidden="true"></span></span>
@endif
</nav>
</div>
</div>
@endif
</section>
<script>
(() => {
const table = document.querySelector('[data-pending-table]');
if (!table) return;
const headers = [...table.querySelectorAll('thead th[data-pending-column-key]')];
if (headers.length === 0) return;
const storageKey = table.dataset.columnStorageKey || 'caiwu.financial-ledger.pending-columns';
const minimumWidth = 56;
const maximumWidth = 720;
const clampWidth = (value) => Math.min(maximumWidth, Math.max(minimumWidth, Math.round(Number(value) || 0)));
const defaults = new Map(headers.map((header) => [
header.dataset.pendingColumnKey,
Math.max(minimumWidth, Math.round(header.getBoundingClientRect().width * 10) / 10),
]));
const widths = new Map(defaults);
try {
const saved = JSON.parse(localStorage.getItem(storageKey) || 'null');
if (saved?.widths && typeof saved.widths === 'object') {
Object.entries(saved.widths).forEach(([key, value]) => {
if (widths.has(key)) widths.set(key, clampWidth(value));
});
}
} catch (_) {}
const group = document.createElement('colgroup');
group.dataset.pendingColumnGroup = 'true';
headers.forEach((header) => {
const column = document.createElement('col');
column.dataset.pendingColumnKey = header.dataset.pendingColumnKey;
group.appendChild(column);
});
table.insertBefore(group, table.firstChild);
const applyWidths = () => {
let totalWidth = 0;
headers.forEach((header) => {
const key = header.dataset.pendingColumnKey;
const width = clampWidth(widths.get(key) ?? defaults.get(key));
const column = group.querySelector('col[data-pending-column-key="' + key + '"]');
if (column) column.style.width = width + 'px';
const resizer = header.querySelector('[data-pending-column-resizer]');
resizer?.setAttribute('aria-valuenow', String(width));
totalWidth += width;
});
table.style.tableLayout = 'fixed';
table.style.width = totalWidth + 'px';
table.style.minWidth = totalWidth + 'px';
};
const persist = () => {
try {
localStorage.setItem(storageKey, JSON.stringify({
widths: Object.fromEntries(widths),
}));
} catch (_) {}
};
headers.forEach((header) => {
const key = header.dataset.pendingColumnKey;
const link = header.querySelector('.pending-balance-sort');
const label = (link?.firstChild?.textContent || header.textContent).trim() || '未命名字段';
const resizer = document.createElement('span');
resizer.className = 'pending-column-resizer';
resizer.dataset.pendingColumnResizer = 'true';
resizer.tabIndex = 0;
resizer.setAttribute('role', 'separator');
resizer.setAttribute('aria-orientation', 'vertical');
resizer.setAttribute('aria-label', '调整“' + label + '”列宽');
resizer.setAttribute('aria-valuemin', String(minimumWidth));
resizer.setAttribute('aria-valuemax', String(maximumWidth));
resizer.title = '拖动调整列宽,双击恢复默认';
const setWidth = (value, save = false) => {
widths.set(key, clampWidth(value));
applyWidths();
if (save) persist();
};
resizer.addEventListener('pointerdown', (event) => {
if (event.button !== 0) return;
event.preventDefault();
event.stopPropagation();
const startX = event.clientX;
const startWidth = header.getBoundingClientRect().width;
resizer.classList.add('is-active');
document.body.classList.add('is-resizing-pending-column');
const move = (moveEvent) => {
setWidth(startWidth + moveEvent.clientX - startX);
};
const finish = () => {
document.removeEventListener('pointermove', move);
document.removeEventListener('pointerup', finish);
document.removeEventListener('pointercancel', finish);
resizer.classList.remove('is-active');
document.body.classList.remove('is-resizing-pending-column');
persist();
};
document.addEventListener('pointermove', move);
document.addEventListener('pointerup', finish);
document.addEventListener('pointercancel', finish);
});
resizer.addEventListener('keydown', (event) => {
if (!['ArrowLeft', 'ArrowRight'].includes(event.key)) return;
event.preventDefault();
const width = Number(widths.get(key)) || header.getBoundingClientRect().width;
setWidth(width + (event.key === 'ArrowLeft' ? -1 : 1) * (event.shiftKey ? 24 : 8), true);
});
resizer.addEventListener('dblclick', (event) => {
event.preventDefault();
event.stopPropagation();
setWidth(defaults.get(key), true);
});
resizer.addEventListener('click', (event) => event.stopPropagation());
header.appendChild(resizer);
});
applyWidths();
})();
</script>
<script>
(() => {
const page = document.querySelector('.pending-balance-page');
if (!page) return;
let pendingRequest = null;
const tableWrap = () => page.querySelector('.pending-balance-table-wrap');
const setPageStatus = (message, isError = false, hideAfter = 0) => {
const status = page.querySelector('[data-pending-page-status]');
if (!status) return;
status.hidden = message === '';
status.textContent = message;
status.style.color = isError ? 'var(--vip-danger, #B80000)' : '';
if (hideAfter > 0) {
window.setTimeout(() => {
status.hidden = true;
status.textContent = '';
}, hideAfter);
}
};
const bindPageLinks = () => {
page.querySelectorAll('.ledger-page-box__arrow[href]').forEach((link) => {
if (link.dataset.ajaxBound === '1') return;
link.dataset.ajaxBound = '1';
link.addEventListener('click', async (event) => {
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) return;
event.preventDefault();
const currentBody = page.querySelector('[data-pending-tbody]');
const currentPagination = page.querySelector('[data-pending-pagination]');
const currentWrap = tableWrap();
if (!currentBody || !currentPagination || !currentWrap) {
window.location.assign(link.href);
return;
}
const scrollTop = currentWrap.scrollTop;
const scrollLeft = currentWrap.scrollLeft;
const targetPage = new URL(link.href, window.location.href).searchParams.get('page');
pendingRequest?.abort();
const request = new AbortController();
pendingRequest = request;
page.classList.add('is-paginating');
currentWrap.setAttribute('aria-busy', 'true');
setPageStatus(targetPage ? '正在加载第 ' + targetPage + ' 页…' : '正在加载…');
try {
const response = await fetch(link.href, {
credentials: 'same-origin',
headers: {
'Accept': 'text/html',
'X-Requested-With': 'XMLHttpRequest',
},
signal: request.signal,
});
if (!response.ok) throw new Error('分页加载失败(HTTP ' + response.status + ')');
const nextDocument = new DOMParser().parseFromString(await response.text(), 'text/html');
const nextBody = nextDocument.querySelector('[data-pending-tbody]');
const nextPagination = nextDocument.querySelector('[data-pending-pagination]');
if (!nextBody || !nextPagination) throw new Error('分页内容解析失败');
currentBody.replaceWith(document.importNode(nextBody, true));
currentPagination.replaceWith(document.importNode(nextPagination, true));
if (window.history?.replaceState) {
window.history.replaceState({ ...window.history.state, pendingBalanceAjax: true }, '', link.href);
}
const nextWrap = tableWrap();
if (nextWrap) {
nextWrap.scrollTop = scrollTop;
nextWrap.scrollLeft = scrollLeft;
nextWrap.removeAttribute('aria-busy');
}
page.classList.remove('is-paginating');
bindPageLinks();
setPageStatus(targetPage ? '已加载第 ' + targetPage + ' 页' : '下一页已加载', false, 1600);
} catch (error) {
if (pendingRequest === request) {
page.classList.remove('is-paginating');
currentWrap.removeAttribute('aria-busy');
if (error.name !== 'AbortError') {
setPageStatus(error.message || '分页加载失败', true);
}
}
} finally {
if (pendingRequest === request) pendingRequest = null;
}
});
});
};
bindPageLinks();
})();
</script>
</x-slot>
</x-layouts.admin>

View File

@ -697,6 +697,14 @@ Route::post('workspace/financial-ledger/mapping/payable/apply', [\App\Http\Contr
->middleware('business.page:financial-operations,ledger')
->name('business.financial-ledger.mapping.payable.apply');
Route::get('workspace/financial-operations/ledger/unavailable-balances', [\App\Http\Controllers\Business\FinanceLedger::class, 'unavailableBalanceTasks'])
->middleware('business.page:financial-operations,ledger')
->name('business.financial-ledger.unavailable-balances');
Route::post('workspace/financial-ledger/accounts/todo', [\App\Http\Controllers\Business\FinanceLedger::class, 'updateAccountTodo'])
->middleware('business.page:financial-operations,ledger')
->name('business.financial-ledger.accounts.todo');
Route::get('workspace/financial-ledger/{page?}/{tab?}', function (Request $request, ?string $page = null, ?string $tab = null) {
abort_unless($page === null || $page === 'ledger', 404);

View File

@ -35,6 +35,20 @@ class FinanceLedgerAccountStatusTest extends FeatureTestCase
]);
}
private function unavailableBalancesUrl(): string
{
return route('business.financial-ledger.unavailable-balances', [
'company_id' => $this->company->id,
]);
}
private function accountTodoUrl(): string
{
return route('business.financial-ledger.accounts.todo', [
'company_id' => $this->company->id,
]);
}
private function statusUrl(): string
{
return route('business.financial-ledger.accounts.status', [
@ -107,12 +121,202 @@ class FinanceLedgerAccountStatusTest extends FeatureTestCase
->assertSee('恢复默认')
->assertSee('可用金额合计')
->assertSee('不可用金额合计')
->assertSee('href="' . $this->unavailableBalancesUrl() . '"', false)
->assertSee('查看账户异常待办')
->assertSee('可用金额 − 不可用金额')
->assertSee('未填写')
->assertDontSee('data-account-filter-state', false)
->assertSee('value="normal" selected', false);
}
public function test_unavailable_balance_tasks_page_is_separate_and_only_lists_blocked_accounts(): void
{
$connection = DB::connection('business_mirror');
$accounts = [
[
'company_name' => '待办测试公司',
'account_short' => '待办测试账户',
'account_no' => '6222000000000099',
'account_type' => '公户',
'bank_name' => '待办测试银行',
'bank_city_branch' => '上海 / 待办测试支行',
'current_balance' => 100,
'blocked_amount' => 25.5,
'daily_available' => 74.5,
'owner' => '待办负责人',
'next_action' => '核实冻结原因',
'remark' => '独立待办备注',
],
[
'company_name' => '零金额测试公司',
'account_short' => '零金额测试账户',
'current_balance' => 100,
'blocked_amount' => 0,
'daily_available' => 100,
],
[
'company_name' => '正常状态测试公司',
'account_short' => '正常状态测试账户',
'current_balance' => 100,
'blocked_amount' => 50,
'daily_available' => 50,
],
[
'company_name' => '待办测试公司二',
'account_short' => '待办测试账户二',
'account_no' => '6222000000000100',
'account_type' => '个人卡',
'bank_name' => '待办测试银行二',
'bank_city_branch' => '杭州 / 待办测试支行',
'current_balance' => 100,
'blocked_amount' => 10,
'daily_available' => 90,
],
];
foreach ($accounts as $account) {
$connection->table('fin_accounts')->insert($account);
}
$connection->table('fin_account_statuses')->insert([
[
'account_short' => '待办测试账户',
'status' => 'limited',
'updated_by' => 'Test User',
],
[
'account_short' => '正常状态测试账户',
'status' => 'normal',
'updated_by' => 'Test User',
],
[
'account_short' => '待办测试账户二',
'status' => 'online_banking_abnormal',
'updated_by' => 'Test User',
],
]);
$connection->table('fin_account_type_overrides')->insert([
'account_short' => '待办测试账户',
'account_type' => '网商银行',
'updated_by' => 'Test User',
]);
$this->loginAs()->get($this->unavailableBalancesUrl())
->assertOk()
->assertSee('账户异常待办')
->assertSee('不与其他页面的待办事项合并')
->assertSee('待办测试公司')
->assertSee('data-pending-account="待办测试账户"', false)
->assertSee('不可用金额合计')
->assertSee('35.50')
->assertSee('限额')
->assertSee('网商银行')
->assertSee('6222000000000099')
->assertSee('待办测试银行')
->assertSee('上海 / 待办测试支行')
->assertSee('异常原因')
->assertSee('办理方式')
->assertSee('当前进度')
->assertSee('是否完成')
->assertSee('name="pending_short"', false)
->assertSee('name="pending_type"', false)
->assertSee('name="pending_status"', false)
->assertSee('name="pending_reason"', false)
->assertSee('name="pending_method"', false)
->assertSee('name="pending_owner"', false)
->assertSee('按开始时间排序')
->assertSee('按完成时间排序')
->assertDontSee('name="pending_q"', false)
->assertSee('data-pending-tbody', false)
->assertSee('data-pending-pagination', false)
->assertSee('data-pending-table', false)
->assertSee('拖动表头右侧边界可调整列宽')
->assertSee('ledger-page-box', false)
->assertSee('1 / 1', false)
->assertSee('data-pending-account="待办测试账户二"', false)
->assertDontSee('data-pending-account="正常状态测试账户"', false)
->assertDontSee('data-pending-account="零金额测试账户"', false);
$this->loginAs()->post($this->accountTodoUrl(), [
'account_short' => '待办测试账户',
'abnormal_reason' => '账户冻结',
'handling_method' => 'online',
'owner_name' => '待办负责人',
'started_at' => '2026-10-21T09:00',
'completed_at' => '2026-10-21T11:30',
'current_progress' => '处理中',
'completion_status' => 'in_progress',
])->assertRedirect();
$this->assertDatabaseHas('fin_account_todos', [
'account_short' => '待办测试账户',
'abnormal_reason' => '账户冻结',
'handling_method' => 'online',
'owner_name' => '待办负责人',
'current_progress' => '处理中',
'completion_status' => 'in_progress',
'updated_by' => 'Test User',
], 'business_mirror');
$connection->table('fin_account_todos')->insert([
'account_short' => '待办测试账户二',
'abnormal_reason' => '网银证书过期',
'handling_method' => 'offline',
'owner_name' => '其他负责人',
'started_at' => '2026-10-21 10:00:00',
'current_progress' => '待联系银行',
'completion_status' => 'not_started',
'updated_by' => 'Test User',
]);
$this->loginAs()->get($this->unavailableBalancesUrl())
->assertOk()
->assertSee('账户冻结')
->assertSee('待办负责人')
->assertSee('处理中')
->assertSee('进行中');
$filteredUrl = $this->unavailableBalancesUrl() . '?' . http_build_query([
'pending_short' => '待办测试账户',
'pending_type' => '网商银行',
'pending_status' => 'limited',
'pending_reason' => '账户冻结',
'pending_method' => 'online',
'pending_owner' => '待办负责人',
]);
$this->loginAs()->get($filteredUrl)
->assertOk()
->assertSee('data-pending-account="待办测试账户"', false)
->assertDontSee('data-pending-account="待办测试账户二"', false);
$sortResponse = $this->loginAs()->get(
$this->unavailableBalancesUrl() . '?pending_sort=started_at&pending_direction=desc'
)->assertOk()->assertSee('aria-sort="descending"', false);
preg_match_all('/data-pending-account="([^"]+)"/u', $sortResponse->getContent(), $sortedAccounts);
$this->assertSame(['待办测试账户二', '待办测试账户'], array_values(array_intersect(
$sortedAccounts[1],
['待办测试账户', '待办测试账户二'],
)));
$this->loginAs()->post($this->accountTodoUrl(), [
'account_short' => '待办测试账户',
'abnormal_reason' => '账户冻结',
'handling_method' => 'online',
'owner_name' => '待办负责人',
'started_at' => '2026-10-21T09:00',
'completed_at' => '2026-10-21T12:00',
'current_progress' => '已处理',
'completion_status' => 'completed',
])->assertRedirect();
$this->assertDatabaseHas('fin_account_statuses', [
'account_short' => '待办测试账户',
'status' => 'normal',
], 'business_mirror');
$this->loginAs()->get($this->unavailableBalancesUrl())
->assertOk()
->assertDontSee('data-pending-account="待办测试账户"', false);
}
public function test_financial_ledger_tab_forms_submit_to_query_tab_routes(): void
{
$pageUrl = $this->pageUrl();
@ -406,7 +610,12 @@ class FinanceLedgerAccountStatusTest extends FeatureTestCase
'account_short' => '测试账户',
'account_should_keep' => '否',
])->assertForbidden();
$this->withExceptionHandling()->loginAs($viewer)->post($this->accountTodoUrl(), [
'account_short' => '测试账户',
'current_progress' => '处理中',
])->assertForbidden();
$this->assertDatabaseCount('fin_account_statuses', 0, 'business_mirror');
$this->assertDatabaseCount('fin_account_todos', 0, 'business_mirror');
}
}