From 872cec7236f6d631e8963c437f4c52b742978752 Mon Sep 17 00:00:00 2001 From: shanshi <3316401569@qq.com> Date: Tue, 20 Oct 2026 11:17:36 +0800 Subject: [PATCH 01/24] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E8=B4=A2?= =?UTF-8?q?=E5=8A=A1=E5=8F=B0=E8=B4=A6=E8=B4=A6=E6=88=B7=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E7=BB=B4=E6=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/Business/FinanceLedger.php | 50 +++++++- app/Utilities/FinanceLedgerAccess.php | 8 ++ app/Utilities/FinanceLedgerAccountStatus.php | 30 +++++ ...create_finance_ledger_account_statuses.php | 41 ++++++ docs/财务台账开发说明.md | 9 +- .../View/FinanceLedgerPageData.php | 18 ++- .../pages/financial-ledger/ledger.blade.php | 78 ++++++++++- routes/admin.php | 4 + .../FinanceLedgerAccountStatusTest.php | 121 ++++++++++++++++++ 9 files changed, 352 insertions(+), 7 deletions(-) create mode 100644 app/Utilities/FinanceLedgerAccountStatus.php create mode 100644 database/migrations/2026_10_20_120000_create_finance_ledger_account_statuses.php create mode 100644 tests/Feature/Business/FinanceLedgerAccountStatusTest.php diff --git a/app/Http/Controllers/Business/FinanceLedger.php b/app/Http/Controllers/Business/FinanceLedger.php index bc74fd42..854ffd86 100644 --- a/app/Http/Controllers/Business/FinanceLedger.php +++ b/app/Http/Controllers/Business/FinanceLedger.php @@ -2,11 +2,13 @@ namespace App\Http\Controllers\Business; -use Illuminate\Routing\Controller; +use App\Utilities\FinanceLedgerAccountStatus; use App\Utilities\FinanceLedgerAccess; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Routing\Controller; use Illuminate\Support\Facades\DB; +use Illuminate\Validation\Rule; use Modules\EcommerceFinance\Services\FinanceLedgerCalculator; class FinanceLedger extends Controller @@ -59,4 +61,50 @@ class FinanceLedger extends Controller 'total' => $connection->table('fin_account_map')->count(), ], 200, ['Cache-Control' => 'private, no-store']); } + + public function updateAccountStatus(Request $request): JsonResponse + { + abort_unless(FinanceLedgerAccess::canEditAccountStatus(user()), 403, '只有财务台账管理员可以修改账户状态。'); + + $data = $request->validate([ + 'account_short' => ['required', 'string', 'max:100'], + 'status' => ['required', 'string', Rule::in(array_keys(FinanceLedgerAccountStatus::OPTIONS))], + ]); + + $accountShort = trim((string) $data['account_short']); + abort_if($accountShort === '', 422, '账户简称不能为空。'); + + $connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')); + abort_unless( + $connection->table('fin_accounts')->where('account_short', $accountShort)->exists(), + 422, + '账户简称不存在,请刷新账户总表后重试。', + ); + + $status = FinanceLedgerAccountStatus::normalize((string) $data['status']); + $statuses = $connection->table('fin_account_statuses'); + $existing = $statuses->where('account_short', $accountShort)->first(); + $now = now(); + $values = [ + 'status' => $status, + 'updated_by' => (string) user()->name, + 'updated_at' => $now, + ]; + + if ($existing === null) { + $statuses->insert($values + [ + 'account_short' => $accountShort, + 'created_at' => $now, + ]); + } else { + $statuses->where('account_short', $accountShort)->update($values); + } + + return response()->json([ + 'ok' => true, + 'account_short' => $accountShort, + 'status' => $status, + 'label' => FinanceLedgerAccountStatus::label($status), + ], 200, ['Cache-Control' => 'private, no-store']); + } } diff --git a/app/Utilities/FinanceLedgerAccess.php b/app/Utilities/FinanceLedgerAccess.php index 309fbbcf..9fb40a73 100644 --- a/app/Utilities/FinanceLedgerAccess.php +++ b/app/Utilities/FinanceLedgerAccess.php @@ -28,4 +28,12 @@ class FinanceLedgerAccess return in_array(SystemAccess::roleName($user), $roles, true); } + + /** + * 账户状态与账户映射使用同一组财务台账编辑角色。 + */ + public static function canEditAccountStatus(?User $user = null): bool + { + return self::canEditMapping($user); + } } diff --git a/app/Utilities/FinanceLedgerAccountStatus.php b/app/Utilities/FinanceLedgerAccountStatus.php new file mode 100644 index 00000000..1fd25926 --- /dev/null +++ b/app/Utilities/FinanceLedgerAccountStatus.php @@ -0,0 +1,30 @@ + '正常', + self::ONLINE_BANKING_ABNORMAL => '网银异常', + self::LIMITED => '限额', + self::BUSINESS_DEREGISTERED => '工商注销', + ]; + + public static function normalize(?string $status): string + { + $status = trim((string) $status); + + return array_key_exists($status, self::OPTIONS) ? $status : self::NORMAL; + } + + public static function label(?string $status): string + { + return self::OPTIONS[self::normalize($status)]; + } +} diff --git a/database/migrations/2026_10_20_120000_create_finance_ledger_account_statuses.php b/database/migrations/2026_10_20_120000_create_finance_ledger_account_statuses.php new file mode 100644 index 00000000..8b468a11 --- /dev/null +++ b/database/migrations/2026_10_20_120000_create_finance_ledger_account_statuses.php @@ -0,0 +1,41 @@ +connection()); + + if (! $schema->hasTable('fin_account_statuses')) { + $schema->create('fin_account_statuses', function (Blueprint $table): void { + $table->bigIncrements('id'); + $table->string('account_short', 100)->unique('fin_account_statuses_account_unique'); + $table->string('status', 40)->default('normal'); + $table->string('updated_by', 100)->nullable(); + $table->timestamps(); + + $table->index(['status'], 'fin_account_statuses_status_index'); + }); + } + } + + public function down(): void + { + Schema::connection($this->connection())->dropIfExists('fin_account_statuses'); + } +}; diff --git a/docs/财务台账开发说明.md b/docs/财务台账开发说明.md index 0821a212..05f5d291 100644 --- a/docs/财务台账开发说明.md +++ b/docs/财务台账开发说明.md @@ -16,8 +16,10 @@ | `fin_daily_flows` | 每日资金流水 | | `fin_account_map` | 来源值 → 账户简称 | | `fin_account_map_events` | 映射修改审计 | +| `fin_account_statuses` | 账户状态维护(正常、网银异常、限额、工商注销) | -迁移:`database/migrations/2026_10_19_160000_create_finance_ledger_tables.php`。 +迁移:`database/migrations/2026_10_19_160000_create_finance_ledger_tables.php`、 +`database/migrations/2026_10_20_120000_create_finance_ledger_account_statuses.php`。 ## 本地导入 @@ -59,9 +61,12 @@ docker compose -f docker-compose.local.yml exec -u www-data app \ - 路由:`/workspace/financial-ledger/ledger` - 页面:账户总表、店铺台账、每日资金流水、动账明细、账户映射、数据来源 -- 写权限:账户映射修改仅系统管理员;其他账号只读 +- 写权限:账户映射和账户状态修改仅系统管理员及配置的财务台账管理员角色;其他账号只读 - 调度:`finance-ledger:sync-rpt` 每天 02:30(Asia/Shanghai) +账户状态独立保存在 `fin_account_statuses`,重新导入账户基础数据不会覆盖; +页面修改使用异步保存,不触发整页刷新。 + 生产宿主机 `/etc/cron.d` 仍需增加每日调用入口;本项目当前没有运行 `schedule:run` 的容器,调度登记不能替代宿主机 cron。 diff --git a/modules/EcommerceFinance/View/FinanceLedgerPageData.php b/modules/EcommerceFinance/View/FinanceLedgerPageData.php index 4b014023..3d4277ee 100644 --- a/modules/EcommerceFinance/View/FinanceLedgerPageData.php +++ b/modules/EcommerceFinance/View/FinanceLedgerPageData.php @@ -2,6 +2,8 @@ namespace Modules\EcommerceFinance\View; +use App\Utilities\FinanceLedgerAccountStatus; +use App\Utilities\FinanceLedgerAccess; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Query\Builder; use Illuminate\Support\Facades\DB; @@ -51,7 +53,9 @@ class FinanceLedgerPageData 'ledgerSourceSummary' => $this->sourceSummary(), 'ledgerMeta' => $this->meta(), 'ledgerAccountOptions' => $this->accountOptions(), - 'ledgerCanEditMapping' => \App\Utilities\FinanceLedgerAccess::canEditMapping(user()), + 'ledgerAccountStatusOptions' => FinanceLedgerAccountStatus::OPTIONS, + 'ledgerCanEditMapping' => FinanceLedgerAccess::canEditMapping(user()), + 'ledgerCanEditAccountStatus' => FinanceLedgerAccess::canEditAccountStatus(user()), ]; } @@ -80,6 +84,18 @@ class FinanceLedgerPageData ->paginate(100, ['*'], 'accounts_page') ->withQueryString(); + $accountShorts = $rows->pluck('account_short')->filter()->unique()->values()->all(); + $statuses = $accountShorts === [] + ? collect() + : $this->db()->table('fin_account_statuses') + ->whereIn('account_short', $accountShorts) + ->pluck('status', 'account_short'); + $rows->getCollection()->transform(static function ($row) use ($statuses) { + $row->account_status = FinanceLedgerAccountStatus::normalize($statuses[$row->account_short] ?? null); + + return $row; + }); + return ['rows' => $rows, 'summary' => $summary]; } diff --git a/resources/views/business/pages/financial-ledger/ledger.blade.php b/resources/views/business/pages/financial-ledger/ledger.blade.php index a1c52686..a45cfa8a 100644 --- a/resources/views/business/pages/financial-ledger/ledger.blade.php +++ b/resources/views/business/pages/financial-ledger/ledger.blade.php @@ -3,6 +3,8 @@ $tabs = $ledgerTabs ?? []; $activeTab = $ledgerTab ?? 'accounts'; $canEdit = (bool) ($ledgerCanEditMapping ?? false); + $canEditAccountStatus = (bool) ($ledgerCanEditAccountStatus ?? false); + $accountStatusOptions = $ledgerAccountStatusOptions ?? \App\Utilities\FinanceLedgerAccountStatus::OPTIONS; $money = static fn ($value): string => $value === null || $value === '' ? '' : number_format((float) $value, 2); $date = static fn ($value): string => $value ? \Illuminate\Support\Carbon::parse($value)->format('Y-m-d') : ''; $tabUrl = static fn (string $tab): string => route('business.pages.show', ['section' => 'financial-ledger', 'page' => 'ledger', 'tab' => $tab]); @@ -64,6 +66,19 @@ .ledger-map-input:focus { border-color:var(--vip-content-accent-dark, #856632); outline:none; box-shadow:0 0 0 3px rgba(133,102,50,.16); } .ledger-map-input.is-saving { border-color:var(--vip-content-accent, #B9954F); background:#fffdf6; } .ledger-map-input.is-error { border-color:var(--vip-danger, #B80000); background:#fff5f5; } + .ledger-status-select { width:7.4rem; height:2rem; padding:0 .45rem; border:1px solid var(--vip-border, #D8D2C7); border-radius:.4rem; background:#fff; color:var(--vip-content-ink, #171717); font:inherit; font-weight:600; cursor:pointer; } + .ledger-status-select:focus { border-color:var(--vip-content-accent-dark, #856632); outline:none; box-shadow:0 0 0 3px rgba(133,102,50,.16); } + .ledger-status-select[data-status="normal"] { border-color:rgba(83,121,62,.38); background:#f3f7ee; color:var(--vip-success, #53793E); } + .ledger-status-select[data-status="online_banking_abnormal"] { border-color:rgba(184,0,0,.34); background:#fff5f5; color:var(--vip-danger, #B80000); } + .ledger-status-select[data-status="limited"] { border-color:rgba(147,95,7,.36); background:#fff8eb; color:var(--vip-warning, #935F07); } + .ledger-status-select[data-status="business_deregistered"] { border-color:rgba(89,89,89,.32); background:#f3f1ec; color:var(--vip-content-muted, #595959); } + .ledger-status-select.is-saving { opacity:.62; cursor:wait; } + .ledger-status-select.is-error { border-color:var(--vip-danger, #B80000); box-shadow:0 0 0 3px rgba(184,0,0,.12); } + .ledger-status-badge { display:inline-flex; align-items:center; min-height:1.65rem; padding:.1rem .48rem; border:1px solid transparent; border-radius:999px; font-size:.68rem; font-weight:700; } + .ledger-status-badge[data-status="normal"] { border-color:rgba(83,121,62,.28); background:#f3f7ee; color:var(--vip-success, #53793E); } + .ledger-status-badge[data-status="online_banking_abnormal"] { border-color:rgba(184,0,0,.24); background:#fff5f5; color:var(--vip-danger, #B80000); } + .ledger-status-badge[data-status="limited"] { border-color:rgba(147,95,7,.26); background:#fff8eb; color:var(--vip-warning, #935F07); } + .ledger-status-badge[data-status="business_deregistered"] { border-color:rgba(89,89,89,.24); background:#f3f1ec; color:var(--vip-content-muted, #595959); } .ledger-toast { position:fixed; right:1.5rem; bottom:1.5rem; z-index:80; max-width:24rem; padding:.7rem 1rem; border:1px solid var(--vip-border, #D8D2C7); border-radius:.5rem; background:#fff; box-shadow:0 4px 12px rgba(23,23,23,.15); font-size:.78rem; display:none; } .ledger-toast.is-visible { display:block; } .ledger-toast.is-error { border-color:var(--vip-danger, #B80000); color:var(--vip-danger, #B80000); } @@ -113,14 +128,27 @@
- + @forelse ($ledgerAccounts as $row) - + + + + @empty - + @endforelse
公司名称账户简称地区账号开户行开户城市/网点账户类型公司规划后4位单笔限额日限额月限额年限额当前余额卡住金额每日可出预计出清天数提额目标提额状态提额动作已做动作下一步备注责任人
公司名称账户简称账户状态地区账号开户行开户城市/网点账户类型公司规划后4位单笔限额日限额月限额年限额当前余额卡住金额每日可出预计出清天数提额目标提额状态提额动作已做动作下一步备注责任人
{{ $row->company_name }}{{ $row->account_short }}{{ $row->region }}{{ $row->account_no }}{{ $row->bank_name }}{{ $row->bank_city_branch }}{{ $row->account_type }}{{ $row->company_plan }}{{ $row->account_last4 }}{{ $row->single_limit }}{{ $row->daily_limit }}{{ $row->monthly_limit }}{{ $row->yearly_limit }}{{ $money($row->current_balance) }}{{ $money($row->blocked_amount) }}{{ $money($row->daily_available) }}{{ $row->clearing_days !== null ? number_format((float) $row->clearing_days, 2) : '' }}{{ $row->raise_target }}{{ $row->raise_status }}{{ $row->raise_actions }}{{ $row->done_actions }}{{ $row->next_action }}{{ $row->remark }}{{ $row->owner }}{{ $row->company_name }}{{ $row->account_short }} + @if ($canEditAccountStatus) + + @else + {{ $accountStatusOptions[$row->account_status] ?? '正常' }} + @endif + {{ $row->region }}{{ $row->account_no }}{{ $row->bank_name }}{{ $row->bank_city_branch }}{{ $row->account_type }}{{ $row->company_plan }}{{ $row->account_last4 }}{{ $row->single_limit }}{{ $row->daily_limit }}{{ $row->monthly_limit }}{{ $row->yearly_limit }}{{ $money($row->current_balance) }}{{ $money($row->blocked_amount) }}{{ $money($row->daily_available) }}{{ $row->clearing_days !== null ? number_format((float) $row->clearing_days, 2) : '' }}{{ $row->raise_target }}{{ $row->raise_status }}{{ $row->raise_actions }}{{ $row->done_actions }}{{ $row->next_action }}{{ $row->remark }}{{ $row->owner }}
没有匹配的账户数据。
没有匹配的账户数据。
@@ -310,5 +338,49 @@ } }); }); + + const statusSelects = Array.from(page.querySelectorAll('[data-account-status]')); + statusSelects.forEach((select) => { + select.dataset.previousStatus = select.value; + select.addEventListener('change', async () => { + const value = select.value; + const previous = select.dataset.previousStatus || value; + const related = statusSelects.filter((item) => item.dataset.accountShort === select.dataset.accountShort); + related.forEach((item) => { + item.classList.add('is-saving'); + item.classList.remove('is-error'); + }); + try { + const response = await fetch(select.dataset.url, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '', + }, + body: JSON.stringify({ + account_short: select.dataset.accountShort, + status: value, + }), + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.message || '保存失败'); + related.forEach((item) => { + item.value = value; + item.dataset.status = value; + item.dataset.previousStatus = value; + }); + notify('账户状态已保存:' + (payload.label || '')); + } catch (error) { + select.value = previous; + select.dataset.status = previous; + select.classList.add('is-error'); + notify(error.message || '账户状态保存失败', true); + } finally { + related.forEach((item) => item.classList.remove('is-saving')); + } + }); + }); })(); diff --git a/routes/admin.php b/routes/admin.php index 01f48839..35f76508 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -637,6 +637,10 @@ Route::post('workspace/financial-ledger/mapping/{id}', [\App\Http\Controllers\Bu ->middleware('business.page:financial-ledger,ledger') ->name('business.financial-ledger.mapping.update'); +Route::post('workspace/financial-ledger/accounts/status', [\App\Http\Controllers\Business\FinanceLedger::class, 'updateAccountStatus']) + ->middleware('business.page:financial-ledger,ledger') + ->name('business.financial-ledger.accounts.status'); + Route::get('workspace/{section}/{page?}/{tab?}', 'Business\Pages@show') ->name('business.pages.show'); diff --git a/tests/Feature/Business/FinanceLedgerAccountStatusTest.php b/tests/Feature/Business/FinanceLedgerAccountStatusTest.php new file mode 100644 index 00000000..b7fb75bf --- /dev/null +++ b/tests/Feature/Business/FinanceLedgerAccountStatusTest.php @@ -0,0 +1,121 @@ +user->attachPermission( + Permission::where('name', 'read-admin-panel')->firstOrFail() + ); + + DB::connection('business_mirror')->table('fin_accounts')->insert([ + 'company_name' => '测试财务公司', + 'account_short' => '测试账户', + 'account_no' => '6222000000000001', + 'bank_name' => '测试银行', + ]); + } + + private function pageUrl(): string + { + return route('business.pages.show', [ + 'company_id' => $this->company->id, + 'section' => 'financial-ledger', + 'page' => 'ledger', + ]); + } + + private function statusUrl(): string + { + return route('business.financial-ledger.accounts.status', [ + 'company_id' => $this->company->id, + ]); + } + + public function test_account_table_renders_status_selector_with_four_options(): void + { + $this->loginAs()->get($this->pageUrl()) + ->assertOk() + ->assertSee('账户状态') + ->assertSee('data-account-status', false) + ->assertSee('正常') + ->assertSee('网银异常') + ->assertSee('限额') + ->assertSee('工商注销') + ->assertSee('value="normal" selected', false); + } + + public function test_account_status_can_be_saved_and_updated_without_duplicate_rows(): void + { + $payload = [ + 'account_short' => '测试账户', + 'status' => 'limited', + ]; + + $this->loginAs()->postJson($this->statusUrl(), $payload) + ->assertOk() + ->assertJsonPath('status', 'limited') + ->assertJsonPath('label', '限额'); + + $this->loginAs()->postJson($this->statusUrl(), [ + 'account_short' => '测试账户', + 'status' => 'normal', + ])->assertOk()->assertJsonPath('label', '正常'); + + $this->assertDatabaseCount('fin_account_statuses', 1, 'business_mirror'); + $this->assertDatabaseHas('fin_account_statuses', [ + 'account_short' => '测试账户', + 'status' => 'normal', + 'updated_by' => 'Test User', + ], 'business_mirror'); + + $this->loginAs()->get($this->pageUrl()) + ->assertOk() + ->assertSee('value="normal" selected', false); + } + + public function test_invalid_status_or_account_is_rejected(): void + { + $this->withExceptionHandling()->loginAs()->postJson($this->statusUrl(), [ + 'account_short' => '测试账户', + 'status' => 'unknown', + ])->assertUnprocessable()->assertJsonValidationErrors('status'); + + $this->withExceptionHandling()->loginAs()->postJson($this->statusUrl(), [ + 'account_short' => '不存在的账户', + 'status' => 'limited', + ])->assertUnprocessable()->assertJsonPath('message', '账户简称不存在,请刷新账户总表后重试。'); + + $this->assertDatabaseCount('fin_account_statuses', 0, 'business_mirror'); + } + + public function test_user_without_finance_editor_role_cannot_change_account_status(): void + { + $viewer = user_model_class()::create([ + 'name' => '只读测试用户', + 'email' => 'ledger-viewer-' . uniqid() . '@example.com', + 'password' => 'local-test-password', + 'locale' => 'zh-CN', + 'enabled' => 1, + 'landing_page' => 'dashboard', + 'created_from' => 'core::test', + ]); + $viewer->companies()->attach($this->company->id); + $viewer->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); + + $this->withExceptionHandling()->loginAs($viewer)->postJson($this->statusUrl(), [ + 'account_short' => '测试账户', + 'status' => 'limited', + ])->assertForbidden(); + + $this->assertDatabaseCount('fin_account_statuses', 0, 'business_mirror'); + } +} -- 2.47.2 From 6999ece6fdc19e88933ac1a1f091bd73fbb479d4 Mon Sep 17 00:00:00 2001 From: shanshi <3316401569@qq.com> Date: Tue, 20 Oct 2026 11:24:36 +0800 Subject: [PATCH 02/24] =?UTF-8?q?style:=20=E6=94=B6=E7=AA=84=E5=B9=B6?= =?UTF-8?q?=E5=B1=85=E4=B8=AD=E8=B4=A2=E5=8A=A1=E5=8F=B0=E8=B4=A6=E8=B4=A6?= =?UTF-8?q?=E6=88=B7=E7=8A=B6=E6=80=81=E5=88=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../business/pages/financial-ledger/ledger.blade.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/resources/views/business/pages/financial-ledger/ledger.blade.php b/resources/views/business/pages/financial-ledger/ledger.blade.php index a45cfa8a..f838b511 100644 --- a/resources/views/business/pages/financial-ledger/ledger.blade.php +++ b/resources/views/business/pages/financial-ledger/ledger.blade.php @@ -66,7 +66,8 @@ .ledger-map-input:focus { border-color:var(--vip-content-accent-dark, #856632); outline:none; box-shadow:0 0 0 3px rgba(133,102,50,.16); } .ledger-map-input.is-saving { border-color:var(--vip-content-accent, #B9954F); background:#fffdf6; } .ledger-map-input.is-error { border-color:var(--vip-danger, #B80000); background:#fff5f5; } - .ledger-status-select { width:7.4rem; height:2rem; padding:0 .45rem; border:1px solid var(--vip-border, #D8D2C7); border-radius:.4rem; background:#fff; color:var(--vip-content-ink, #171717); font:inherit; font-weight:600; cursor:pointer; } + .ledger-table .ledger-status-column { width:4.6rem; min-width:4.6rem; padding-right:.2rem; padding-left:.2rem; text-align:center; } + .ledger-status-select { display:block; width:4.15rem; min-width:4.15rem; height:2rem; margin:0 auto; padding:0 .1rem; border:1px solid var(--vip-border, #D8D2C7); border-radius:.4rem; background:#fff; color:var(--vip-content-ink, #171717); font:inherit; font-size:.68rem; font-weight:600; text-align:center; text-align-last:center; cursor:pointer; } .ledger-status-select:focus { border-color:var(--vip-content-accent-dark, #856632); outline:none; box-shadow:0 0 0 3px rgba(133,102,50,.16); } .ledger-status-select[data-status="normal"] { border-color:rgba(83,121,62,.38); background:#f3f7ee; color:var(--vip-success, #53793E); } .ledger-status-select[data-status="online_banking_abnormal"] { border-color:rgba(184,0,0,.34); background:#fff5f5; color:var(--vip-danger, #B80000); } @@ -74,7 +75,7 @@ .ledger-status-select[data-status="business_deregistered"] { border-color:rgba(89,89,89,.32); background:#f3f1ec; color:var(--vip-content-muted, #595959); } .ledger-status-select.is-saving { opacity:.62; cursor:wait; } .ledger-status-select.is-error { border-color:var(--vip-danger, #B80000); box-shadow:0 0 0 3px rgba(184,0,0,.12); } - .ledger-status-badge { display:inline-flex; align-items:center; min-height:1.65rem; padding:.1rem .48rem; border:1px solid transparent; border-radius:999px; font-size:.68rem; font-weight:700; } + .ledger-status-badge { display:inline-flex; align-items:center; justify-content:center; min-height:1.65rem; padding:.1rem .3rem; border:1px solid transparent; border-radius:999px; font-size:.68rem; font-weight:700; } .ledger-status-badge[data-status="normal"] { border-color:rgba(83,121,62,.28); background:#f3f7ee; color:var(--vip-success, #53793E); } .ledger-status-badge[data-status="online_banking_abnormal"] { border-color:rgba(184,0,0,.24); background:#fff5f5; color:var(--vip-danger, #B80000); } .ledger-status-badge[data-status="limited"] { border-color:rgba(147,95,7,.26); background:#fff8eb; color:var(--vip-warning, #935F07); } @@ -128,13 +129,13 @@
- + @forelse ($ledgerAccounts as $row) - ', false) ->assertSee('网商银行') + ->assertSee('ledger-accounts-table-wrap', false) + ->assertSee('ledger-pagination__controls', false) + ->assertSee('data-ledger-page-select', false) ->assertSee('未填写') ->assertDontSee('data-account-filter-state', false) ->assertSee('value="normal" selected', false); } + public function test_account_pagination_lists_page_urls_with_current_filter(): void + { + $rows = []; + for ($index = 1; $index <= 101; $index++) { + $rows[] = [ + 'company_name' => '分页测试公司', + 'account_short' => 'page-test-' . $index, + ]; + } + DB::connection('business_mirror')->table('fin_accounts')->insert($rows); + + $this->loginAs()->get(route('business.pages.show', [ + 'company_id' => $this->company->id, + 'section' => 'financial-ledger', + 'page' => 'ledger', + 'accounts_short' => 'page-test', + ])) + ->assertOk() + ->assertSee('data-ledger-page-select', false) + ->assertSee('accounts_page=2', false) + ->assertSee('accounts_short=page-test', false); + } + public function test_account_filters_narrow_by_short_status_region_and_type(): void { $connection = DB::connection('business_mirror'); -- 2.47.2 From 952953d6820bc0a42a08913c227cd4c3884c3bb5 Mon Sep 17 00:00:00 2001 From: shanshi <3316401569@qq.com> Date: Tue, 20 Oct 2026 14:04:45 +0800 Subject: [PATCH 16/24] =?UTF-8?q?style:=20=E7=B2=BE=E7=AE=80=E8=B4=A6?= =?UTF-8?q?=E6=88=B7=E6=80=BB=E8=A1=A8=E5=88=86=E9=A1=B5=E6=8E=A7=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/financial-ledger/ledger.blade.php | 42 +++++++++---------- .../FinanceLedgerAccountStatusTest.php | 6 ++- 2 files changed, 23 insertions(+), 25 deletions(-) diff --git a/resources/views/business/pages/financial-ledger/ledger.blade.php b/resources/views/business/pages/financial-ledger/ledger.blade.php index 632faa7a..39b07cb4 100644 --- a/resources/views/business/pages/financial-ledger/ledger.blade.php +++ b/resources/views/business/pages/financial-ledger/ledger.blade.php @@ -80,9 +80,12 @@ .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 { color:var(--vip-content-accent-dark, #856632); text-decoration:none; margin-left:.75rem; font-weight:600; } .ledger-pagination__controls { display:flex; align-items:center; justify-content:flex-end; gap:.45rem; margin-left:auto; } - .ledger-page-picker { display:inline-flex; align-items:center; gap:.3rem; color:var(--vip-content-muted, #595959); white-space:nowrap; } - .ledger-page-picker select { height:1.9rem; min-width:4rem; padding:0 .35rem; border:1px solid var(--vip-border, #D8D2C7); border-radius:.4rem; background:#fff; color:var(--vip-content-ink, #171717); font:inherit; font-weight:600; cursor:pointer; } - .ledger-page-picker select:focus { border-color:var(--vip-content-accent-dark, #856632); outline:none; box-shadow:0 0 0 3px rgba(133,102,50,.16); } + .ledger-page-box { display:inline-flex; align-items:center; height:1.9rem; overflow:hidden; border:1px solid var(--vip-border, #D8D2C7); border-radius:.4rem; background:#fff; } + .ledger-page-box__arrow { display:inline-grid; place-items:center; width:1.75rem; height:100%; color:var(--vip-content-accent-dark, #856632); text-decoration:none; } + .ledger-page-box__arrow:hover { background:var(--vip-content-accent-soft, #F7F0DE); } + .ledger-page-box__arrow .material-icons-outlined { font-size:1rem; } + .ledger-page-box__arrow.is-disabled { color:#b9b3a9; cursor:not-allowed; } + .ledger-page-box__current { min-width:3.6rem; padding:0 .4rem; border-right:1px solid #e8e3da; border-left:1px solid #e8e3da; color:var(--vip-content-ink, #171717); font-size:.7rem; font-variant-numeric:tabular-nums; font-weight:700; text-align:center; white-space:nowrap; } .ledger-empty { padding:3.5rem 1rem; text-align:center; color:var(--vip-content-muted, #595959); } .ledger-progress { display:flex; align-items:center; gap:.65rem; min-width:15rem; } .ledger-progress__track { flex:1; height:.45rem; overflow:hidden; border-radius:999px; background:#e8e3da; } @@ -205,20 +208,19 @@
共 {{ number_format($ledgerAccounts->total()) }} 条 · 每页 100 条
- @if (!$ledgerAccounts->onFirstPage()) - 上一页 - @endif - - @if ($ledgerAccounts->hasMorePages()) - 下一页 - @endif +
@@ -361,12 +363,6 @@ tabs.forEach((tab) => tab.addEventListener('click', () => showTab(tab.dataset.ledgerTab))); if (window.location.hash === '#mapping') showTab('mapping', false); - page.querySelectorAll('[data-ledger-page-select]').forEach((select) => { - select.addEventListener('change', () => { - if (select.value) window.location.assign(select.value); - }); - }); - const toast = page.querySelector('[data-ledger-toast]'); const notify = (message, error = false) => { if (!toast) return; diff --git a/tests/Feature/Business/FinanceLedgerAccountStatusTest.php b/tests/Feature/Business/FinanceLedgerAccountStatusTest.php index 8f77b848..575fe2ab 100644 --- a/tests/Feature/Business/FinanceLedgerAccountStatusTest.php +++ b/tests/Feature/Business/FinanceLedgerAccountStatusTest.php @@ -71,7 +71,8 @@ class FinanceLedgerAccountStatusTest extends FeatureTestCase ->assertSee('网商银行') ->assertSee('ledger-accounts-table-wrap', false) ->assertSee('ledger-pagination__controls', false) - ->assertSee('data-ledger-page-select', false) + ->assertSee('ledger-page-box', false) + ->assertSee('1 / 1', false) ->assertSee('未填写') ->assertDontSee('data-account-filter-state', false) ->assertSee('value="normal" selected', false); @@ -95,7 +96,8 @@ class FinanceLedgerAccountStatusTest extends FeatureTestCase 'accounts_short' => 'page-test', ])) ->assertOk() - ->assertSee('data-ledger-page-select', false) + ->assertSee('ledger-page-box', false) + ->assertSee('aria-label="下一页"', false) ->assertSee('accounts_page=2', false) ->assertSee('accounts_short=page-test', false); } -- 2.47.2 From 908a402fc8b4a04b72b3ecdce094d1f3035d899e Mon Sep 17 00:00:00 2001 From: shanshi <3316401569@qq.com> Date: Tue, 20 Oct 2026 14:06:53 +0800 Subject: [PATCH 17/24] =?UTF-8?q?style:=20=E7=AE=80=E5=8C=96=E5=88=86?= =?UTF-8?q?=E9=A1=B5=E4=B8=89=E8=A7=92=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/financial-ledger/ledger.blade.php | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/resources/views/business/pages/financial-ledger/ledger.blade.php b/resources/views/business/pages/financial-ledger/ledger.blade.php index 39b07cb4..5dd54430 100644 --- a/resources/views/business/pages/financial-ledger/ledger.blade.php +++ b/resources/views/business/pages/financial-ledger/ledger.blade.php @@ -80,12 +80,14 @@ .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 { color:var(--vip-content-accent-dark, #856632); text-decoration:none; margin-left:.75rem; font-weight:600; } .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; height:1.9rem; overflow:hidden; border:1px solid var(--vip-border, #D8D2C7); border-radius:.4rem; background:#fff; } - .ledger-page-box__arrow { display:inline-grid; place-items:center; width:1.75rem; height:100%; color:var(--vip-content-accent-dark, #856632); text-decoration:none; } - .ledger-page-box__arrow:hover { background:var(--vip-content-accent-soft, #F7F0DE); } - .ledger-page-box__arrow .material-icons-outlined { font-size:1rem; } + .ledger-page-box { display:inline-flex; align-items:center; gap:.55rem; height:1.7rem; } + .ledger-page-box__arrow { display:inline-grid; place-items:center; width:1.35rem; height:1.5rem; color:var(--vip-content-accent-dark, #856632); text-decoration:none; } + .ledger-page-box__arrow:hover { color:#6A5129; } + .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:3.6rem; padding:0 .4rem; border-right:1px solid #e8e3da; border-left:1px solid #e8e3da; color:var(--vip-content-ink, #171717); font-size:.7rem; font-variant-numeric:tabular-nums; font-weight:700; text-align:center; white-space:nowrap; } + .ledger-page-box__current { min-width:2.8rem; color:var(--vip-content-ink, #171717); font-size:.7rem; font-variant-numeric:tabular-nums; font-weight:700; text-align:center; white-space:nowrap; } .ledger-empty { padding:3.5rem 1rem; text-align:center; color:var(--vip-content-muted, #595959); } .ledger-progress { display:flex; align-items:center; gap:.65rem; min-width:15rem; } .ledger-progress__track { flex:1; height:.45rem; overflow:hidden; border-radius:999px; background:#e8e3da; } @@ -210,15 +212,15 @@
-- 2.47.2 From 67c6f19c6f0afc5e2753f466eea4312630e6b667 Mon Sep 17 00:00:00 2001 From: shanshi <3316401569@qq.com> Date: Tue, 20 Oct 2026 14:07:31 +0800 Subject: [PATCH 18/24] =?UTF-8?q?style:=20=E6=94=B6=E7=B4=A7=E5=88=86?= =?UTF-8?q?=E9=A1=B5=E6=8E=A7=E4=BB=B6=E9=97=B4=E8=B7=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../views/business/pages/financial-ledger/ledger.blade.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/resources/views/business/pages/financial-ledger/ledger.blade.php b/resources/views/business/pages/financial-ledger/ledger.blade.php index 5dd54430..c9239774 100644 --- a/resources/views/business/pages/financial-ledger/ledger.blade.php +++ b/resources/views/business/pages/financial-ledger/ledger.blade.php @@ -80,14 +80,14 @@ .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 { color:var(--vip-content-accent-dark, #856632); text-decoration:none; margin-left:.75rem; font-weight:600; } .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:.55rem; height:1.7rem; } - .ledger-page-box__arrow { display:inline-grid; place-items:center; width:1.35rem; height:1.5rem; color:var(--vip-content-accent-dark, #856632); text-decoration:none; } + .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; color:var(--vip-content-accent-dark, #856632); text-decoration:none; } .ledger-page-box__arrow:hover { color:#6A5129; } .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.8rem; color:var(--vip-content-ink, #171717); font-size:.7rem; font-variant-numeric:tabular-nums; font-weight:700; text-align:center; white-space:nowrap; } + .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; } .ledger-empty { padding:3.5rem 1rem; text-align:center; color:var(--vip-content-muted, #595959); } .ledger-progress { display:flex; align-items:center; gap:.65rem; min-width:15rem; } .ledger-progress__track { flex:1; height:.45rem; overflow:hidden; border-radius:999px; background:#e8e3da; } -- 2.47.2 From 3509da5764fc1e896568da899759e841495d1324 Mon Sep 17 00:00:00 2001 From: shanshi <3316401569@qq.com> Date: Tue, 20 Oct 2026 14:08:15 +0800 Subject: [PATCH 19/24] =?UTF-8?q?style:=20=E5=8E=BB=E9=99=A4=E5=88=86?= =?UTF-8?q?=E9=A1=B5=E6=8C=89=E9=92=AE=E7=82=B9=E5=87=BB=E7=84=A6=E7=82=B9?= =?UTF-8?q?=E6=96=B9=E6=A1=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../views/business/pages/financial-ledger/ledger.blade.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/resources/views/business/pages/financial-ledger/ledger.blade.php b/resources/views/business/pages/financial-ledger/ledger.blade.php index c9239774..a102f215 100644 --- a/resources/views/business/pages/financial-ledger/ledger.blade.php +++ b/resources/views/business/pages/financial-ledger/ledger.blade.php @@ -81,8 +81,10 @@ .ledger-pagination a { color:var(--vip-content-accent-dark, #856632); text-decoration:none; margin-left:.75rem; font-weight:600; } .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; color:var(--vip-content-accent-dark, #856632); text-decoration:none; } + .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; } -- 2.47.2 From ee24477b750955a99e1a73be98de21fb20081b40 Mon Sep 17 00:00:00 2001 From: shanshi <3316401569@qq.com> Date: Tue, 20 Oct 2026 14:11:08 +0800 Subject: [PATCH 20/24] =?UTF-8?q?feat:=20=E6=94=B9=E4=B8=BA=E6=97=A0?= =?UTF-8?q?=E5=88=B7=E6=96=B0=E5=88=86=E9=A1=B5=E5=B9=B6=E4=BF=9D=E6=8C=81?= =?UTF-8?q?=E6=BB=9A=E5=8A=A8=E4=BD=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/financial-ledger/ledger.blade.php | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/resources/views/business/pages/financial-ledger/ledger.blade.php b/resources/views/business/pages/financial-ledger/ledger.blade.php index a102f215..b0c0d6c3 100644 --- a/resources/views/business/pages/financial-ledger/ledger.blade.php +++ b/resources/views/business/pages/financial-ledger/ledger.blade.php @@ -77,6 +77,7 @@ .ledger-table .danger { color:var(--vip-danger, #B80000); } .ledger-table .success { color:var(--vip-success, #53793E); } .ledger-accounts-table-wrap { max-height:calc(100vh - 390px); min-height:20rem; } + #ledger-panel-accounts[aria-busy="true"] .ledger-accounts-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 { color:var(--vip-content-accent-dark, #856632); text-decoration:none; margin-left:.75rem; font-weight:600; } .ledger-pagination__controls { display:flex; align-items:center; justify-content:flex-end; gap:.45rem; margin-left:auto; } @@ -523,5 +524,73 @@ }; bindInlineAccountSelect('[data-account-status]', 'status', '账户状态已保存'); bindInlineAccountSelect('[data-account-type]', 'account_type', '账户类型已保存'); + + const accountPanel = page.querySelector('#ledger-panel-accounts'); + let accountPageRequest = null; + const bindAccountPagination = () => { + if (!accountPanel) return; + accountPanel.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 = accountPanel.querySelector('.ledger-accounts-table tbody'); + const currentSummary = accountPanel.querySelector('.ledger-summary'); + const currentPagination = accountPanel.querySelector('.ledger-pagination'); + const tableWrap = accountPanel.querySelector('.ledger-accounts-table-wrap'); + if (!currentBody || !currentSummary || !currentPagination || !tableWrap) { + window.location.assign(link.href); + return; + } + + const tableScrollTop = tableWrap.scrollTop; + const tableScrollLeft = tableWrap.scrollLeft; + accountPageRequest?.abort(); + accountPageRequest = new AbortController(); + accountPanel.setAttribute('aria-busy', 'true'); + try { + const response = await fetch(link.href, { + credentials: 'same-origin', + headers: { + 'Accept': 'text/html', + 'X-Requested-With': 'XMLHttpRequest', + }, + signal: accountPageRequest.signal, + }); + if (!response.ok) throw new Error('分页加载失败(HTTP ' + response.status + ')'); + + const nextDocument = new DOMParser().parseFromString(await response.text(), 'text/html'); + const nextPanel = nextDocument.querySelector('#ledger-panel-accounts'); + const nextBody = nextPanel?.querySelector('.ledger-accounts-table tbody'); + const nextSummary = nextPanel?.querySelector('.ledger-summary'); + const nextPagination = nextPanel?.querySelector('.ledger-pagination'); + if (!nextBody || !nextSummary || !nextPagination) throw new Error('分页内容解析失败'); + + currentBody.replaceWith(document.importNode(nextBody, true)); + currentSummary.replaceWith(document.importNode(nextSummary, true)); + currentPagination.replaceWith(document.importNode(nextPagination, true)); + if (window.history?.replaceState) { + window.history.replaceState({ ...window.history.state, ledgerAccountsAjax: true }, '', link.href); + } + + const nextTableWrap = accountPanel.querySelector('.ledger-accounts-table-wrap'); + if (nextTableWrap) { + nextTableWrap.scrollTop = tableScrollTop; + nextTableWrap.scrollLeft = tableScrollLeft; + } + bindInlineAccountSelect('[data-account-status]', 'status', '账户状态已保存'); + bindInlineAccountSelect('[data-account-type]', 'account_type', '账户类型已保存'); + bindAccountPagination(); + } catch (error) { + if (error.name !== 'AbortError') notify(error.message || '分页加载失败', true); + } finally { + accountPanel.removeAttribute('aria-busy'); + } + }); + }); + }; + bindAccountPagination(); })(); -- 2.47.2 From 6e78b7001f2d0724c63daafe4afb816d39bad3ac Mon Sep 17 00:00:00 2001 From: shanshi <3316401569@qq.com> Date: Tue, 20 Oct 2026 14:23:21 +0800 Subject: [PATCH 21/24] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E5=88=86?= =?UTF-8?q?=E9=A1=B5=E5=8A=A0=E8=BD=BD=E7=8A=B6=E6=80=81=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/financial-ledger/ledger.blade.php | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/resources/views/business/pages/financial-ledger/ledger.blade.php b/resources/views/business/pages/financial-ledger/ledger.blade.php index b0c0d6c3..71439362 100644 --- a/resources/views/business/pages/financial-ledger/ledger.blade.php +++ b/resources/views/business/pages/financial-ledger/ledger.blade.php @@ -369,13 +369,15 @@ if (window.location.hash === '#mapping') showTab('mapping', false); const toast = page.querySelector('[data-ledger-toast]'); - const notify = (message, error = false) => { + const notify = (message, error = false, persistent = false) => { if (!toast) return; toast.textContent = message; toast.classList.toggle('is-error', error); toast.classList.add('is-visible'); window.clearTimeout(notify.timer); - notify.timer = window.setTimeout(() => toast.classList.remove('is-visible'), 2600); + if (!persistent) { + notify.timer = window.setTimeout(() => toast.classList.remove('is-visible'), 2600); + } }; const accountTemplateDownload = page.querySelector('[data-account-template-download]'); @@ -548,8 +550,12 @@ const tableScrollTop = tableWrap.scrollTop; const tableScrollLeft = tableWrap.scrollLeft; accountPageRequest?.abort(); - accountPageRequest = new AbortController(); + const request = new AbortController(); + accountPageRequest = request; + const targetPage = new URL(link.href, window.location.href).searchParams.get('accounts_page'); + const loadingMessage = targetPage ? '正在加载第 ' + targetPage + ' 页…' : '正在加载下一页…'; accountPanel.setAttribute('aria-busy', 'true'); + notify(loadingMessage, false, true); try { const response = await fetch(link.href, { credentials: 'same-origin', @@ -557,7 +563,7 @@ 'Accept': 'text/html', 'X-Requested-With': 'XMLHttpRequest', }, - signal: accountPageRequest.signal, + signal: request.signal, }); if (!response.ok) throw new Error('分页加载失败(HTTP ' + response.status + ')'); @@ -583,10 +589,13 @@ bindInlineAccountSelect('[data-account-status]', 'status', '账户状态已保存'); bindInlineAccountSelect('[data-account-type]', 'account_type', '账户类型已保存'); bindAccountPagination(); + notify(targetPage ? '已加载第 ' + targetPage + ' 页' : '下一页已加载'); } catch (error) { if (error.name !== 'AbortError') notify(error.message || '分页加载失败', true); } finally { - accountPanel.removeAttribute('aria-busy'); + if (accountPageRequest === request) { + accountPanel.removeAttribute('aria-busy'); + } } }); }); -- 2.47.2 From ba4da42bc372d56493b7d4a8d9b87f329103276b Mon Sep 17 00:00:00 2001 From: shanshi <3316401569@qq.com> Date: Wed, 21 Oct 2026 09:51:49 +0800 Subject: [PATCH 22/24] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E8=B4=A6?= =?UTF-8?q?=E6=88=B7=E6=80=BB=E8=A1=A8=E5=88=97=E6=98=BE=E7=A4=BA=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/financial-ledger/ledger.blade.php | 128 +++++++++++++++++- .../FinanceLedgerAccountStatusTest.php | 3 + 2 files changed, 130 insertions(+), 1 deletion(-) diff --git a/resources/views/business/pages/financial-ledger/ledger.blade.php b/resources/views/business/pages/financial-ledger/ledger.blade.php index 71439362..1d1475e6 100644 --- a/resources/views/business/pages/financial-ledger/ledger.blade.php +++ b/resources/views/business/pages/financial-ledger/ledger.blade.php @@ -51,6 +51,15 @@ .ledger-filter-state { display:inline-flex; align-items:center; align-self:flex-end; gap:.28rem; height:2.15rem; padding:0 .58rem; border:1px solid rgba(185,149,79,.42); border-radius:999px; background:var(--vip-content-accent-soft, #F7F0DE); color:var(--vip-content-accent-dark, #856632); font-size:.68rem; font-weight:700; white-space:nowrap; } .ledger-filter-state .material-icons-outlined { font-size:.95rem; } .ledger-filter-state b { display:inline-grid; place-items:center; min-width:1.15rem; height:1.15rem; padding:0 .2rem; border-radius:999px; background:var(--vip-content-accent-dark, #856632); color:#fff; font-size:.6rem; line-height:1; } + .ledger-column-picker { position:relative; align-self:flex-end; } + .ledger-column-panel { position:absolute; top:calc(100% + .35rem); left:0; z-index:45; width:min(30rem, calc(100vw - 2rem)); max-height:min(25rem, 65vh); overflow:auto; padding:.75rem; border:1px solid var(--vip-border, #D8D2C7); border-radius:.55rem; background:#fff; box-shadow:0 4px 12px rgba(23,23,23,.14); } + .ledger-column-panel[hidden] { display:none !important; } + .ledger-column-options { display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:.25rem .8rem; } + .ledger-column-option { display:flex; align-items:center; gap:.4rem; min-width:0; min-height:1.8rem; color:var(--vip-content-ink, #171717); font-size:.72rem; cursor:pointer; } + .ledger-column-option input { flex:0 0 auto; accent-color:var(--vip-content-accent-dark, #856632); } + .ledger-column-option span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } + .ledger-column-actions { display:flex; align-items:center; justify-content:space-between; gap:.5rem; margin-top:.55rem; padding-top:.55rem; border-top:1px solid #e8e3da; color:var(--vip-content-muted, #595959); font-size:.68rem; } + .ledger-column-actions button { height:1.8rem; padding:0 .6rem; border:1px solid var(--vip-border, #D8D2C7); border-radius:.35rem; background:#fff; color:var(--vip-content-accent-dark, #856632); font:inherit; font-weight:600; cursor:pointer; } .ledger-mapping-bulk { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:.75rem; } .ledger-mapping-bulk-actions { display:flex; flex-wrap:wrap; gap:.45rem; } .ledger-mapping-bulk .ledger-btn { height:1.95rem; } @@ -72,6 +81,8 @@ .ledger-table td.num, .ledger-table th.num { text-align:right; font-variant-numeric:tabular-nums; } .ledger-accounts-table thead th, .ledger-accounts-table thead th.num { text-align:center; } + .ledger-accounts-table { min-width:max-content; } + .ledger-accounts-table .is-column-hidden { display:none !important; } .ledger-table td.wrap { white-space:normal; min-width:14rem; max-width:22rem; line-height:1.5; } .ledger-table .muted { color:var(--vip-content-muted, #595959); } .ledger-table .danger { color:var(--vip-danger, #B80000); } @@ -161,6 +172,13 @@ @if ($activeAccountFilterCount > 0) 筛选中{{ $activeAccountFilterCount }} @endif +
+ + +
refresh重置download导出录入模板
@@ -389,6 +407,114 @@ } }); + const accountPanel = page.querySelector('#ledger-panel-accounts'); + const accountColumnPicker = page.querySelector('[data-ledger-column-picker]'); + const accountColumnPanel = accountColumnPicker?.querySelector('[data-ledger-column-panel]'); + const accountColumnOptions = accountColumnPicker?.querySelector('[data-ledger-column-options]'); + const accountColumnToggle = accountColumnPicker?.querySelector('[data-ledger-column-toggle]'); + const accountColumnCount = accountColumnPicker?.querySelector('[data-ledger-column-count]'); + const accountColumnKey = accountColumnPicker?.dataset.columnStorageKey || 'caiwu.financial-ledger.accounts.columns'; + const accountColumnHeaders = () => Array.from( + accountPanel?.querySelectorAll('.ledger-accounts-table thead th') || [], + ); + const accountColumnIndexes = () => accountColumnHeaders().map((_, index) => index); + let visibleAccountColumns = accountColumnIndexes(); + + const normaliseColumnIndexes = (values, count) => { + if (!Array.isArray(values)) return Array.from({ length: count }, (_, index) => index); + return [...new Set(values.map(Number).filter((index) => Number.isInteger(index) && index >= 0 && index < count))] + .sort((left, right) => left - right); + }; + + const applyAccountColumnVisibility = () => { + const table = accountPanel?.querySelector('.ledger-accounts-table'); + if (!table) return; + const visible = new Set(visibleAccountColumns); + table.querySelectorAll('thead th').forEach((header, index) => { + header.classList.toggle('is-column-hidden', !visible.has(index)); + }); + table.querySelectorAll('tbody tr').forEach((row) => { + [...row.children].forEach((cell, index) => { + cell.classList.toggle('is-column-hidden', !visible.has(index)); + }); + }); + if (accountColumnCount) { + accountColumnCount.textContent = '显示 ' + visible.size + ' / ' + accountColumnHeaders().length + ' 列'; + } + }; + + const saveAccountColumnVisibility = () => { + try { + localStorage.setItem(accountColumnKey, JSON.stringify(visibleAccountColumns)); + } catch (_) {} + }; + + if (accountColumnPicker && accountColumnPanel && accountColumnOptions && accountColumnToggle) { + const headers = accountColumnHeaders(); + try { + visibleAccountColumns = normaliseColumnIndexes( + JSON.parse(localStorage.getItem(accountColumnKey) || 'null'), + headers.length, + ); + } catch (_) { + visibleAccountColumns = accountColumnIndexes(); + } + + headers.forEach((header, index) => { + const label = document.createElement('label'); + label.className = 'ledger-column-option'; + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.value = String(index); + checkbox.checked = visibleAccountColumns.includes(index); + checkbox.dataset.ledgerColumnCheck = 'true'; + const text = document.createElement('span'); + text.textContent = header.textContent.trim() || ('第 ' + (index + 1) + ' 列'); + label.append(checkbox, text); + accountColumnOptions.appendChild(label); + }); + + accountColumnOptions.addEventListener('change', (event) => { + const checkbox = event.target.closest('[data-ledger-column-check]'); + if (!checkbox) return; + const next = Array.from(accountColumnOptions.querySelectorAll('[data-ledger-column-check]:checked')) + .map((item) => Number(item.value)); + if (next.length === 0) { + checkbox.checked = true; + notify('账户总表至少保留一列', true); + return; + } + visibleAccountColumns = next.sort((left, right) => left - right); + saveAccountColumnVisibility(); + applyAccountColumnVisibility(); + }); + + accountColumnToggle.addEventListener('click', () => { + const open = accountColumnPanel.hidden; + accountColumnPanel.hidden = !open; + accountColumnToggle.setAttribute('aria-expanded', String(open)); + }); + accountColumnPicker.querySelector('[data-ledger-column-all]')?.addEventListener('click', () => { + accountColumnOptions.querySelectorAll('[data-ledger-column-check]').forEach((checkbox) => { + checkbox.checked = true; + }); + visibleAccountColumns = accountColumnIndexes(); + saveAccountColumnVisibility(); + applyAccountColumnVisibility(); + }); + document.addEventListener('click', (event) => { + if (accountColumnPanel.hidden || accountColumnPicker.contains(event.target)) return; + accountColumnPanel.hidden = true; + accountColumnToggle.setAttribute('aria-expanded', 'false'); + }); + document.addEventListener('keydown', (event) => { + if (event.key !== 'Escape' || accountColumnPanel.hidden) return; + accountColumnPanel.hidden = true; + accountColumnToggle.setAttribute('aria-expanded', 'false'); + }); + applyAccountColumnVisibility(); + } + const postMappingBulk = async (url) => { const response = await fetch(url, { method: 'POST', @@ -527,7 +653,6 @@ bindInlineAccountSelect('[data-account-status]', 'status', '账户状态已保存'); bindInlineAccountSelect('[data-account-type]', 'account_type', '账户类型已保存'); - const accountPanel = page.querySelector('#ledger-panel-accounts'); let accountPageRequest = null; const bindAccountPagination = () => { if (!accountPanel) return; @@ -588,6 +713,7 @@ } bindInlineAccountSelect('[data-account-status]', 'status', '账户状态已保存'); bindInlineAccountSelect('[data-account-type]', 'account_type', '账户类型已保存'); + applyAccountColumnVisibility(); bindAccountPagination(); notify(targetPage ? '已加载第 ' + targetPage + ' 页' : '下一页已加载'); } catch (error) { diff --git a/tests/Feature/Business/FinanceLedgerAccountStatusTest.php b/tests/Feature/Business/FinanceLedgerAccountStatusTest.php index 575fe2ab..afc4c336 100644 --- a/tests/Feature/Business/FinanceLedgerAccountStatusTest.php +++ b/tests/Feature/Business/FinanceLedgerAccountStatusTest.php @@ -73,6 +73,9 @@ class FinanceLedgerAccountStatusTest extends FeatureTestCase ->assertSee('ledger-pagination__controls', false) ->assertSee('ledger-page-box', false) ->assertSee('1 / 1', false) + ->assertSee('data-ledger-column-picker', false) + ->assertSee('data-ledger-column-panel', false) + ->assertSee('列设置') ->assertSee('未填写') ->assertDontSee('data-account-filter-state', false) ->assertSee('value="normal" selected', false); -- 2.47.2 From 0edb3260de7556a69e26e92ef12b9fb5e306c504 Mon Sep 17 00:00:00 2001 From: shanshi <3316401569@qq.com> Date: Wed, 21 Oct 2026 10:25:04 +0800 Subject: [PATCH 23/24] =?UTF-8?q?style:=20=E8=B0=83=E6=95=B4=E8=B4=A6?= =?UTF-8?q?=E6=88=B7=E9=87=91=E9=A2=9D=E5=AD=97=E6=AE=B5=E5=90=8D=E7=A7=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Services/FinanceLedgerAccountTemplate.php | 6 +++--- .../business/pages/financial-ledger/ledger.blade.php | 8 ++++---- tests/Feature/Business/FinanceLedgerAccountStatusTest.php | 3 +++ .../Feature/Business/FinanceLedgerAccountTemplateTest.php | 4 ++-- 4 files changed, 12 insertions(+), 9 deletions(-) diff --git a/modules/EcommerceFinance/Services/FinanceLedgerAccountTemplate.php b/modules/EcommerceFinance/Services/FinanceLedgerAccountTemplate.php index 7b3ee635..3b059c2e 100644 --- a/modules/EcommerceFinance/Services/FinanceLedgerAccountTemplate.php +++ b/modules/EcommerceFinance/Services/FinanceLedgerAccountTemplate.php @@ -38,8 +38,8 @@ class FinanceLedgerAccountTemplate '日限额', '月限额', '年限额', - '当前余额', - '卡住金额', + '可用金额', + '不可用金额', '每日可出', '预计出清天数', '提额目标', @@ -130,7 +130,7 @@ class FinanceLedgerAccountTemplate ['必填', '公司名称、账户简称。'], ['账户状态', implode(' / ', $statuses)], ['账户类型', $types !== [] ? implode(' / ', $types) : '留空或按实际填写'], - ['金额', '当前余额、卡住金额、每日可出按数字填写;限额列保留原有组合写法。'], + ['金额', '可用金额、不可用金额、每日可出按数字填写;限额列保留原有组合写法。'], ['提交前', '请核对账户数、余额合计和期初合计,再导入系统。'], ], null, 'A1'); $notes->getColumnDimension('A')->setWidth(16); diff --git a/resources/views/business/pages/financial-ledger/ledger.blade.php b/resources/views/business/pages/financial-ledger/ledger.blade.php index 1d1475e6..e7f3ccc8 100644 --- a/resources/views/business/pages/financial-ledger/ledger.blade.php +++ b/resources/views/business/pages/financial-ledger/ledger.blade.php @@ -184,13 +184,13 @@
账户数{{ number_format((int) ($ledgerAccountSummary['count'] ?? 0)) }}
-
当前余额合计{{ $money($ledgerAccountSummary['balance'] ?? 0) }}
-
卡住金额合计{{ $money($ledgerAccountSummary['blocked'] ?? 0) }}
-
每日可出金额口径余额 − 卡住金额
+
可用金额合计{{ $money($ledgerAccountSummary['balance'] ?? 0) }}
+
不可用金额合计{{ $money($ledgerAccountSummary['blocked'] ?? 0) }}
+
每日可出金额口径可用金额 − 不可用金额
公司名称账户简称账户状态地区账号开户行开户城市/网点账户类型公司规划后4位单笔限额日限额月限额年限额当前余额卡住金额每日可出预计出清天数提额目标提额状态提额动作已做动作下一步备注责任人
公司名称账户简称账户状态地区账号开户行开户城市/网点账户类型公司规划后4位单笔限额日限额月限额年限额当前余额卡住金额每日可出预计出清天数提额目标提额状态提额动作已做动作下一步备注责任人
{{ $row->company_name }} {{ $row->account_short }} + @if ($canEditAccountStatus) +
@forelse ($ledgerAccounts as $row) -- 2.47.2 From 3fd5417c731dfd3f0e4eff463acbc8b3dea413db Mon Sep 17 00:00:00 2001 From: shanshi <3316401569@qq.com> Date: Tue, 20 Oct 2026 11:27:08 +0800 Subject: [PATCH 04/24] =?UTF-8?q?style:=20=E8=BF=9B=E4=B8=80=E6=AD=A5?= =?UTF-8?q?=E5=8E=8B=E7=BC=A9=E8=B4=A2=E5=8A=A1=E5=8F=B0=E8=B4=A6=E8=A1=8C?= =?UTF-8?q?=E9=AB=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../views/business/pages/financial-ledger/ledger.blade.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/views/business/pages/financial-ledger/ledger.blade.php b/resources/views/business/pages/financial-ledger/ledger.blade.php index ca19f4dd..f2a73524 100644 --- a/resources/views/business/pages/financial-ledger/ledger.blade.php +++ b/resources/views/business/pages/financial-ledger/ledger.blade.php @@ -48,7 +48,7 @@ .ledger-table th, .ledger-table td { padding:.52rem .6rem; border-right:1px solid #e8e3da; border-bottom:1px solid #e8e3da; vertical-align:top; white-space:nowrap; } .ledger-table th { position:sticky; top:0; z-index:1; background:var(--vip-content-accent-soft, #F7F0DE); color:var(--vip-content-ink, #171717); font-weight:700; text-align:left; } .ledger-table tr:hover td { background:#fcfaf4; } - .ledger-accounts-table tbody td { padding-top:.38rem; padding-bottom:.38rem; } + .ledger-accounts-table tbody td { padding-top:.2rem; padding-bottom:.2rem; vertical-align:middle; } .ledger-table th:first-child, .ledger-table td:first-child { position:sticky; left:0; z-index:2; background:#fff; } .ledger-table th:first-child { z-index:3; background:var(--vip-content-accent-soft, #F7F0DE); } .ledger-table tr:hover td:first-child { background:#fcfaf4; } @@ -68,7 +68,7 @@ .ledger-map-input.is-saving { border-color:var(--vip-content-accent, #B9954F); background:#fffdf6; } .ledger-map-input.is-error { border-color:var(--vip-danger, #B80000); background:#fff5f5; } .ledger-table .ledger-status-column { width:4.6rem; min-width:4.6rem; padding-right:.2rem; padding-left:.2rem; text-align:center; } - .ledger-status-select { display:block; width:4.15rem; min-width:4.15rem; height:2rem; margin:0 auto; padding:0 .1rem; border:1px solid var(--vip-border, #D8D2C7); border-radius:.4rem; background:#fff; color:var(--vip-content-ink, #171717); font:inherit; font-size:.68rem; font-weight:600; text-align:center; text-align-last:center; cursor:pointer; } + .ledger-status-select { display:block; width:4.15rem; min-width:4.15rem; height:1.75rem; margin:0 auto; padding:0 .1rem; border:1px solid var(--vip-border, #D8D2C7); border-radius:.4rem; background:#fff; color:var(--vip-content-ink, #171717); font:inherit; font-size:.68rem; font-weight:600; text-align:center; text-align-last:center; cursor:pointer; } .ledger-status-select:focus { border-color:var(--vip-content-accent-dark, #856632); outline:none; box-shadow:0 0 0 3px rgba(133,102,50,.16); } .ledger-status-select[data-status="normal"] { border-color:rgba(83,121,62,.38); background:#f3f7ee; color:var(--vip-success, #53793E); } .ledger-status-select[data-status="online_banking_abnormal"] { border-color:rgba(184,0,0,.34); background:#fff5f5; color:var(--vip-danger, #B80000); } -- 2.47.2 From 76a940fe97fba1c43a2c172a315ea324f72ee4aa Mon Sep 17 00:00:00 2001 From: shanshi <3316401569@qq.com> Date: Tue, 20 Oct 2026 11:31:28 +0800 Subject: [PATCH 05/24] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E8=B4=A2?= =?UTF-8?q?=E5=8A=A1=E5=8F=B0=E8=B4=A6=E8=B4=A6=E6=88=B7=E7=BB=84=E5=90=88?= =?UTF-8?q?=E7=AD=9B=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../View/FinanceLedgerPageData.php | 57 ++++++++++++++++ .../pages/financial-ledger/ledger.blade.php | 7 +- .../FinanceLedgerAccountStatusTest.php | 66 +++++++++++++++++++ 3 files changed, 129 insertions(+), 1 deletion(-) diff --git a/modules/EcommerceFinance/View/FinanceLedgerPageData.php b/modules/EcommerceFinance/View/FinanceLedgerPageData.php index 3d4277ee..64bb5ff2 100644 --- a/modules/EcommerceFinance/View/FinanceLedgerPageData.php +++ b/modules/EcommerceFinance/View/FinanceLedgerPageData.php @@ -25,6 +25,8 @@ class FinanceLedgerPageData 'sources' => ['title' => '数据来源', 'icon' => 'database'], ]; + private const ACCOUNT_FILTER_EMPTY = '__empty__'; + public function page(?string $tab = null): array { $tab = (string) (request('tab') ?: $tab); @@ -53,6 +55,7 @@ class FinanceLedgerPageData 'ledgerSourceSummary' => $this->sourceSummary(), 'ledgerMeta' => $this->meta(), 'ledgerAccountOptions' => $this->accountOptions(), + 'ledgerAccountFilterOptions' => $this->accountFilterOptions(), 'ledgerAccountStatusOptions' => FinanceLedgerAccountStatus::OPTIONS, 'ledgerCanEditMapping' => FinanceLedgerAccess::canEditMapping(user()), 'ledgerCanEditAccountStatus' => FinanceLedgerAccess::canEditAccountStatus(user()), @@ -71,6 +74,48 @@ class FinanceLedgerPageData }); } + $accountShort = trim((string) request('accounts_short', '')); + if ($accountShort !== '') { + $query->where('account_short', 'like', '%' . $accountShort . '%'); + } + + $status = trim((string) request('accounts_status', '')); + if ($status !== '' && array_key_exists($status, FinanceLedgerAccountStatus::OPTIONS)) { + if ($status === FinanceLedgerAccountStatus::NORMAL) { + $nonNormalStatuses = array_values(array_diff( + array_keys(FinanceLedgerAccountStatus::OPTIONS), + [FinanceLedgerAccountStatus::NORMAL], + )); + $query->whereNotIn( + 'account_short', + $this->db()->table('fin_account_statuses') + ->select('account_short') + ->whereIn('status', $nonNormalStatuses), + ); + } else { + $query->whereIn( + 'account_short', + $this->db()->table('fin_account_statuses') + ->select('account_short') + ->where('status', $status), + ); + } + } + + foreach (['accounts_region' => 'region', 'accounts_type' => 'account_type'] as $parameter => $column) { + $value = trim((string) request($parameter, '')); + if ($value === '') { + continue; + } + if ($value === self::ACCOUNT_FILTER_EMPTY) { + $query->where(function (Builder $builder) use ($column): void { + $builder->whereNull($column)->orWhere($column, ''); + }); + } else { + $query->where($column, $value); + } + } + $summaryQuery = clone $query; $summary = [ 'count' => (clone $query)->count(), @@ -272,6 +317,18 @@ class FinanceLedgerPageData ->select('account_short')->distinct()->orderBy('account_short')->pluck('account_short')->all(); } + private function accountFilterOptions(): array + { + return [ + 'regions' => $this->db()->table('fin_accounts') + ->whereNotNull('region')->where('region', '<>', '') + ->distinct()->orderBy('region')->pluck('region')->all(), + 'types' => $this->db()->table('fin_accounts') + ->whereNotNull('account_type')->where('account_type', '<>', '') + ->distinct()->orderBy('account_type')->pluck('account_type')->all(), + ]; + } + private function db() { return DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')); diff --git a/resources/views/business/pages/financial-ledger/ledger.blade.php b/resources/views/business/pages/financial-ledger/ledger.blade.php index f2a73524..9d2d1bff 100644 --- a/resources/views/business/pages/financial-ledger/ledger.blade.php +++ b/resources/views/business/pages/financial-ledger/ledger.blade.php @@ -5,6 +5,7 @@ $canEdit = (bool) ($ledgerCanEditMapping ?? false); $canEditAccountStatus = (bool) ($ledgerCanEditAccountStatus ?? false); $accountStatusOptions = $ledgerAccountStatusOptions ?? \App\Utilities\FinanceLedgerAccountStatus::OPTIONS; + $accountFilterOptions = $ledgerAccountFilterOptions ?? ['regions' => [], 'types' => []]; $money = static fn ($value): string => $value === null || $value === '' ? '' : number_format((float) $value, 2); $date = static fn ($value): string => $value ? \Illuminate\Support\Carbon::parse($value)->format('Y-m-d') : ''; $tabUrl = static fn (string $tab): string => route('business.pages.show', ['section' => 'financial-ledger', 'page' => 'ledger', 'tab' => $tab]); @@ -119,7 +120,11 @@ - + + + @empty @@ -346,48 +363,52 @@ }); }); - const statusSelects = Array.from(page.querySelectorAll('[data-account-status]')); - statusSelects.forEach((select) => { - select.dataset.previousStatus = select.value; - select.addEventListener('change', async () => { - const value = select.value; - const previous = select.dataset.previousStatus || value; - const related = statusSelects.filter((item) => item.dataset.accountShort === select.dataset.accountShort); - related.forEach((item) => { - item.classList.add('is-saving'); - item.classList.remove('is-error'); - }); - try { - const response = await fetch(select.dataset.url, { - method: 'POST', - credentials: 'same-origin', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '', - }, - body: JSON.stringify({ - account_short: select.dataset.accountShort, - status: value, - }), - }); - const payload = await response.json().catch(() => ({})); - if (!response.ok) throw new Error(payload.message || '保存失败'); + const bindInlineAccountSelect = (selector, field, successLabel) => { + const selects = Array.from(page.querySelectorAll(selector)); + selects.forEach((select) => { + select.dataset.previousValue = select.value; + select.addEventListener('change', async () => { + const value = select.value; + const previous = select.dataset.previousValue ?? value; + const related = selects.filter((item) => item.dataset.accountShort === select.dataset.accountShort); related.forEach((item) => { - item.value = value; - item.dataset.status = value; - item.dataset.previousStatus = value; + item.classList.add('is-saving'); + item.classList.remove('is-error'); }); - notify('账户状态已保存:' + (payload.label || '')); - } catch (error) { - select.value = previous; - select.dataset.status = previous; - select.classList.add('is-error'); - notify(error.message || '账户状态保存失败', true); - } finally { - related.forEach((item) => item.classList.remove('is-saving')); - } + try { + const response = await fetch(select.dataset.url, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '', + }, + body: JSON.stringify({ + account_short: select.dataset.accountShort, + [field]: value, + }), + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.message || '保存失败'); + related.forEach((item) => { + item.value = value; + item.dataset.previousValue = value; + if (field === 'status') item.dataset.status = value; + }); + notify(successLabel + ':' + (payload.label || '')); + } catch (error) { + select.value = previous; + if (field === 'status') select.dataset.status = previous; + select.classList.add('is-error'); + notify(error.message || successLabel + '失败', true); + } finally { + related.forEach((item) => item.classList.remove('is-saving')); + } + }); }); - }); + }; + bindInlineAccountSelect('[data-account-status]', 'status', '账户状态已保存'); + bindInlineAccountSelect('[data-account-type]', 'account_type', '账户类型已保存'); })(); diff --git a/routes/admin.php b/routes/admin.php index 35f76508..5df1afd5 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -641,6 +641,10 @@ Route::post('workspace/financial-ledger/accounts/status', [\App\Http\Controllers ->middleware('business.page:financial-ledger,ledger') ->name('business.financial-ledger.accounts.status'); +Route::post('workspace/financial-ledger/accounts/type', [\App\Http\Controllers\Business\FinanceLedger::class, 'updateAccountType']) + ->middleware('business.page:financial-ledger,ledger') + ->name('business.financial-ledger.accounts.type'); + Route::get('workspace/{section}/{page?}/{tab?}', 'Business\Pages@show') ->name('business.pages.show'); diff --git a/tests/Feature/Business/FinanceLedgerAccountStatusTest.php b/tests/Feature/Business/FinanceLedgerAccountStatusTest.php index fb6a4bb0..21d1ea15 100644 --- a/tests/Feature/Business/FinanceLedgerAccountStatusTest.php +++ b/tests/Feature/Business/FinanceLedgerAccountStatusTest.php @@ -40,6 +40,13 @@ class FinanceLedgerAccountStatusTest extends FeatureTestCase ]); } + private function typeUrl(): string + { + return route('business.financial-ledger.accounts.type', [ + 'company_id' => $this->company->id, + ]); + } + public function test_account_table_renders_status_selector_with_four_options(): void { $this->loginAs()->get($this->pageUrl()) @@ -54,6 +61,8 @@ class FinanceLedgerAccountStatusTest extends FeatureTestCase ->assertSee('name="accounts_status"', false) ->assertSee('name="accounts_region"', false) ->assertSee('name="accounts_type"', false) + ->assertSee('data-account-type', false) + ->assertSee('ledger-type-select', false) ->assertSee('未填写') ->assertSee('value="normal" selected', false); } @@ -119,6 +128,74 @@ class FinanceLedgerAccountStatusTest extends FeatureTestCase ->assertSee('data-account-short="测试账户"', false); } + public function test_account_type_can_be_saved_and_filtered_by_effective_value(): void + { + $connection = DB::connection('business_mirror'); + $connection->table('fin_accounts')->insert([ + [ + 'company_name' => '类型测试公司甲', + 'account_short' => '类型账户甲', + 'account_type' => '公户', + ], + [ + 'company_name' => '类型测试公司乙', + 'account_short' => '类型账户乙', + 'account_type' => '个人卡', + ], + [ + 'company_name' => '类型测试公司丙', + 'account_short' => '类型账户丙', + 'account_type' => '支付宝', + ], + ]); + + $this->loginAs()->postJson($this->typeUrl(), [ + 'account_short' => '类型账户甲', + 'account_type' => '支付宝', + ]) + ->assertOk() + ->assertJsonPath('account_type', '支付宝') + ->assertJsonPath('label', '支付宝'); + + $this->loginAs()->postJson($this->typeUrl(), [ + 'account_short' => '类型账户甲', + 'account_type' => '个人卡', + ])->assertOk(); + + $this->assertDatabaseCount('fin_account_type_overrides', 1, 'business_mirror'); + $this->assertDatabaseHas('fin_account_type_overrides', [ + 'account_short' => '类型账户甲', + 'account_type' => '个人卡', + 'updated_by' => 'Test User', + ], 'business_mirror'); + + $this->loginAs()->get(route('business.pages.show', [ + 'company_id' => $this->company->id, + 'section' => 'financial-ledger', + 'page' => 'ledger', + 'accounts_type' => '个人卡', + ])) + ->assertOk() + ->assertSee('data-account-short="类型账户甲"', false) + ->assertSee('data-account-short="类型账户乙"', false) + ->assertDontSee('data-account-short="类型账户丙"', false) + ->assertSee('value="个人卡" selected', false); + + $this->loginAs()->get(route('business.pages.show', [ + 'company_id' => $this->company->id, + 'section' => 'financial-ledger', + 'page' => 'ledger', + 'accounts_type' => '公户', + ])) + ->assertOk() + ->assertDontSee('data-account-short="类型账户甲"', false); + + $this->withExceptionHandling()->loginAs()->postJson($this->typeUrl(), [ + 'account_short' => '类型账户甲', + 'account_type' => '不存在的类型', + ])->assertUnprocessable()->assertJsonPath('message', '请选择现有账户类型。'); + } + public function test_account_status_can_be_saved_and_updated_without_duplicate_rows(): void { $payload = [ -- 2.47.2 From 1d9b5fd15341e87e88324f2f595985f9b15bfd65 Mon Sep 17 00:00:00 2001 From: shanshi <3316401569@qq.com> Date: Tue, 20 Oct 2026 11:41:15 +0800 Subject: [PATCH 07/24] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E8=B4=A2?= =?UTF-8?q?=E5=8A=A1=E5=8F=B0=E8=B4=A6=E7=AD=9B=E9=80=89=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/financial-ledger/ledger.blade.php | 13 +++++++++++++ .../Business/FinanceLedgerAccountStatusTest.php | 3 +++ 2 files changed, 16 insertions(+) diff --git a/resources/views/business/pages/financial-ledger/ledger.blade.php b/resources/views/business/pages/financial-ledger/ledger.blade.php index 448ad1fe..1ed8f04d 100644 --- a/resources/views/business/pages/financial-ledger/ledger.blade.php +++ b/resources/views/business/pages/financial-ledger/ledger.blade.php @@ -8,6 +8,13 @@ $accountStatusOptions = $ledgerAccountStatusOptions ?? \App\Utilities\FinanceLedgerAccountStatus::OPTIONS; $accountFilterOptions = $ledgerAccountFilterOptions ?? ['regions' => [], 'types' => []]; $accountTypeOptions = $accountFilterOptions['types'] ?? []; + $activeAccountFilterCount = count(array_filter([ + request('accounts_q'), + request('accounts_short'), + request('accounts_status'), + request('accounts_region'), + request('accounts_type'), + ], static fn ($value): bool => trim((string) $value) !== '')); $money = static fn ($value): string => $value === null || $value === '' ? '' : number_format((float) $value, 2); $date = static fn ($value): string => $value ? \Illuminate\Support\Carbon::parse($value)->format('Y-m-d') : ''; $tabUrl = static fn (string $tab): string => route('business.pages.show', ['section' => 'financial-ledger', 'page' => 'ledger', 'tab' => $tab]); @@ -41,6 +48,9 @@ .ledger-btn:hover { border-color:var(--vip-content-accent-dark, #856632); color:var(--vip-content-accent-dark, #856632); } .ledger-btn.primary { border-color:var(--vip-content-accent-dark, #856632); background:var(--vip-content-accent-dark, #856632); color:#fff; } .ledger-btn.primary:hover { background:#6A5129; color:#fff; } + .ledger-filter-state { display:inline-flex; align-items:center; align-self:flex-end; gap:.28rem; height:2.15rem; padding:0 .58rem; border:1px solid rgba(185,149,79,.42); border-radius:999px; background:var(--vip-content-accent-soft, #F7F0DE); color:var(--vip-content-accent-dark, #856632); font-size:.68rem; font-weight:700; white-space:nowrap; } + .ledger-filter-state .material-icons-outlined { font-size:.95rem; } + .ledger-filter-state b { display:inline-grid; place-items:center; min-width:1.15rem; height:1.15rem; padding:0 .2rem; border-radius:999px; background:var(--vip-content-accent-dark, #856632); color:#fff; font-size:.6rem; line-height:1; } .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; } @@ -129,6 +139,9 @@
+ @if ($activeAccountFilterCount > 0) + 筛选中{{ $activeAccountFilterCount }} + @endif
refresh重置
diff --git a/tests/Feature/Business/FinanceLedgerAccountStatusTest.php b/tests/Feature/Business/FinanceLedgerAccountStatusTest.php index 21d1ea15..bbfe3554 100644 --- a/tests/Feature/Business/FinanceLedgerAccountStatusTest.php +++ b/tests/Feature/Business/FinanceLedgerAccountStatusTest.php @@ -64,6 +64,7 @@ class FinanceLedgerAccountStatusTest extends FeatureTestCase ->assertSee('data-account-type', false) ->assertSee('ledger-type-select', false) ->assertSee('未填写') + ->assertDontSee('data-account-filter-state', false) ->assertSee('value="normal" selected', false); } @@ -102,6 +103,8 @@ class FinanceLedgerAccountStatusTest extends FeatureTestCase ->assertOk() ->assertSee('data-account-short="筛选账户乙"', false) ->assertDontSee('data-account-short="筛选账户甲"', false) + ->assertSee('data-account-filter-state', false) + ->assertSee('筛选中') ->assertSee('value="limited" selected', false); $this->loginAs()->get(route('business.pages.show', [ -- 2.47.2 From 40a1ea250de10e9759bfbbc709b5b6329cd351cc Mon Sep 17 00:00:00 2001 From: shanshi <3316401569@qq.com> Date: Tue, 20 Oct 2026 12:13:25 +0800 Subject: [PATCH 08/24] =?UTF-8?q?feat:=20=E6=89=B9=E9=87=8F=E5=85=B3?= =?UTF-8?q?=E8=81=94=E5=BA=97=E9=93=BA=E6=8F=90=E7=8E=B0=E8=B4=A6=E6=88=B7?= =?UTF-8?q?=E6=98=A0=E5=B0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/Business/FinanceLedger.php | 31 +++++ docs/财务台账开发说明.md | 4 + .../Services/FinanceLedgerShopMappingBulk.php | 117 ++++++++++++++++++ .../pages/financial-ledger/ledger.blade.php | 49 +++++++- routes/admin.php | 8 ++ .../FinanceLedgerAccountStatusTest.php | 1 + .../FinanceLedgerShopMappingBulkTest.php | 94 ++++++++++++++ 7 files changed, 303 insertions(+), 1 deletion(-) create mode 100644 modules/EcommerceFinance/Services/FinanceLedgerShopMappingBulk.php create mode 100644 tests/Feature/Business/FinanceLedgerShopMappingBulkTest.php diff --git a/app/Http/Controllers/Business/FinanceLedger.php b/app/Http/Controllers/Business/FinanceLedger.php index 98be1e6b..950c1042 100644 --- a/app/Http/Controllers/Business/FinanceLedger.php +++ b/app/Http/Controllers/Business/FinanceLedger.php @@ -10,6 +10,7 @@ use Illuminate\Routing\Controller; use Illuminate\Support\Facades\DB; use Illuminate\Validation\Rule; use Modules\EcommerceFinance\Services\FinanceLedgerCalculator; +use Modules\EcommerceFinance\Services\FinanceLedgerShopMappingBulk; class FinanceLedger extends Controller { @@ -165,4 +166,34 @@ class FinanceLedger extends Controller 'label' => $accountType !== '' ? $accountType : '未填写', ], 200, ['Cache-Control' => 'private, no-store']); } + + public function previewShopMapping(FinanceLedgerShopMappingBulk $bulk): JsonResponse + { + abort_unless(FinanceLedgerAccess::canEditMapping(user()), 403, '只有财务台账管理员可以批量修改账户映射。'); + + $preview = $bulk->preview(); + + return response()->json([ + 'ok' => true, + 'total' => $preview['total'], + 'matched' => $preview['matched'], + 'unmatched' => $preview['unmatched'], + 'sample' => array_slice($preview['candidates'], 0, 10), + 'unmatched_sample' => array_slice($preview['unmatched_values'], 0, 10), + ], 200, ['Cache-Control' => 'private, no-store']); + } + + public function applyShopMapping(FinanceLedgerShopMappingBulk $bulk): JsonResponse + { + abort_unless(FinanceLedgerAccess::canEditMapping(user()), 403, '只有财务台账管理员可以批量修改账户映射。'); + + $result = $bulk->apply(user()); + + return response()->json([ + 'ok' => true, + 'total' => $result['total'], + 'mapped' => $result['mapped'], + 'unmatched' => $result['unmatched'], + ], 200, ['Cache-Control' => 'private, no-store']); + } } diff --git a/docs/财务台账开发说明.md b/docs/财务台账开发说明.md index 7e7e91ce..4700c3b5 100644 --- a/docs/财务台账开发说明.md +++ b/docs/财务台账开发说明.md @@ -70,6 +70,10 @@ docker compose -f docker-compose.local.yml exec -u www-data app \ `fin_account_type_overrides`,重新导入账户基础数据均不会覆盖;页面修改使用 异步保存,不触发整页刷新。 +“账户映射”页支持按店铺台账批量关联:仅匹配 `source = shop`、来源值与 +`fin_shops.shop_name` 完全一致、店铺回款账户简称存在于 `fin_accounts` +的未映射记录。批量操作写入映射审计并重算流水,未精确匹配的来源值保留人工处理。 + 生产宿主机 `/etc/cron.d` 仍需增加每日调用入口;本项目当前没有运行 `schedule:run` 的容器,调度登记不能替代宿主机 cron。 diff --git a/modules/EcommerceFinance/Services/FinanceLedgerShopMappingBulk.php b/modules/EcommerceFinance/Services/FinanceLedgerShopMappingBulk.php new file mode 100644 index 00000000..7c2a0bbe --- /dev/null +++ b/modules/EcommerceFinance/Services/FinanceLedgerShopMappingBulk.php @@ -0,0 +1,117 @@ +connection(); + $shopAccounts = $connection->table('fin_shops') + ->whereNotNull('account_short') + ->where('account_short', '<>', '') + ->where('account_short', '<>', '/') + ->pluck('account_short', 'shop_name'); + $masterAccounts = array_fill_keys( + $connection->table('fin_accounts')->distinct()->pluck('account_short')->all(), + true, + ); + $rows = $connection->table('fin_account_map') + ->where('source', 'shop') + ->where(function ($query): void { + $query->whereNull('account_short')->orWhere('account_short', ''); + }) + ->orderBy('source_value') + ->get(['id', 'source_value']); + + $candidates = []; + $unmatched = []; + foreach ($rows as $row) { + $sourceValue = trim((string) $row->source_value); + $accountShort = trim((string) ($shopAccounts[$sourceValue] ?? '')); + if ($accountShort === '' || ! isset($masterAccounts[$accountShort])) { + $unmatched[] = $sourceValue; + continue; + } + + $candidates[] = [ + 'id' => (int) $row->id, + 'source_value' => $sourceValue, + 'account_short' => $accountShort, + ]; + } + + return [ + 'total' => $rows->count(), + 'matched' => count($candidates), + 'unmatched' => count($unmatched), + 'candidates' => $candidates, + 'unmatched_values' => $unmatched, + ]; + } + + public function apply(?User $user = null): array + { + $preview = $this->preview(); + $connection = $this->connection(); + $userName = (string) ($user?->name ?? ''); + $userId = (int) ($user?->id ?? 0); + $now = now(); + $mapped = 0; + + if ($preview['candidates'] !== []) { + $connection->transaction(function () use ($connection, $preview, $userName, $userId, $now, &$mapped): void { + foreach ($preview['candidates'] as $candidate) { + $updated = $connection->table('fin_account_map') + ->where('id', $candidate['id']) + ->where(function ($query): void { + $query->whereNull('account_short')->orWhere('account_short', ''); + }) + ->update([ + 'account_short' => $candidate['account_short'], + 'updated_by' => $userName, + 'updated_at' => $now, + ]); + if ($updated !== 1) { + continue; + } + + $connection->table('fin_account_map_events')->insert([ + 'source' => 'shop', + 'source_value' => $candidate['source_value'], + 'old_account_short' => null, + 'new_account_short' => $candidate['account_short'], + 'user_id' => $userId, + 'user_name' => $userName, + 'created_at' => $now, + ]); + $mapped++; + } + }); + } + + if ($mapped > 0) { + app(FinanceLedgerCalculator::class)->recalculate(); + } + + return [ + 'total' => $preview['total'], + 'mapped' => $mapped, + 'unmatched' => $preview['unmatched'], + ]; + } + + private function connection() + { + return DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')); + } +} diff --git a/resources/views/business/pages/financial-ledger/ledger.blade.php b/resources/views/business/pages/financial-ledger/ledger.blade.php index 1ed8f04d..29e68316 100644 --- a/resources/views/business/pages/financial-ledger/ledger.blade.php +++ b/resources/views/business/pages/financial-ledger/ledger.blade.php @@ -51,6 +51,8 @@ .ledger-filter-state { display:inline-flex; align-items:center; align-self:flex-end; gap:.28rem; height:2.15rem; padding:0 .58rem; border:1px solid rgba(185,149,79,.42); border-radius:999px; background:var(--vip-content-accent-soft, #F7F0DE); color:var(--vip-content-accent-dark, #856632); font-size:.68rem; font-weight:700; white-space:nowrap; } .ledger-filter-state .material-icons-outlined { font-size:.95rem; } .ledger-filter-state b { display:inline-grid; place-items:center; min-width:1.15rem; height:1.15rem; padding:0 .2rem; border-radius:999px; background:var(--vip-content-accent-dark, #856632); color:#fff; font-size:.6rem; line-height:1; } + .ledger-mapping-bulk { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:.75rem; } + .ledger-mapping-bulk .ledger-btn { height:1.95rem; } .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; } @@ -269,7 +271,14 @@
refresh重置
-
{{ $canEdit ? '映射修改后失焦自动保存,并立即重算账户流水;输入值必须来自账户总表。' : '当前账号为只读权限,不能修改账户映射。' }}
+
+ {{ $canEdit ? '映射修改后失焦自动保存,并立即重算账户流水;输入值必须来自账户总表。' : '当前账号为只读权限,不能修改账户映射。' }} + @if ($canEdit) + + @endif +
公司名称账户简称账户状态地区账号开户行开户城市/网点账户类型公司规划后4位单笔限额日限额月限额年限额当前余额卡住金额每日可出预计出清天数提额目标提额状态提额动作已做动作下一步备注责任人
{{ $row->account_short }} @if ($canEditAccountStatus) - @foreach ($accountStatusOptions as $statusCode => $statusLabel) @endforeach @@ -152,7 +156,20 @@ {{ $accountStatusOptions[$row->account_status] ?? '正常' }} @endif {{ $row->region }}{{ $row->account_no }}{{ $row->bank_name }}{{ $row->bank_city_branch }}{{ $row->account_type }}{{ $row->company_plan }}{{ $row->account_last4 }}{{ $row->single_limit }}{{ $row->daily_limit }}{{ $row->monthly_limit }}{{ $row->yearly_limit }}{{ $money($row->current_balance) }}{{ $money($row->blocked_amount) }}{{ $money($row->daily_available) }}{{ $row->clearing_days !== null ? number_format((float) $row->clearing_days, 2) : '' }}{{ $row->raise_target }}{{ $row->raise_status }}{{ $row->raise_actions }}{{ $row->done_actions }}{{ $row->next_action }}{{ $row->remark }}{{ $row->owner }}{{ $row->region }}{{ $row->account_no }}{{ $row->bank_name }}{{ $row->bank_city_branch }} + @if ($canEditAccountType) + + @else + {{ $row->account_type ?: '未填写' }} + @endif + {{ $row->company_plan }}{{ $row->account_last4 }}{{ $row->single_limit }}{{ $row->daily_limit }}{{ $row->monthly_limit }}{{ $row->yearly_limit }}{{ $money($row->current_balance) }}{{ $money($row->blocked_amount) }}{{ $money($row->daily_available) }}{{ $row->clearing_days !== null ? number_format((float) $row->clearing_days, 2) : '' }}{{ $row->raise_target }}{{ $row->raise_status }}{{ $row->raise_actions }}{{ $row->done_actions }}{{ $row->next_action }}{{ $row->remark }}{{ $row->owner }}
没有匹配的账户数据。
@forelse (($mapping['rows'] ?? collect()) as $row)@empty@endforelse @@ -329,6 +338,44 @@ notify.timer = window.setTimeout(() => toast.classList.remove('is-visible'), 2600); }; + const shopMappingBulk = page.querySelector('[data-shop-mapping-bulk]'); + if (shopMappingBulk) { + const postBulkAction = async (url) => { + const response = await fetch(url, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Accept': 'application/json', + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '', + }, + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.message || '操作失败'); + + return payload; + }; + shopMappingBulk.addEventListener('click', async () => { + shopMappingBulk.disabled = true; + try { + const preview = await postBulkAction(shopMappingBulk.dataset.previewUrl); + if (Number(preview.matched || 0) === 0) { + notify('没有可按店铺台账自动关联的未映射记录,剩余 ' + Number(preview.unmatched || 0) + ' 条需手工处理。', true); + return; + } + if (!window.confirm('可自动关联 ' + preview.matched + ' 条,尚有 ' + preview.unmatched + ' 条未匹配。确定继续吗?')) { + return; + } + const result = await postBulkAction(shopMappingBulk.dataset.applyUrl); + notify('已按店铺台账关联 ' + result.mapped + ' 条,剩余 ' + result.unmatched + ' 条未匹配。'); + window.setTimeout(() => window.location.reload(), 900); + } catch (error) { + notify(error.message || '批量关联失败', true); + } finally { + shopMappingBulk.disabled = false; + } + }); + } + const inputs = Array.from(page.querySelectorAll('[data-mapping-input]')); inputs.forEach((input) => { let previous = input.value; diff --git a/routes/admin.php b/routes/admin.php index 5df1afd5..bf091a62 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -645,6 +645,14 @@ Route::post('workspace/financial-ledger/accounts/type', [\App\Http\Controllers\B ->middleware('business.page:financial-ledger,ledger') ->name('business.financial-ledger.accounts.type'); +Route::post('workspace/financial-ledger/mapping/shop/preview', [\App\Http\Controllers\Business\FinanceLedger::class, 'previewShopMapping']) + ->middleware('business.page:financial-ledger,ledger') + ->name('business.financial-ledger.mapping.shop.preview'); + +Route::post('workspace/financial-ledger/mapping/shop/apply', [\App\Http\Controllers\Business\FinanceLedger::class, 'applyShopMapping']) + ->middleware('business.page:financial-ledger,ledger') + ->name('business.financial-ledger.mapping.shop.apply'); + Route::get('workspace/{section}/{page?}/{tab?}', 'Business\Pages@show') ->name('business.pages.show'); diff --git a/tests/Feature/Business/FinanceLedgerAccountStatusTest.php b/tests/Feature/Business/FinanceLedgerAccountStatusTest.php index bbfe3554..7e3bda83 100644 --- a/tests/Feature/Business/FinanceLedgerAccountStatusTest.php +++ b/tests/Feature/Business/FinanceLedgerAccountStatusTest.php @@ -63,6 +63,7 @@ class FinanceLedgerAccountStatusTest extends FeatureTestCase ->assertSee('name="accounts_type"', false) ->assertSee('data-account-type', false) ->assertSee('ledger-type-select', false) + ->assertSee('data-shop-mapping-bulk', false) ->assertSee('未填写') ->assertDontSee('data-account-filter-state', false) ->assertSee('value="normal" selected', false); diff --git a/tests/Feature/Business/FinanceLedgerShopMappingBulkTest.php b/tests/Feature/Business/FinanceLedgerShopMappingBulkTest.php new file mode 100644 index 00000000..a53d5e83 --- /dev/null +++ b/tests/Feature/Business/FinanceLedgerShopMappingBulkTest.php @@ -0,0 +1,94 @@ +user->attachPermission( + Permission::where('name', 'read-admin-panel')->firstOrFail() + ); + + $connection = DB::connection('business_mirror'); + $connection->table('fin_accounts')->insert([ + ['company_name' => '店铺映射测试公司', 'account_short' => '映射账户甲'], + ['company_name' => '店铺映射测试公司', 'account_short' => '映射账户乙'], + ]); + $connection->table('fin_shops')->insert([ + ['shop_name' => '天猫-映射店铺甲', 'account_short' => '映射账户甲'], + ['shop_name' => '天猫-映射店铺乙', 'account_short' => '映射账户乙'], + ['shop_name' => '天猫-映射店铺丙', 'account_short' => '/'], + ]); + $connection->table('fin_account_map')->insert([ + ['source' => 'shop', 'source_value' => '天猫-映射店铺甲', 'account_short' => null], + ['source' => 'shop', 'source_value' => '天猫-映射店铺乙', 'account_short' => null], + ['source' => 'shop', 'source_value' => '天猫-映射店铺丙', 'account_short' => null], + ['source' => 'shop', 'source_value' => '已经映射的店铺', 'account_short' => '映射账户甲'], + ]); + } + + private function previewUrl(): string + { + return route('business.financial-ledger.mapping.shop.preview', [ + 'company_id' => $this->company->id, + ]); + } + + private function applyUrl(): string + { + return route('business.financial-ledger.mapping.shop.apply', [ + 'company_id' => $this->company->id, + ]); + } + + public function test_preview_and_apply_only_exact_shop_name_matches(): void + { + $this->loginAs()->postJson($this->previewUrl()) + ->assertOk() + ->assertJsonPath('total', 3) + ->assertJsonPath('matched', 2) + ->assertJsonPath('unmatched', 1) + ->assertJsonCount(2, 'sample'); + + $this->assertDatabaseHas('fin_account_map', [ + 'source' => 'shop', + 'source_value' => '天猫-映射店铺甲', + 'account_short' => null, + ], 'business_mirror'); + + $this->loginAs()->postJson($this->applyUrl()) + ->assertOk() + ->assertJsonPath('mapped', 2) + ->assertJsonPath('unmatched', 1); + + $this->assertDatabaseHas('fin_account_map', [ + 'source' => 'shop', + 'source_value' => '天猫-映射店铺甲', + 'account_short' => '映射账户甲', + 'updated_by' => 'Test User', + ], 'business_mirror'); + $this->assertDatabaseHas('fin_account_map', [ + 'source' => 'shop', + 'source_value' => '天猫-映射店铺乙', + 'account_short' => '映射账户乙', + ], 'business_mirror'); + $this->assertDatabaseHas('fin_account_map', [ + 'source' => 'shop', + 'source_value' => '天猫-映射店铺丙', + 'account_short' => null, + ], 'business_mirror'); + $this->assertDatabaseHas('fin_account_map', [ + 'source' => 'shop', + 'source_value' => '已经映射的店铺', + 'account_short' => '映射账户甲', + ], 'business_mirror'); + $this->assertDatabaseCount('fin_account_map_events', 2, 'business_mirror'); + } +} -- 2.47.2 From ea9ea0d5acf24af78c6f00235d6525d58106747d Mon Sep 17 00:00:00 2001 From: shanshi <3316401569@qq.com> Date: Tue, 20 Oct 2026 13:35:01 +0800 Subject: [PATCH 09/24] =?UTF-8?q?feat:=20=E6=8C=89=E8=B4=A6=E6=88=B7?= =?UTF-8?q?=E6=80=BB=E8=A1=A8=E6=89=B9=E9=87=8F=E5=8C=B9=E9=85=8D=E4=BE=9B?= =?UTF-8?q?=E5=BA=94=E5=95=86=E5=BA=94=E4=BB=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/Business/FinanceLedger.php | 34 ++++ docs/财务台账开发说明.md | 4 + .../FinanceLedgerPayableMappingBulk.php | 150 ++++++++++++++++++ .../pages/financial-ledger/ledger.blade.php | 67 ++++---- routes/admin.php | 8 + .../FinanceLedgerAccountStatusTest.php | 2 +- .../FinanceLedgerPayableMappingBulkTest.php | 87 ++++++++++ 7 files changed, 323 insertions(+), 29 deletions(-) create mode 100644 modules/EcommerceFinance/Services/FinanceLedgerPayableMappingBulk.php create mode 100644 tests/Feature/Business/FinanceLedgerPayableMappingBulkTest.php diff --git a/app/Http/Controllers/Business/FinanceLedger.php b/app/Http/Controllers/Business/FinanceLedger.php index 950c1042..536907b1 100644 --- a/app/Http/Controllers/Business/FinanceLedger.php +++ b/app/Http/Controllers/Business/FinanceLedger.php @@ -10,6 +10,7 @@ use Illuminate\Routing\Controller; use Illuminate\Support\Facades\DB; use Illuminate\Validation\Rule; use Modules\EcommerceFinance\Services\FinanceLedgerCalculator; +use Modules\EcommerceFinance\Services\FinanceLedgerPayableMappingBulk; use Modules\EcommerceFinance\Services\FinanceLedgerShopMappingBulk; class FinanceLedger extends Controller @@ -196,4 +197,37 @@ class FinanceLedger extends Controller 'unmatched' => $result['unmatched'], ], 200, ['Cache-Control' => 'private, no-store']); } + + public function previewPayableMapping(FinanceLedgerPayableMappingBulk $bulk): JsonResponse + { + abort_unless(FinanceLedgerAccess::canEditMapping(user()), 403, '只有财务台账管理员可以批量修改账户映射。'); + + $preview = $bulk->preview(); + + return response()->json([ + 'ok' => true, + 'total' => $preview['total'], + 'eligible' => $preview['eligible'], + 'ambiguous' => $preview['ambiguous'], + 'missing' => $preview['missing'], + 'sample' => array_slice($preview['candidates'], 0, 10), + 'ambiguous_sample' => array_slice($preview['ambiguous_values'], 0, 10), + 'missing_sample' => array_slice($preview['missing_values'], 0, 10), + ], 200, ['Cache-Control' => 'private, no-store']); + } + + public function applyPayableMapping(FinanceLedgerPayableMappingBulk $bulk): JsonResponse + { + abort_unless(FinanceLedgerAccess::canEditMapping(user()), 403, '只有财务台账管理员可以批量修改账户映射。'); + + $result = $bulk->apply(user()); + + return response()->json([ + 'ok' => true, + 'total' => $result['total'], + 'mapped' => $result['mapped'], + 'ambiguous' => $result['ambiguous'], + 'missing' => $result['missing'], + ], 200, ['Cache-Control' => 'private, no-store']); + } } diff --git a/docs/财务台账开发说明.md b/docs/财务台账开发说明.md index 4700c3b5..90809490 100644 --- a/docs/财务台账开发说明.md +++ b/docs/财务台账开发说明.md @@ -74,6 +74,10 @@ docker compose -f docker-compose.local.yml exec -u www-data app \ `fin_shops.shop_name` 完全一致、店铺回款账户简称存在于 `fin_accounts` 的未映射记录。批量操作写入映射审计并重算流水,未精确匹配的来源值保留人工处理。 +同时支持按账户总表批量关联供应商应付:公司名称与 `fin_accounts.company_name` +完全一致时,只有唯一账户或唯一公户才自动映射;存在多公户或仅私人卡等歧义的公司 +保留人工选择。 + 生产宿主机 `/etc/cron.d` 仍需增加每日调用入口;本项目当前没有运行 `schedule:run` 的容器,调度登记不能替代宿主机 cron。 diff --git a/modules/EcommerceFinance/Services/FinanceLedgerPayableMappingBulk.php b/modules/EcommerceFinance/Services/FinanceLedgerPayableMappingBulk.php new file mode 100644 index 00000000..e607681b --- /dev/null +++ b/modules/EcommerceFinance/Services/FinanceLedgerPayableMappingBulk.php @@ -0,0 +1,150 @@ +connection(); + $accountsByCompany = []; + foreach ($connection->table('fin_accounts')->get(['company_name', 'account_short', 'account_type']) as $account) { + $company = trim((string) $account->company_name); + $accountShort = trim((string) $account->account_short); + if ($company === '' || $accountShort === '') { + continue; + } + $accountsByCompany[$company][$accountShort] = [ + 'account_short' => $accountShort, + 'account_type' => trim((string) $account->account_type), + ]; + } + + $rows = $connection->table('fin_account_map') + ->where('source', 'payable') + ->where(function ($query): void { + $query->whereNull('account_short')->orWhere('account_short', ''); + }) + ->orderBy('source_value') + ->get(['id', 'source_value']); + + $candidates = []; + $ambiguous = []; + $missing = []; + foreach ($rows as $row) { + $company = trim((string) $row->source_value); + $accounts = array_values($accountsByCompany[$company] ?? []); + if ($accounts === []) { + $missing[] = $company; + continue; + } + if (count($accounts) === 1) { + $candidates[] = $this->candidate($row, $accounts[0]['account_short'], 'single_account'); + continue; + } + + $publicAccounts = array_values(array_filter( + $accounts, + static fn (array $account): bool => $account['account_type'] === '公户', + )); + if (count($publicAccounts) === 1) { + $candidates[] = $this->candidate($row, $publicAccounts[0]['account_short'], 'single_public_account'); + continue; + } + + $ambiguous[] = [ + 'company' => $company, + 'accounts' => array_values(array_map( + static fn (array $account): string => $account['account_short'], + $accounts, + )), + ]; + } + + return [ + 'total' => $rows->count(), + 'eligible' => count($candidates), + 'ambiguous' => count($ambiguous), + 'missing' => count($missing), + 'candidates' => $candidates, + 'ambiguous_values' => $ambiguous, + 'missing_values' => $missing, + ]; + } + + public function apply(?User $user = null): array + { + $preview = $this->preview(); + $connection = $this->connection(); + $userName = (string) ($user?->name ?? ''); + $userId = (int) ($user?->id ?? 0); + $now = now(); + $mapped = 0; + + if ($preview['candidates'] !== []) { + $connection->transaction(function () use ($connection, $preview, $userName, $userId, $now, &$mapped): void { + foreach ($preview['candidates'] as $candidate) { + $updated = $connection->table('fin_account_map') + ->where('id', $candidate['id']) + ->where(function ($query): void { + $query->whereNull('account_short')->orWhere('account_short', ''); + }) + ->update([ + 'account_short' => $candidate['account_short'], + 'updated_by' => $userName, + 'updated_at' => $now, + ]); + if ($updated !== 1) { + continue; + } + + $connection->table('fin_account_map_events')->insert([ + 'source' => 'payable', + 'source_value' => $candidate['source_value'], + 'old_account_short' => null, + 'new_account_short' => $candidate['account_short'], + 'user_id' => $userId, + 'user_name' => $userName, + 'created_at' => $now, + ]); + $mapped++; + } + }); + } + + if ($mapped > 0) { + app(FinanceLedgerCalculator::class)->recalculate(); + } + + return [ + 'total' => $preview['total'], + 'mapped' => $mapped, + 'ambiguous' => $preview['ambiguous'], + 'missing' => $preview['missing'], + ]; + } + + private function candidate(object $row, string $accountShort, string $matchType): array + { + return [ + 'id' => (int) $row->id, + 'source_value' => trim((string) $row->source_value), + 'account_short' => $accountShort, + 'match_type' => $matchType, + ]; + } + + private function connection() + { + return DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')); + } +} diff --git a/resources/views/business/pages/financial-ledger/ledger.blade.php b/resources/views/business/pages/financial-ledger/ledger.blade.php index 29e68316..c53ea74e 100644 --- a/resources/views/business/pages/financial-ledger/ledger.blade.php +++ b/resources/views/business/pages/financial-ledger/ledger.blade.php @@ -52,6 +52,7 @@ .ledger-filter-state .material-icons-outlined { font-size:.95rem; } .ledger-filter-state b { display:inline-grid; place-items:center; min-width:1.15rem; height:1.15rem; padding:0 .2rem; border-radius:999px; background:var(--vip-content-accent-dark, #856632); color:#fff; font-size:.6rem; line-height:1; } .ledger-mapping-bulk { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:.75rem; } + .ledger-mapping-bulk-actions { display:flex; flex-wrap:wrap; gap:.45rem; } .ledger-mapping-bulk .ledger-btn { height:1.95rem; } .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; } @@ -274,9 +275,14 @@
{{ $canEdit ? '映射修改后失焦自动保存,并立即重算账户流水;输入值必须来自账户总表。' : '当前账号为只读权限,不能修改账户映射。' }} @if ($canEdit) - +
+ + +
@endif
@@ -338,43 +344,48 @@ notify.timer = window.setTimeout(() => toast.classList.remove('is-visible'), 2600); }; - const shopMappingBulk = page.querySelector('[data-shop-mapping-bulk]'); - if (shopMappingBulk) { - const postBulkAction = async (url) => { - const response = await fetch(url, { - method: 'POST', - credentials: 'same-origin', - headers: { - 'Accept': 'application/json', - 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '', - }, - }); - const payload = await response.json().catch(() => ({})); - if (!response.ok) throw new Error(payload.message || '操作失败'); + const postMappingBulk = async (url) => { + const response = await fetch(url, { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Accept': 'application/json', + 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content || '', + }, + }); + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.message || '操作失败'); - return payload; - }; - shopMappingBulk.addEventListener('click', async () => { - shopMappingBulk.disabled = true; + return payload; + }; + page.querySelectorAll('[data-mapping-bulk]').forEach((button) => { + button.addEventListener('click', async () => { + const kind = button.dataset.mappingKind; + const isPayable = kind === 'payable'; + const label = isPayable ? '供应商应付' : '店铺提现'; + button.disabled = true; try { - const preview = await postBulkAction(shopMappingBulk.dataset.previewUrl); - if (Number(preview.matched || 0) === 0) { - notify('没有可按店铺台账自动关联的未映射记录,剩余 ' + Number(preview.unmatched || 0) + ' 条需手工处理。', true); + const preview = await postMappingBulk(button.dataset.previewUrl); + const autoMatched = Number(isPayable ? preview.eligible : preview.matched) || 0; + const incomplete = Number(isPayable ? preview.ambiguous : preview.unmatched) || 0; + if (autoMatched === 0) { + notify('没有可按“' + label + '”自动关联的未映射记录,剩余 ' + incomplete + ' 条需手工处理。', true); return; } - if (!window.confirm('可自动关联 ' + preview.matched + ' 条,尚有 ' + preview.unmatched + ' 条未匹配。确定继续吗?')) { + if (!window.confirm('“' + label + '”可自动关联 ' + autoMatched + ' 条,尚有 ' + incomplete + ' 条无法唯一匹配。确定继续吗?')) { return; } - const result = await postBulkAction(shopMappingBulk.dataset.applyUrl); - notify('已按店铺台账关联 ' + result.mapped + ' 条,剩余 ' + result.unmatched + ' 条未匹配。'); + const result = await postMappingBulk(button.dataset.applyUrl); + const remaining = Number(isPayable ? result.ambiguous : result.unmatched) || 0; + notify('已按“' + label + '”关联 ' + result.mapped + ' 条,剩余 ' + remaining + ' 条需人工确认。'); window.setTimeout(() => window.location.reload(), 900); } catch (error) { notify(error.message || '批量关联失败', true); } finally { - shopMappingBulk.disabled = false; + button.disabled = false; } }); - } + }); const inputs = Array.from(page.querySelectorAll('[data-mapping-input]')); inputs.forEach((input) => { diff --git a/routes/admin.php b/routes/admin.php index bf091a62..2d2bb058 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -653,6 +653,14 @@ Route::post('workspace/financial-ledger/mapping/shop/apply', [\App\Http\Controll ->middleware('business.page:financial-ledger,ledger') ->name('business.financial-ledger.mapping.shop.apply'); +Route::post('workspace/financial-ledger/mapping/payable/preview', [\App\Http\Controllers\Business\FinanceLedger::class, 'previewPayableMapping']) + ->middleware('business.page:financial-ledger,ledger') + ->name('business.financial-ledger.mapping.payable.preview'); + +Route::post('workspace/financial-ledger/mapping/payable/apply', [\App\Http\Controllers\Business\FinanceLedger::class, 'applyPayableMapping']) + ->middleware('business.page:financial-ledger,ledger') + ->name('business.financial-ledger.mapping.payable.apply'); + Route::get('workspace/{section}/{page?}/{tab?}', 'Business\Pages@show') ->name('business.pages.show'); diff --git a/tests/Feature/Business/FinanceLedgerAccountStatusTest.php b/tests/Feature/Business/FinanceLedgerAccountStatusTest.php index 7e3bda83..7483ece1 100644 --- a/tests/Feature/Business/FinanceLedgerAccountStatusTest.php +++ b/tests/Feature/Business/FinanceLedgerAccountStatusTest.php @@ -63,7 +63,7 @@ class FinanceLedgerAccountStatusTest extends FeatureTestCase ->assertSee('name="accounts_type"', false) ->assertSee('data-account-type', false) ->assertSee('ledger-type-select', false) - ->assertSee('data-shop-mapping-bulk', false) + ->assertSee('data-mapping-bulk', false) ->assertSee('未填写') ->assertDontSee('data-account-filter-state', false) ->assertSee('value="normal" selected', false); diff --git a/tests/Feature/Business/FinanceLedgerPayableMappingBulkTest.php b/tests/Feature/Business/FinanceLedgerPayableMappingBulkTest.php new file mode 100644 index 00000000..77389029 --- /dev/null +++ b/tests/Feature/Business/FinanceLedgerPayableMappingBulkTest.php @@ -0,0 +1,87 @@ +user->attachPermission( + Permission::where('name', 'read-admin-panel')->firstOrFail() + ); + + $connection = DB::connection('business_mirror'); + $connection->table('fin_accounts')->insert([ + ['company_name' => '唯一账户测试公司甲', 'account_short' => '甲工行', 'account_type' => '公户'], + ['company_name' => '唯一公户测试公司乙', 'account_short' => '乙中行', 'account_type' => '公户'], + ['company_name' => '唯一公户测试公司乙', 'account_short' => '乙支付宝', 'account_type' => '支付宝'], + ['company_name' => '多公户测试公司丙', 'account_short' => '丙中行', 'account_type' => '公户'], + ['company_name' => '多公户测试公司丙', 'account_short' => '丙华夏', 'account_type' => '公户'], + ]); + $connection->table('fin_account_map')->insert([ + ['source' => 'payable', 'source_value' => '唯一账户测试公司甲', 'account_short' => null], + ['source' => 'payable', 'source_value' => '唯一公户测试公司乙', 'account_short' => null], + ['source' => 'payable', 'source_value' => '多公户测试公司丙', 'account_short' => null], + ['source' => 'payable', 'source_value' => '无账户测试公司丁', 'account_short' => null], + ]); + } + + private function previewUrl(): string + { + return route('business.financial-ledger.mapping.payable.preview', [ + 'company_id' => $this->company->id, + ]); + } + + private function applyUrl(): string + { + return route('business.financial-ledger.mapping.payable.apply', [ + 'company_id' => $this->company->id, + ]); + } + + public function test_preview_and_apply_single_account_or_single_public_account(): void + { + $this->loginAs()->postJson($this->previewUrl()) + ->assertOk() + ->assertJsonPath('total', 4) + ->assertJsonPath('eligible', 2) + ->assertJsonPath('ambiguous', 1) + ->assertJsonPath('missing', 1); + + $this->loginAs()->postJson($this->applyUrl()) + ->assertOk() + ->assertJsonPath('mapped', 2) + ->assertJsonPath('ambiguous', 1) + ->assertJsonPath('missing', 1); + + $this->assertDatabaseHas('fin_account_map', [ + 'source' => 'payable', + 'source_value' => '唯一账户测试公司甲', + 'account_short' => '甲工行', + 'updated_by' => 'Test User', + ], 'business_mirror'); + $this->assertDatabaseHas('fin_account_map', [ + 'source' => 'payable', + 'source_value' => '唯一公户测试公司乙', + 'account_short' => '乙中行', + ], 'business_mirror'); + $this->assertDatabaseHas('fin_account_map', [ + 'source' => 'payable', + 'source_value' => '多公户测试公司丙', + 'account_short' => null, + ], 'business_mirror'); + $this->assertDatabaseHas('fin_account_map', [ + 'source' => 'payable', + 'source_value' => '无账户测试公司丁', + 'account_short' => null, + ], 'business_mirror'); + $this->assertDatabaseCount('fin_account_map_events', 2, 'business_mirror'); + } +} -- 2.47.2 From aba8039b8d879d6cb52da7e3eb5a7ff9d5ea7c36 Mon Sep 17 00:00:00 2001 From: shanshi <3316401569@qq.com> Date: Tue, 20 Oct 2026 13:38:30 +0800 Subject: [PATCH 10/24] =?UTF-8?q?feat:=20=E5=AF=BC=E5=87=BA=E8=B4=A2?= =?UTF-8?q?=E5=8A=A1=E5=8F=B0=E8=B4=A6=E8=B4=A6=E6=88=B7=E5=BD=95=E5=85=A5?= =?UTF-8?q?=E6=A8=A1=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/Business/FinanceLedger.php | 7 + docs/财务台账开发说明.md | 1 + .../Services/FinanceLedgerAccountTemplate.php | 200 ++++++++++++++++++ .../pages/financial-ledger/ledger.blade.php | 2 +- routes/admin.php | 4 + .../FinanceLedgerAccountStatusTest.php | 1 + .../FinanceLedgerAccountTemplateTest.php | 83 ++++++++ 7 files changed, 297 insertions(+), 1 deletion(-) create mode 100644 modules/EcommerceFinance/Services/FinanceLedgerAccountTemplate.php create mode 100644 tests/Feature/Business/FinanceLedgerAccountTemplateTest.php diff --git a/app/Http/Controllers/Business/FinanceLedger.php b/app/Http/Controllers/Business/FinanceLedger.php index 536907b1..3f6c97f0 100644 --- a/app/Http/Controllers/Business/FinanceLedger.php +++ b/app/Http/Controllers/Business/FinanceLedger.php @@ -9,12 +9,19 @@ use Illuminate\Http\Request; use Illuminate\Routing\Controller; use Illuminate\Support\Facades\DB; use Illuminate\Validation\Rule; +use Modules\EcommerceFinance\Services\FinanceLedgerAccountTemplate; use Modules\EcommerceFinance\Services\FinanceLedgerCalculator; use Modules\EcommerceFinance\Services\FinanceLedgerPayableMappingBulk; use Modules\EcommerceFinance\Services\FinanceLedgerShopMappingBulk; +use Symfony\Component\HttpFoundation\StreamedResponse; class FinanceLedger extends Controller { + public function downloadAccountTemplate(FinanceLedgerAccountTemplate $template): StreamedResponse + { + return $template->download(); + } + public function updateMapping(Request $request, int $id, FinanceLedgerCalculator $calculator): JsonResponse { abort_unless(FinanceLedgerAccess::canEditMapping(user()), 403, '只有财务台账管理员可以修改账户映射。'); diff --git a/docs/财务台账开发说明.md b/docs/财务台账开发说明.md index 90809490..7c729e01 100644 --- a/docs/财务台账开发说明.md +++ b/docs/财务台账开发说明.md @@ -63,6 +63,7 @@ docker compose -f docker-compose.local.yml exec -u www-data app \ - 路由:`/workspace/financial-ledger/ledger` - 页面:账户总表、店铺台账、每日资金流水、动账明细、账户映射、数据来源 +- 账户总表支持导出空白 Excel 录入模板,包含页面列、状态和账户类型选项及填写说明 - 写权限:账户映射、账户状态和账户类型修改仅系统管理员及配置的财务台账管理员角色;其他账号只读 - 调度:`finance-ledger:sync-rpt` 每天 02:30(Asia/Shanghai) diff --git a/modules/EcommerceFinance/Services/FinanceLedgerAccountTemplate.php b/modules/EcommerceFinance/Services/FinanceLedgerAccountTemplate.php new file mode 100644 index 00000000..7d23054a --- /dev/null +++ b/modules/EcommerceFinance/Services/FinanceLedgerAccountTemplate.php @@ -0,0 +1,200 @@ +makeSpreadsheet(); + + return response()->streamDownload(function () use ($spreadsheet): void { + $writer = new Xlsx($spreadsheet); + $writer->setPreCalculateFormulas(false); + $writer->save('php://output'); + $spreadsheet->disconnectWorksheets(); + }, '财务台账-账户总表录入模板.xlsx', [ + 'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'Cache-Control' => 'no-store, no-cache, must-revalidate', + ]); + } + + private function makeSpreadsheet(): Spreadsheet + { + $spreadsheet = new Spreadsheet(); + $spreadsheet->getProperties() + ->setTitle('财务台账账户总表录入模板') + ->setSubject('账户总表重新录入'); + + $sheet = $spreadsheet->getActiveSheet(); + $sheet->setTitle('账户总表'); + $lastColumn = Coordinate::stringFromColumnIndex(count(self::HEADERS)); + $lastRow = self::MAX_ROWS + 1; + + foreach (self::HEADERS as $index => $header) { + $column = Coordinate::stringFromColumnIndex($index + 1); + $sheet->setCellValue("{$column}1", $header); + $sheet->getColumnDimension($column)->setWidth($this->width($index)); + } + + $sheet->freezePane('A2'); + $sheet->setAutoFilter("A1:{$lastColumn}1"); + $sheet->getRowDimension(1)->setRowHeight(28); + $sheet->getStyle("A1:{$lastColumn}1")->getAlignment() + ->setHorizontal(Alignment::HORIZONTAL_CENTER) + ->setVertical(Alignment::VERTICAL_CENTER) + ->setWrapText(true); + $sheet->getStyle("A1:{$lastColumn}1")->getFont()->setBold(true)->getColor()->setARGB('FFFFFFFF'); + $sheet->getStyle("A1:{$lastColumn}1")->getFill() + ->setFillType(Fill::FILL_SOLID) + ->getStartColor()->setARGB('FF856632'); + $sheet->getStyle("A1:{$lastColumn}{$lastRow}")->getBorders()->getAllBorders() + ->setBorderStyle(Border::BORDER_THIN)->getColor()->setARGB('FFD8D2C7'); + + $sheet->getStyle("A2:N{$lastRow}")->getNumberFormat()->setFormatCode(NumberFormat::FORMAT_TEXT); + $sheet->getStyle("O2:Q{$lastRow}")->getNumberFormat()->setFormatCode('#,##0.00'); + $sheet->getStyle("R2:R{$lastRow}")->getNumberFormat()->setFormatCode('0.00'); + $sheet->getStyle("S2:Y{$lastRow}")->getNumberFormat()->setFormatCode(NumberFormat::FORMAT_TEXT); + + $options = $spreadsheet->createSheet(); + $options->setTitle('选项'); + $options->setCellValue('A1', '账户状态'); + $options->setCellValue('B1', '账户类型'); + $statuses = array_values(FinanceLedgerAccountStatus::OPTIONS); + $types = $this->accountTypes(); + foreach ($statuses as $index => $status) { + $options->setCellValue('A' . ($index + 2), $status); + } + foreach ($types as $index => $type) { + $options->setCellValue('B' . ($index + 2), $type); + } + $options->setSheetState(Worksheet::SHEETSTATE_HIDDEN); + + $this->applyListValidation($sheet, 'C', '\'选项\'!$A$2:$A$' . (count($statuses) + 1), '请选择账户状态'); + if ($types !== []) { + $this->applyListValidation($sheet, 'H', '\'选项\'!$B$2:$B$' . (count($types) + 1), '请选择账户类型'); + } + + $notes = $spreadsheet->createSheet(); + $notes->setTitle('填写说明'); + $notes->fromArray([ + ['项目', '说明'], + ['用途', '账户总表重新录入模板,不包含当前账户数据。'], + ['必填', '公司名称、账户简称。'], + ['账户状态', implode(' / ', $statuses)], + ['账户类型', $types !== [] ? implode(' / ', $types) : '留空或按实际填写'], + ['金额', '当前余额、卡住金额、每日可出按数字填写;限额列保留原有组合写法。'], + ['提交前', '请核对账户数、余额合计和期初合计,再导入系统。'], + ], null, 'A1'); + $notes->getColumnDimension('A')->setWidth(16); + $notes->getColumnDimension('B')->setWidth(70); + $notes->getStyle('A1:B1')->getFont()->setBold(true)->getColor()->setARGB('FFFFFFFF'); + $notes->getStyle('A1:B1')->getFill() + ->setFillType(Fill::FILL_SOLID) + ->getStartColor()->setARGB('FF856632'); + $notes->getStyle('A1:B7')->getAlignment()->setVertical(Alignment::VERTICAL_TOP)->setWrapText(true); + $notes->getStyle('A1:B7')->getBorders()->getAllBorders() + ->setBorderStyle(Border::BORDER_THIN)->getColor()->setARGB('FFD8D2C7'); + $notes->getRowDimension(1)->setRowHeight(26); + + $spreadsheet->setActiveSheetIndex(0); + + return $spreadsheet; + } + + private function applyListValidation( + Worksheet $sheet, + string $column, + string $formula, + string $prompt, + ): void { + $validation = $sheet->getCell("{$column}2")->getDataValidation(); + $validation->setType(DataValidation::TYPE_LIST); + $validation->setErrorStyle(DataValidation::STYLE_STOP); + $validation->setAllowBlank(true); + $validation->setShowInputMessage(true); + $validation->setPromptTitle('选择' . $prompt); + $validation->setPrompt($prompt); + $validation->setShowErrorMessage(true); + $validation->setErrorTitle('选项无效'); + $validation->setError($prompt); + $validation->setFormula1($formula); + $validation->setShowDropDown(true); + + for ($row = 2; $row <= self::MAX_ROWS + 1; $row++) { + $sheet->getCell("{$column}{$row}")->setDataValidation(clone $validation); + } + } + + private function accountTypes(): array + { + $connection = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror')); + $base = $connection->table('fin_accounts') + ->whereNotNull('account_type')->where('account_type', '<>', '') + ->distinct()->pluck('account_type')->all(); + $overrides = $connection->table('fin_account_type_overrides') + ->whereNotNull('account_type')->where('account_type', '<>', '') + ->distinct()->pluck('account_type')->all(); + $types = array_values(array_unique(array_merge($base, $overrides))); + sort($types, SORT_NATURAL | SORT_FLAG_CASE); + + return $types; + } + + private function width(int $index): int + { + return match ($index) { + 0, 5 => 30, + 1, 4, 6, 23 => 22, + 2, 3, 7, 8, 17 => 13, + 14, 15, 16 => 16, + default => 18, + }; + } +} diff --git a/resources/views/business/pages/financial-ledger/ledger.blade.php b/resources/views/business/pages/financial-ledger/ledger.blade.php index c53ea74e..297ca5c0 100644 --- a/resources/views/business/pages/financial-ledger/ledger.blade.php +++ b/resources/views/business/pages/financial-ledger/ledger.blade.php @@ -145,7 +145,7 @@ @if ($activeAccountFilterCount > 0) 筛选中{{ $activeAccountFilterCount }} @endif -
refresh重置
+
账户数{{ number_format((int) ($ledgerAccountSummary['count'] ?? 0)) }}
diff --git a/routes/admin.php b/routes/admin.php index 2d2bb058..712f70d4 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -645,6 +645,10 @@ Route::post('workspace/financial-ledger/accounts/type', [\App\Http\Controllers\B ->middleware('business.page:financial-ledger,ledger') ->name('business.financial-ledger.accounts.type'); +Route::get('workspace/financial-ledger/accounts/template', [\App\Http\Controllers\Business\FinanceLedger::class, 'downloadAccountTemplate']) + ->middleware('business.page:financial-ledger,ledger') + ->name('business.financial-ledger.accounts.template'); + Route::post('workspace/financial-ledger/mapping/shop/preview', [\App\Http\Controllers\Business\FinanceLedger::class, 'previewShopMapping']) ->middleware('business.page:financial-ledger,ledger') ->name('business.financial-ledger.mapping.shop.preview'); diff --git a/tests/Feature/Business/FinanceLedgerAccountStatusTest.php b/tests/Feature/Business/FinanceLedgerAccountStatusTest.php index 7483ece1..87fca225 100644 --- a/tests/Feature/Business/FinanceLedgerAccountStatusTest.php +++ b/tests/Feature/Business/FinanceLedgerAccountStatusTest.php @@ -64,6 +64,7 @@ class FinanceLedgerAccountStatusTest extends FeatureTestCase ->assertSee('data-account-type', false) ->assertSee('ledger-type-select', false) ->assertSee('data-mapping-bulk', false) + ->assertSee('导出录入模板') ->assertSee('未填写') ->assertDontSee('data-account-filter-state', false) ->assertSee('value="normal" selected', false); diff --git a/tests/Feature/Business/FinanceLedgerAccountTemplateTest.php b/tests/Feature/Business/FinanceLedgerAccountTemplateTest.php new file mode 100644 index 00000000..afcf4dd2 --- /dev/null +++ b/tests/Feature/Business/FinanceLedgerAccountTemplateTest.php @@ -0,0 +1,83 @@ +user->attachPermission( + Permission::where('name', 'read-admin-panel')->firstOrFail() + ); + DB::connection('business_mirror')->table('fin_accounts')->insert([ + 'company_name' => '模板测试公司', + 'account_short' => '模板测试账户', + 'account_type' => '公户', + ]); + } + + public function test_account_master_template_is_blank_and_contains_page_columns_and_validations(): void + { + $response = $this->loginAs()->get(route('business.financial-ledger.accounts.template', [ + 'company_id' => $this->company->id, + ])); + $response->assertOk(); + $this->assertSame( + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + $response->headers->get('content-type'), + ); + + $path = tempnam(sys_get_temp_dir(), 'ledger-template-'); + file_put_contents($path, $response->streamedContent()); + + try { + $spreadsheet = IOFactory::load($path); + $sheet = $spreadsheet->getSheetByName('账户总表'); + $this->assertNotNull($sheet); + $this->assertSame([ + '公司名称', + '账户简称', + '账户状态', + '地区', + '账号', + '开户行', + '开户城市/网点', + '账户类型', + '公司规划', + '后4位', + '单笔限额', + '日限额', + '月限额', + '年限额', + '当前余额', + '卡住金额', + '每日可出', + '预计出清天数', + '提额目标', + '提额状态', + '提额动作', + '已做动作', + '下一步', + '备注', + '责任人', + ], $sheet->rangeToArray('A1:Y1')[0]); + $this->assertNull($sheet->getCell('A2')->getValue()); + $this->assertSame(DataValidation::TYPE_LIST, $sheet->getCell('C2')->getDataValidation()->getType()); + $this->assertSame(DataValidation::TYPE_LIST, $sheet->getCell('H2')->getDataValidation()->getType()); + $this->assertSame(Worksheet::SHEETSTATE_HIDDEN, $spreadsheet->getSheetByName('选项')->getSheetState()); + $this->assertNotNull($spreadsheet->getSheetByName('填写说明')); + $spreadsheet->disconnectWorksheets(); + } finally { + @unlink($path); + } + } +} -- 2.47.2 From 2ddb5296296eebe569238d5deefbaa5770b68195 Mon Sep 17 00:00:00 2001 From: shanshi <3316401569@qq.com> Date: Tue, 20 Oct 2026 13:43:26 +0800 Subject: [PATCH 11/24] =?UTF-8?q?fix:=20=E6=8F=90=E7=A4=BA=E8=B4=A6?= =?UTF-8?q?=E6=88=B7=E5=BD=95=E5=85=A5=E6=A8=A1=E6=9D=BF=E4=B8=8B=E8=BD=BD?= =?UTF-8?q?=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/financial-ledger/ledger.blade.php | 13 ++++++++++++- .../Business/FinanceLedgerAccountStatusTest.php | 2 ++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/resources/views/business/pages/financial-ledger/ledger.blade.php b/resources/views/business/pages/financial-ledger/ledger.blade.php index 297ca5c0..b387515d 100644 --- a/resources/views/business/pages/financial-ledger/ledger.blade.php +++ b/resources/views/business/pages/financial-ledger/ledger.blade.php @@ -54,6 +54,7 @@ .ledger-mapping-bulk { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:.75rem; } .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; } @@ -145,7 +146,8 @@ @if ($activeAccountFilterCount > 0) 筛选中{{ $activeAccountFilterCount }} @endif - + +
账户数{{ number_format((int) ($ledgerAccountSummary['count'] ?? 0)) }}
@@ -344,6 +346,15 @@ notify.timer = window.setTimeout(() => toast.classList.remove('is-visible'), 2600); }; + const accountTemplateDownload = page.querySelector('[data-account-template-download]'); + accountTemplateDownload?.addEventListener('click', () => { + const status = page.querySelector('[data-account-template-download-status]'); + if (status) { + status.hidden = false; + status.textContent = '下载已开始,请查看浏览器下载目录:财务台账-账户总表录入模板.xlsx'; + } + }); + const postMappingBulk = async (url) => { const response = await fetch(url, { method: 'POST', diff --git a/tests/Feature/Business/FinanceLedgerAccountStatusTest.php b/tests/Feature/Business/FinanceLedgerAccountStatusTest.php index 87fca225..09883be7 100644 --- a/tests/Feature/Business/FinanceLedgerAccountStatusTest.php +++ b/tests/Feature/Business/FinanceLedgerAccountStatusTest.php @@ -65,6 +65,8 @@ class FinanceLedgerAccountStatusTest extends FeatureTestCase ->assertSee('ledger-type-select', false) ->assertSee('data-mapping-bulk', false) ->assertSee('导出录入模板') + ->assertSee('data-account-template-download', false) + ->assertSee('data-account-template-download-status', false) ->assertSee('未填写') ->assertDontSee('data-account-filter-state', false) ->assertSee('value="normal" selected', false); -- 2.47.2 From 1912affa3791493d2ed1bb820fc55e50da6b7ab0 Mon Sep 17 00:00:00 2001 From: shanshi <3316401569@qq.com> Date: Tue, 20 Oct 2026 13:58:39 +0800 Subject: [PATCH 12/24] =?UTF-8?q?style:=20=E8=B0=83=E6=95=B4=E8=B4=A6?= =?UTF-8?q?=E6=88=B7=E7=B1=BB=E5=9E=8B=E5=88=97=E4=BD=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Services/FinanceLedgerAccountTemplate.php | 12 ++++----- .../pages/financial-ledger/ledger.blade.php | 26 +++++++++---------- .../FinanceLedgerAccountStatusTest.php | 1 + .../FinanceLedgerAccountTemplateTest.php | 4 +-- 4 files changed, 22 insertions(+), 21 deletions(-) diff --git a/modules/EcommerceFinance/Services/FinanceLedgerAccountTemplate.php b/modules/EcommerceFinance/Services/FinanceLedgerAccountTemplate.php index 7d23054a..3782325c 100644 --- a/modules/EcommerceFinance/Services/FinanceLedgerAccountTemplate.php +++ b/modules/EcommerceFinance/Services/FinanceLedgerAccountTemplate.php @@ -25,12 +25,12 @@ class FinanceLedgerAccountTemplate private const HEADERS = [ '公司名称', '账户简称', + '账户类型', '账户状态', '地区', '账号', '开户行', '开户城市/网点', - '账户类型', '公司规划', '后4位', '单笔限额', @@ -116,9 +116,9 @@ class FinanceLedgerAccountTemplate } $options->setSheetState(Worksheet::SHEETSTATE_HIDDEN); - $this->applyListValidation($sheet, 'C', '\'选项\'!$A$2:$A$' . (count($statuses) + 1), '请选择账户状态'); + $this->applyListValidation($sheet, 'D', '\'选项\'!$A$2:$A$' . (count($statuses) + 1), '请选择账户状态'); if ($types !== []) { - $this->applyListValidation($sheet, 'H', '\'选项\'!$B$2:$B$' . (count($types) + 1), '请选择账户类型'); + $this->applyListValidation($sheet, 'C', '\'选项\'!$B$2:$B$' . (count($types) + 1), '请选择账户类型'); } $notes = $spreadsheet->createSheet(); @@ -190,9 +190,9 @@ class FinanceLedgerAccountTemplate private function width(int $index): int { return match ($index) { - 0, 5 => 30, - 1, 4, 6, 23 => 22, - 2, 3, 7, 8, 17 => 13, + 0, 6 => 30, + 1, 5, 7, 23 => 22, + 2, 3, 4, 8, 17 => 13, 14, 15, 16 => 16, default => 18, }; diff --git a/resources/views/business/pages/financial-ledger/ledger.blade.php b/resources/views/business/pages/financial-ledger/ledger.blade.php index b387515d..60d7a5fe 100644 --- a/resources/views/business/pages/financial-ledger/ledger.blade.php +++ b/resources/views/business/pages/financial-ledger/ledger.blade.php @@ -157,24 +157,12 @@
来源原始值笔数收入合计支出合计映射账户简称状态
{{ $sourceLabels[$row->source] ?? $row->source }}{{ $row->source_value }}{{ number_format((int) $row->txn_count) }}{{ $money($row->income_sum) }}{{ $money($row->expense_sum) }}@if ($canEdit)@else{{ $row->account_short ?: '未匹配' }}@endif{{ $row->account_short ? '已映射' : '未匹配' }}
没有匹配的映射记录。
- + @forelse ($ledgerAccounts as $row) - - + + @empty diff --git a/tests/Feature/Business/FinanceLedgerAccountStatusTest.php b/tests/Feature/Business/FinanceLedgerAccountStatusTest.php index 09883be7..109d6c67 100644 --- a/tests/Feature/Business/FinanceLedgerAccountStatusTest.php +++ b/tests/Feature/Business/FinanceLedgerAccountStatusTest.php @@ -67,6 +67,7 @@ class FinanceLedgerAccountStatusTest extends FeatureTestCase ->assertSee('导出录入模板') ->assertSee('data-account-template-download', false) ->assertSee('data-account-template-download-status', false) + ->assertSee('', false) ->assertSee('未填写') ->assertDontSee('data-account-filter-state', false) ->assertSee('value="normal" selected', false); diff --git a/tests/Feature/Business/FinanceLedgerAccountTemplateTest.php b/tests/Feature/Business/FinanceLedgerAccountTemplateTest.php index afcf4dd2..caef1f5f 100644 --- a/tests/Feature/Business/FinanceLedgerAccountTemplateTest.php +++ b/tests/Feature/Business/FinanceLedgerAccountTemplateTest.php @@ -46,12 +46,12 @@ class FinanceLedgerAccountTemplateTest extends FeatureTestCase $this->assertSame([ '公司名称', '账户简称', + '账户类型', '账户状态', '地区', '账号', '开户行', '开户城市/网点', - '账户类型', '公司规划', '后4位', '单笔限额', @@ -72,7 +72,7 @@ class FinanceLedgerAccountTemplateTest extends FeatureTestCase ], $sheet->rangeToArray('A1:Y1')[0]); $this->assertNull($sheet->getCell('A2')->getValue()); $this->assertSame(DataValidation::TYPE_LIST, $sheet->getCell('C2')->getDataValidation()->getType()); - $this->assertSame(DataValidation::TYPE_LIST, $sheet->getCell('H2')->getDataValidation()->getType()); + $this->assertSame(DataValidation::TYPE_LIST, $sheet->getCell('D2')->getDataValidation()->getType()); $this->assertSame(Worksheet::SHEETSTATE_HIDDEN, $spreadsheet->getSheetByName('选项')->getSheetState()); $this->assertNotNull($spreadsheet->getSheetByName('填写说明')); $spreadsheet->disconnectWorksheets(); -- 2.47.2 From b2c7a3ac8f1a21c88464377cea5103a1851d3179 Mon Sep 17 00:00:00 2001 From: shanshi <3316401569@qq.com> Date: Tue, 20 Oct 2026 13:59:29 +0800 Subject: [PATCH 13/24] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E7=BD=91?= =?UTF-8?q?=E5=95=86=E9=93=B6=E8=A1=8C=E8=B4=A6=E6=88=B7=E7=B1=BB=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/Business/FinanceLedger.php | 5 +-- app/Utilities/FinanceLedgerAccountType.php | 31 +++++++++++++++++++ .../Services/FinanceLedgerAccountTemplate.php | 6 ++-- .../View/FinanceLedgerPageData.php | 4 +-- .../FinanceLedgerAccountStatusTest.php | 7 +++-- .../FinanceLedgerAccountTemplateTest.php | 5 +++ 6 files changed, 47 insertions(+), 11 deletions(-) create mode 100644 app/Utilities/FinanceLedgerAccountType.php diff --git a/app/Http/Controllers/Business/FinanceLedger.php b/app/Http/Controllers/Business/FinanceLedger.php index 3f6c97f0..0c877320 100644 --- a/app/Http/Controllers/Business/FinanceLedger.php +++ b/app/Http/Controllers/Business/FinanceLedger.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Business; use App\Utilities\FinanceLedgerAccountStatus; +use App\Utilities\FinanceLedgerAccountType; use App\Utilities\FinanceLedgerAccess; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -138,14 +139,14 @@ class FinanceLedger extends Controller ); if ($accountType !== '') { - $allowedTypes = array_values(array_unique(array_merge( + $allowedTypes = FinanceLedgerAccountType::options( $connection->table('fin_accounts') ->whereNotNull('account_type')->where('account_type', '<>', '') ->distinct()->pluck('account_type')->all(), $connection->table('fin_account_type_overrides') ->whereNotNull('account_type')->where('account_type', '<>', '') ->distinct()->pluck('account_type')->all(), - ))); + ); abort_unless(in_array($accountType, $allowedTypes, true), 422, '请选择现有账户类型。'); } diff --git a/app/Utilities/FinanceLedgerAccountType.php b/app/Utilities/FinanceLedgerAccountType.php new file mode 100644 index 00000000..92518e36 --- /dev/null +++ b/app/Utilities/FinanceLedgerAccountType.php @@ -0,0 +1,31 @@ +table('fin_account_type_overrides') ->whereNotNull('account_type')->where('account_type', '<>', '') ->distinct()->pluck('account_type')->all(); - $types = array_values(array_unique(array_merge($base, $overrides))); - sort($types, SORT_NATURAL | SORT_FLAG_CASE); - - return $types; + return FinanceLedgerAccountType::options($base, $overrides); } private function width(int $index): int diff --git a/modules/EcommerceFinance/View/FinanceLedgerPageData.php b/modules/EcommerceFinance/View/FinanceLedgerPageData.php index 2150ca04..e027325b 100644 --- a/modules/EcommerceFinance/View/FinanceLedgerPageData.php +++ b/modules/EcommerceFinance/View/FinanceLedgerPageData.php @@ -3,6 +3,7 @@ namespace Modules\EcommerceFinance\View; use App\Utilities\FinanceLedgerAccountStatus; +use App\Utilities\FinanceLedgerAccountType; use App\Utilities\FinanceLedgerAccess; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Database\Query\Builder; @@ -360,8 +361,7 @@ class FinanceLedgerPageData $overrideTypes = $this->db()->table('fin_account_type_overrides') ->whereNotNull('account_type')->where('account_type', '<>', '') ->distinct()->orderBy('account_type')->pluck('account_type')->all(); - $types = array_values(array_unique(array_merge($baseTypes, $overrideTypes))); - sort($types, SORT_NATURAL | SORT_FLAG_CASE); + $types = FinanceLedgerAccountType::options($baseTypes, $overrideTypes); return [ 'regions' => $this->db()->table('fin_accounts') diff --git a/tests/Feature/Business/FinanceLedgerAccountStatusTest.php b/tests/Feature/Business/FinanceLedgerAccountStatusTest.php index 109d6c67..52d59abe 100644 --- a/tests/Feature/Business/FinanceLedgerAccountStatusTest.php +++ b/tests/Feature/Business/FinanceLedgerAccountStatusTest.php @@ -68,6 +68,7 @@ class FinanceLedgerAccountStatusTest extends FeatureTestCase ->assertSee('data-account-template-download', false) ->assertSee('data-account-template-download-status', false) ->assertSee('', false) + ->assertSee('网商银行') ->assertSee('未填写') ->assertDontSee('data-account-filter-state', false) ->assertSee('value="normal" selected', false); @@ -159,11 +160,11 @@ class FinanceLedgerAccountStatusTest extends FeatureTestCase $this->loginAs()->postJson($this->typeUrl(), [ 'account_short' => '类型账户甲', - 'account_type' => '支付宝', + 'account_type' => '网商银行', ]) ->assertOk() - ->assertJsonPath('account_type', '支付宝') - ->assertJsonPath('label', '支付宝'); + ->assertJsonPath('account_type', '网商银行') + ->assertJsonPath('label', '网商银行'); $this->loginAs()->postJson($this->typeUrl(), [ 'account_short' => '类型账户甲', diff --git a/tests/Feature/Business/FinanceLedgerAccountTemplateTest.php b/tests/Feature/Business/FinanceLedgerAccountTemplateTest.php index caef1f5f..d928a68c 100644 --- a/tests/Feature/Business/FinanceLedgerAccountTemplateTest.php +++ b/tests/Feature/Business/FinanceLedgerAccountTemplateTest.php @@ -74,6 +74,11 @@ class FinanceLedgerAccountTemplateTest extends FeatureTestCase $this->assertSame(DataValidation::TYPE_LIST, $sheet->getCell('C2')->getDataValidation()->getType()); $this->assertSame(DataValidation::TYPE_LIST, $sheet->getCell('D2')->getDataValidation()->getType()); $this->assertSame(Worksheet::SHEETSTATE_HIDDEN, $spreadsheet->getSheetByName('选项')->getSheetState()); + $typeOptions = array_map( + static fn (array $row): string => (string) ($row[0] ?? ''), + $spreadsheet->getSheetByName('选项')->rangeToArray('B2:B20'), + ); + $this->assertContains('网商银行', $typeOptions); $this->assertNotNull($spreadsheet->getSheetByName('填写说明')); $spreadsheet->disconnectWorksheets(); } finally { -- 2.47.2 From aef239cfb14f007c4145a7475a0ebb803e44f5f8 Mon Sep 17 00:00:00 2001 From: shanshi <3316401569@qq.com> Date: Tue, 20 Oct 2026 14:00:37 +0800 Subject: [PATCH 14/24] =?UTF-8?q?style:=20=E5=B1=85=E4=B8=AD=E8=B4=A6?= =?UTF-8?q?=E6=88=B7=E6=80=BB=E8=A1=A8=E8=A1=A8=E5=A4=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../views/business/pages/financial-ledger/ledger.blade.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/resources/views/business/pages/financial-ledger/ledger.blade.php b/resources/views/business/pages/financial-ledger/ledger.blade.php index 60d7a5fe..dc9a6701 100644 --- a/resources/views/business/pages/financial-ledger/ledger.blade.php +++ b/resources/views/business/pages/financial-ledger/ledger.blade.php @@ -70,6 +70,8 @@ .ledger-table th:first-child { z-index:3; background:var(--vip-content-accent-soft, #F7F0DE); } .ledger-table tr:hover td:first-child { background:#fcfaf4; } .ledger-table td.num, .ledger-table th.num { text-align:right; font-variant-numeric:tabular-nums; } + .ledger-accounts-table thead th, + .ledger-accounts-table thead th.num { text-align:center; } .ledger-table td.wrap { white-space:normal; min-width:14rem; max-width:22rem; line-height:1.5; } .ledger-table .muted { color:var(--vip-content-muted, #595959); } .ledger-table .danger { color:var(--vip-danger, #B80000); } -- 2.47.2 From 8f41ae393698892c3be2538807de2d4c8cecac2a Mon Sep 17 00:00:00 2001 From: shanshi <3316401569@qq.com> Date: Tue, 20 Oct 2026 14:03:20 +0800 Subject: [PATCH 15/24] =?UTF-8?q?feat:=20=E5=9B=BA=E5=AE=9A=E8=B4=A6?= =?UTF-8?q?=E6=88=B7=E8=A1=A8=E5=A4=B4=E5=B9=B6=E5=A2=9E=E5=8A=A0=E9=A1=B5?= =?UTF-8?q?=E7=A0=81=E9=80=89=E6=8B=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/financial-ledger/ledger.blade.php | 33 +++++++++++++++++-- .../FinanceLedgerAccountStatusTest.php | 26 +++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/resources/views/business/pages/financial-ledger/ledger.blade.php b/resources/views/business/pages/financial-ledger/ledger.blade.php index dc9a6701..632faa7a 100644 --- a/resources/views/business/pages/financial-ledger/ledger.blade.php +++ b/resources/views/business/pages/financial-ledger/ledger.blade.php @@ -76,8 +76,13 @@ .ledger-table .muted { color:var(--vip-content-muted, #595959); } .ledger-table .danger { color:var(--vip-danger, #B80000); } .ledger-table .success { color:var(--vip-success, #53793E); } + .ledger-accounts-table-wrap { max-height:calc(100vh - 390px); min-height:20rem; } .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 { color:var(--vip-content-accent-dark, #856632); text-decoration:none; margin-left:.75rem; font-weight:600; } + .ledger-pagination__controls { display:flex; align-items:center; justify-content:flex-end; gap:.45rem; margin-left:auto; } + .ledger-page-picker { display:inline-flex; align-items:center; gap:.3rem; color:var(--vip-content-muted, #595959); white-space:nowrap; } + .ledger-page-picker select { height:1.9rem; min-width:4rem; padding:0 .35rem; border:1px solid var(--vip-border, #D8D2C7); border-radius:.4rem; background:#fff; color:var(--vip-content-ink, #171717); font:inherit; font-weight:600; cursor:pointer; } + .ledger-page-picker select:focus { border-color:var(--vip-content-accent-dark, #856632); outline:none; box-shadow:0 0 0 3px rgba(133,102,50,.16); } .ledger-empty { padding:3.5rem 1rem; text-align:center; color:var(--vip-content-muted, #595959); } .ledger-progress { display:flex; align-items:center; gap:.65rem; min-width:15rem; } .ledger-progress__track { flex:1; height:.45rem; overflow:hidden; border-radius:999px; background:#e8e3da; } @@ -157,7 +162,7 @@
卡住金额合计{{ $money($ledgerAccountSummary['blocked'] ?? 0) }}
每日可出金额口径余额 − 卡住金额
-
+
公司名称账户简称账户状态地区账号开户行开户城市/网点账户类型公司规划后4位单笔限额日限额月限额年限额当前余额卡住金额每日可出预计出清天数提额目标提额状态提额动作已做动作下一步备注责任人
公司名称账户简称账户类型账户状态地区账号开户行开户城市/网点公司规划后4位单笔限额日限额月限额年限额当前余额卡住金额每日可出预计出清天数提额目标提额状态提额动作已做动作下一步备注责任人
{{ $row->company_name }} {{ $row->account_short }} - @if ($canEditAccountStatus) - - @else - {{ $accountStatusOptions[$row->account_status] ?? '正常' }} - @endif - {{ $row->region }}{{ $row->account_no }}{{ $row->bank_name }}{{ $row->bank_city_branch }} @if ($canEditAccountType) + @if ($canEditAccountStatus) + + @else + {{ $accountStatusOptions[$row->account_status] ?? '正常' }} + @endif + {{ $row->region }}{{ $row->account_no }}{{ $row->bank_name }}{{ $row->bank_city_branch }} {{ $row->company_plan }}{{ $row->account_last4 }}{{ $row->single_limit }}{{ $row->daily_limit }}{{ $row->monthly_limit }}{{ $row->yearly_limit }}{{ $money($row->current_balance) }}{{ $money($row->blocked_amount) }}{{ $money($row->daily_available) }}{{ $row->clearing_days !== null ? number_format((float) $row->clearing_days, 2) : '' }}{{ $row->raise_target }}{{ $row->raise_status }}{{ $row->raise_actions }}{{ $row->done_actions }}{{ $row->next_action }}{{ $row->remark }}{{ $row->owner }}
账户简称账户类型账户状态账户简称账户类型账户状态
@@ -196,7 +201,25 @@ @endforelse
公司名称账户简称账户类型账户状态地区账号开户行开户城市/网点公司规划后4位单笔限额日限额月限额年限额当前余额卡住金额每日可出预计出清天数提额目标提额状态提额动作已做动作下一步备注责任人
-
共 {{ number_format($ledgerAccounts->total()) }} 条 · 第 {{ $ledgerAccounts->currentPage() }} / {{ $ledgerAccounts->lastPage() }} 页@if (!$ledgerAccounts->onFirstPage())上一页@endif @if ($ledgerAccounts->hasMorePages())下一页@endif
+ +
+ 共 {{ number_format($ledgerAccounts->total()) }} 条 · 每页 100 条 +
+ @if (!$ledgerAccounts->onFirstPage()) + 上一页 + @endif + + @if ($ledgerAccounts->hasMorePages()) + 下一页 + @endif +
@@ -338,6 +361,12 @@ tabs.forEach((tab) => tab.addEventListener('click', () => showTab(tab.dataset.ledgerTab))); if (window.location.hash === '#mapping') showTab('mapping', false); + page.querySelectorAll('[data-ledger-page-select]').forEach((select) => { + select.addEventListener('change', () => { + if (select.value) window.location.assign(select.value); + }); + }); + const toast = page.querySelector('[data-ledger-toast]'); const notify = (message, error = false) => { if (!toast) return; diff --git a/tests/Feature/Business/FinanceLedgerAccountStatusTest.php b/tests/Feature/Business/FinanceLedgerAccountStatusTest.php index 52d59abe..8f77b848 100644 --- a/tests/Feature/Business/FinanceLedgerAccountStatusTest.php +++ b/tests/Feature/Business/FinanceLedgerAccountStatusTest.php @@ -69,11 +69,37 @@ class FinanceLedgerAccountStatusTest extends FeatureTestCase ->assertSee('data-account-template-download-status', false) ->assertSee('
账户简称账户类型账户状态
- + @forelse ($ledgerAccounts as $row) diff --git a/tests/Feature/Business/FinanceLedgerAccountStatusTest.php b/tests/Feature/Business/FinanceLedgerAccountStatusTest.php index afc4c336..6932cb36 100644 --- a/tests/Feature/Business/FinanceLedgerAccountStatusTest.php +++ b/tests/Feature/Business/FinanceLedgerAccountStatusTest.php @@ -76,6 +76,9 @@ class FinanceLedgerAccountStatusTest extends FeatureTestCase ->assertSee('data-ledger-column-picker', false) ->assertSee('data-ledger-column-panel', false) ->assertSee('列设置') + ->assertSee('可用金额合计') + ->assertSee('不可用金额合计') + ->assertSee('可用金额 − 不可用金额') ->assertSee('未填写') ->assertDontSee('data-account-filter-state', false) ->assertSee('value="normal" selected', false); diff --git a/tests/Feature/Business/FinanceLedgerAccountTemplateTest.php b/tests/Feature/Business/FinanceLedgerAccountTemplateTest.php index d928a68c..bfecef65 100644 --- a/tests/Feature/Business/FinanceLedgerAccountTemplateTest.php +++ b/tests/Feature/Business/FinanceLedgerAccountTemplateTest.php @@ -58,8 +58,8 @@ class FinanceLedgerAccountTemplateTest extends FeatureTestCase '日限额', '月限额', '年限额', - '当前余额', - '卡住金额', + '可用金额', + '不可用金额', '每日可出', '预计出清天数', '提额目标', -- 2.47.2 From 564fb599919a48bf5c3daf99b57113daf68a5bda Mon Sep 17 00:00:00 2001 From: shanshi <3316401569@qq.com> Date: Wed, 21 Oct 2026 10:30:23 +0800 Subject: [PATCH 24/24] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E8=87=AA?= =?UTF-8?q?=E5=AE=9A=E4=B9=89=E8=B4=A6=E6=88=B7=E5=88=97=E9=A1=BA=E5=BA=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/financial-ledger/ledger.blade.php | 184 ++++++++++++++---- .../FinanceLedgerAccountStatusTest.php | 2 + 2 files changed, 149 insertions(+), 37 deletions(-) diff --git a/resources/views/business/pages/financial-ledger/ledger.blade.php b/resources/views/business/pages/financial-ledger/ledger.blade.php index e7f3ccc8..3560b03a 100644 --- a/resources/views/business/pages/financial-ledger/ledger.blade.php +++ b/resources/views/business/pages/financial-ledger/ledger.blade.php @@ -54,11 +54,15 @@ .ledger-column-picker { position:relative; align-self:flex-end; } .ledger-column-panel { position:absolute; top:calc(100% + .35rem); left:0; z-index:45; width:min(30rem, calc(100vw - 2rem)); max-height:min(25rem, 65vh); overflow:auto; padding:.75rem; border:1px solid var(--vip-border, #D8D2C7); border-radius:.55rem; background:#fff; box-shadow:0 4px 12px rgba(23,23,23,.14); } .ledger-column-panel[hidden] { display:none !important; } + .ledger-column-help { margin:0 0 .55rem; color:var(--vip-content-muted, #595959); font-size:.68rem; line-height:1.5; } .ledger-column-options { display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:.25rem .8rem; } - .ledger-column-option { display:flex; align-items:center; gap:.4rem; min-width:0; min-height:1.8rem; color:var(--vip-content-ink, #171717); font-size:.72rem; cursor:pointer; } + .ledger-column-option { display:flex; align-items:center; gap:.4rem; min-width:0; min-height:1.8rem; color:var(--vip-content-ink, #171717); font-size:.72rem; cursor:grab; } + .ledger-column-option.is-dragging { opacity:.45; } + .ledger-column-drag { flex:0 0 auto; color:var(--vip-content-muted, #595959); font-size:.95rem; } .ledger-column-option input { flex:0 0 auto; accent-color:var(--vip-content-accent-dark, #856632); } - .ledger-column-option span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } + .ledger-column-label { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } .ledger-column-actions { display:flex; align-items:center; justify-content:space-between; gap:.5rem; margin-top:.55rem; padding-top:.55rem; border-top:1px solid #e8e3da; color:var(--vip-content-muted, #595959); font-size:.68rem; } + .ledger-column-action-group { display:flex; gap:.4rem; } .ledger-column-actions button { height:1.8rem; padding:0 .6rem; border:1px solid var(--vip-border, #D8D2C7); border-radius:.35rem; background:#fff; color:var(--vip-content-accent-dark, #856632); font:inherit; font-weight:600; cursor:pointer; } .ledger-mapping-bulk { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:.75rem; } .ledger-mapping-bulk-actions { display:flex; flex-wrap:wrap; gap:.45rem; } @@ -172,11 +176,12 @@ @if ($activeAccountFilterCount > 0) 筛选中{{ $activeAccountFilterCount }} @endif -
+
@@ -417,60 +422,108 @@ const accountColumnHeaders = () => Array.from( accountPanel?.querySelectorAll('.ledger-accounts-table thead th') || [], ); - const accountColumnIndexes = () => accountColumnHeaders().map((_, index) => index); - let visibleAccountColumns = accountColumnIndexes(); + const initialAccountColumnHeaders = accountColumnHeaders(); + const initialAccountColumnKeys = initialAccountColumnHeaders.map((_, index) => 'account-column-' + index); + initialAccountColumnHeaders.forEach((header, index) => { + header.dataset.ledgerColumnKey = initialAccountColumnKeys[index]; + }); + let visibleAccountColumns = new Set(initialAccountColumnKeys); + let accountColumnOrder = [...initialAccountColumnKeys]; - const normaliseColumnIndexes = (values, count) => { - if (!Array.isArray(values)) return Array.from({ length: count }, (_, index) => index); - return [...new Set(values.map(Number).filter((index) => Number.isInteger(index) && index >= 0 && index < count))] - .sort((left, right) => left - right); + const normaliseColumnKeys = (values, fallback) => { + const valid = new Set(initialAccountColumnKeys); + const result = Array.isArray(values) + ? [...new Set(values.filter((value) => valid.has(value)))] + : []; + fallback.forEach((value) => { + if (!result.includes(value)) result.push(value); + }); + + return result; }; - const applyAccountColumnVisibility = () => { + const applyAccountColumnLayout = () => { const table = accountPanel?.querySelector('.ledger-accounts-table'); if (!table) return; + const headerRow = table.tHead?.rows?.[0]; + if (!headerRow) return; + const headersByKey = new Map( + [...headerRow.children].map((header) => [header.dataset.ledgerColumnKey, header]), + ); + accountColumnOrder.forEach((key) => { + const header = headersByKey.get(key); + if (header) headerRow.appendChild(header); + }); + const visible = new Set(visibleAccountColumns); - table.querySelectorAll('thead th').forEach((header, index) => { - header.classList.toggle('is-column-hidden', !visible.has(index)); + table.querySelectorAll('thead th').forEach((header) => { + header.classList.toggle('is-column-hidden', !visible.has(header.dataset.ledgerColumnKey)); }); table.querySelectorAll('tbody tr').forEach((row) => { [...row.children].forEach((cell, index) => { - cell.classList.toggle('is-column-hidden', !visible.has(index)); + if (!cell.dataset.ledgerColumnKey) { + cell.dataset.ledgerColumnKey = initialAccountColumnKeys[index] || ('account-column-extra-' + index); + } + }); + const cellsByKey = new Map( + [...row.children].map((cell) => [cell.dataset.ledgerColumnKey, cell]), + ); + accountColumnOrder.forEach((key) => { + const cell = cellsByKey.get(key); + if (cell) row.appendChild(cell); + }); + [...row.children].forEach((cell) => { + cell.classList.toggle('is-column-hidden', !visible.has(cell.dataset.ledgerColumnKey)); }); }); if (accountColumnCount) { - accountColumnCount.textContent = '显示 ' + visible.size + ' / ' + accountColumnHeaders().length + ' 列'; + accountColumnCount.textContent = '显示 ' + visible.size + ' / ' + initialAccountColumnKeys.length + ' 列'; } }; - const saveAccountColumnVisibility = () => { + const saveAccountColumnPreferences = () => { try { - localStorage.setItem(accountColumnKey, JSON.stringify(visibleAccountColumns)); + localStorage.setItem(accountColumnKey, JSON.stringify({ + visible: [...visibleAccountColumns], + order: accountColumnOrder, + })); } catch (_) {} }; if (accountColumnPicker && accountColumnPanel && accountColumnOptions && accountColumnToggle) { - const headers = accountColumnHeaders(); + const headersByKey = new Map( + initialAccountColumnHeaders.map((header, index) => [initialAccountColumnKeys[index], header]), + ); try { - visibleAccountColumns = normaliseColumnIndexes( - JSON.parse(localStorage.getItem(accountColumnKey) || 'null'), - headers.length, - ); + const saved = JSON.parse(localStorage.getItem(accountColumnKey) || 'null'); + const savedVisible = Array.isArray(saved?.visible) ? saved.visible : initialAccountColumnKeys; + visibleAccountColumns = new Set(normaliseColumnKeys(savedVisible, initialAccountColumnKeys)); + accountColumnOrder = normaliseColumnKeys(saved?.order, initialAccountColumnKeys); } catch (_) { - visibleAccountColumns = accountColumnIndexes(); + visibleAccountColumns = new Set(initialAccountColumnKeys); + accountColumnOrder = [...initialAccountColumnKeys]; } - headers.forEach((header, index) => { + accountColumnOrder.forEach((key) => { + const header = headersByKey.get(key); + if (!header) return; const label = document.createElement('label'); label.className = 'ledger-column-option'; + label.draggable = true; + label.dataset.ledgerColumnKey = key; + const handle = document.createElement('span'); + handle.className = 'material-icons-outlined ledger-column-drag'; + handle.setAttribute('aria-hidden', 'true'); + handle.textContent = 'drag_indicator'; const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; - checkbox.value = String(index); - checkbox.checked = visibleAccountColumns.includes(index); + checkbox.value = key; + checkbox.checked = visibleAccountColumns.has(key); checkbox.dataset.ledgerColumnCheck = 'true'; const text = document.createElement('span'); - text.textContent = header.textContent.trim() || ('第 ' + (index + 1) + ' 列'); - label.append(checkbox, text); + text.className = 'ledger-column-label'; + text.textContent = header.textContent.trim() || '未命名字段'; + label.append(handle, checkbox, text); accountColumnOptions.appendChild(label); }); @@ -478,15 +531,15 @@ const checkbox = event.target.closest('[data-ledger-column-check]'); if (!checkbox) return; const next = Array.from(accountColumnOptions.querySelectorAll('[data-ledger-column-check]:checked')) - .map((item) => Number(item.value)); + .map((item) => item.value); if (next.length === 0) { checkbox.checked = true; notify('账户总表至少保留一列', true); return; } - visibleAccountColumns = next.sort((left, right) => left - right); - saveAccountColumnVisibility(); - applyAccountColumnVisibility(); + visibleAccountColumns = new Set(next); + saveAccountColumnPreferences(); + applyAccountColumnLayout(); }); accountColumnToggle.addEventListener('click', () => { @@ -498,10 +551,67 @@ accountColumnOptions.querySelectorAll('[data-ledger-column-check]').forEach((checkbox) => { checkbox.checked = true; }); - visibleAccountColumns = accountColumnIndexes(); - saveAccountColumnVisibility(); - applyAccountColumnVisibility(); + visibleAccountColumns = new Set(initialAccountColumnKeys); + saveAccountColumnPreferences(); + applyAccountColumnLayout(); }); + accountColumnPicker.querySelector('[data-ledger-column-reset]')?.addEventListener('click', () => { + visibleAccountColumns = new Set(initialAccountColumnKeys); + accountColumnOrder = [...initialAccountColumnKeys]; + accountColumnOptions.querySelectorAll('[data-ledger-column-check]').forEach((checkbox) => { + checkbox.checked = true; + }); + accountColumnOrder.forEach((key) => { + const option = accountColumnOptions.querySelector('[data-ledger-column-key="' + key + '"]'); + if (option) accountColumnOptions.appendChild(option); + }); + saveAccountColumnPreferences(); + applyAccountColumnLayout(); + }); + + let draggedColumnOption = null; + const commitColumnOptionOrder = () => { + accountColumnOrder = Array.from( + accountColumnOptions.querySelectorAll('[data-ledger-column-key]'), + (option) => option.dataset.ledgerColumnKey, + ); + saveAccountColumnPreferences(); + applyAccountColumnLayout(); + }; + accountColumnOptions.querySelectorAll('[data-ledger-column-key]').forEach((option) => { + option.addEventListener('dragstart', () => { + draggedColumnOption = option; + option.classList.add('is-dragging'); + }); + option.addEventListener('dragover', (event) => { + event.preventDefault(); + if (!draggedColumnOption || draggedColumnOption === option) return; + const rect = option.getBoundingClientRect(); + const insertAfter = event.clientY > rect.top + rect.height / 2; + accountColumnOptions.insertBefore( + draggedColumnOption, + insertAfter ? option.nextSibling : option, + ); + }); + option.addEventListener('dragend', () => { + option.classList.remove('is-dragging'); + draggedColumnOption = null; + commitColumnOptionOrder(); + }); + option.addEventListener('keydown', (event) => { + if (!event.altKey || !['ArrowUp', 'ArrowDown'].includes(event.key)) return; + event.preventDefault(); + const ordered = Array.from(accountColumnOptions.querySelectorAll('[data-ledger-column-key]')); + const index = ordered.indexOf(option); + const next = Math.max(0, Math.min(ordered.length - 1, index + (event.key === 'ArrowUp' ? -1 : 1))); + if (next === index) return; + if (next < index) accountColumnOptions.insertBefore(option, ordered[next]); + else accountColumnOptions.insertBefore(option, ordered[next].nextSibling); + commitColumnOptionOrder(); + option.querySelector('input')?.focus(); + }); + }); + document.addEventListener('click', (event) => { if (accountColumnPanel.hidden || accountColumnPicker.contains(event.target)) return; accountColumnPanel.hidden = true; @@ -512,7 +622,7 @@ accountColumnPanel.hidden = true; accountColumnToggle.setAttribute('aria-expanded', 'false'); }); - applyAccountColumnVisibility(); + applyAccountColumnLayout(); } const postMappingBulk = async (url) => { @@ -713,7 +823,7 @@ } bindInlineAccountSelect('[data-account-status]', 'status', '账户状态已保存'); bindInlineAccountSelect('[data-account-type]', 'account_type', '账户类型已保存'); - applyAccountColumnVisibility(); + applyAccountColumnLayout(); bindAccountPagination(); notify(targetPage ? '已加载第 ' + targetPage + ' 页' : '下一页已加载'); } catch (error) { diff --git a/tests/Feature/Business/FinanceLedgerAccountStatusTest.php b/tests/Feature/Business/FinanceLedgerAccountStatusTest.php index 6932cb36..4cef3470 100644 --- a/tests/Feature/Business/FinanceLedgerAccountStatusTest.php +++ b/tests/Feature/Business/FinanceLedgerAccountStatusTest.php @@ -76,6 +76,8 @@ class FinanceLedgerAccountStatusTest extends FeatureTestCase ->assertSee('data-ledger-column-picker', false) ->assertSee('data-ledger-column-panel', false) ->assertSee('列设置') + ->assertSee('拖动字段可调整列顺序') + ->assertSee('恢复默认') ->assertSee('可用金额合计') ->assertSee('不可用金额合计') ->assertSee('可用金额 − 不可用金额') -- 2.47.2
公司名称账户简称账户类型账户状态地区账号开户行开户城市/网点公司规划后4位单笔限额日限额月限额年限额当前余额卡住金额每日可出预计出清天数提额目标提额状态提额动作已做动作下一步备注责任人
公司名称账户简称账户类型账户状态地区账号开户行开户城市/网点公司规划后4位单笔限额日限额月限额年限额可用金额不可用金额每日可出预计出清天数提额目标提额状态提额动作已做动作下一步备注责任人