diff --git a/app/Http/Controllers/Business/FinanceLedger.php b/app/Http/Controllers/Business/FinanceLedger.php index 854ffd86..98be1e6b 100644 --- a/app/Http/Controllers/Business/FinanceLedger.php +++ b/app/Http/Controllers/Business/FinanceLedger.php @@ -107,4 +107,62 @@ class FinanceLedger extends Controller 'label' => FinanceLedgerAccountStatus::label($status), ], 200, ['Cache-Control' => 'private, no-store']); } + + public function updateAccountType(Request $request): JsonResponse + { + abort_unless(FinanceLedgerAccess::canEditAccountType(user()), 403, '只有财务台账管理员可以修改账户类型。'); + + $data = $request->validate([ + 'account_short' => ['required', 'string', 'max:100'], + 'account_type' => ['nullable', 'string', 'max:50'], + ]); + + $accountShort = trim((string) $data['account_short']); + $accountType = trim((string) ($data['account_type'] ?? '')); + 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, + '账户简称不存在,请刷新账户总表后重试。', + ); + + if ($accountType !== '') { + $allowedTypes = array_values(array_unique(array_merge( + $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, '请选择现有账户类型。'); + } + + $overrides = $connection->table('fin_account_type_overrides'); + $existing = $overrides->where('account_short', $accountShort)->first(); + $now = now(); + $values = [ + 'account_type' => $accountType !== '' ? $accountType : null, + 'updated_by' => (string) user()->name, + 'updated_at' => $now, + ]; + + if ($existing === null) { + $overrides->insert($values + [ + 'account_short' => $accountShort, + 'created_at' => $now, + ]); + } else { + $overrides->where('account_short', $accountShort)->update($values); + } + + return response()->json([ + 'ok' => true, + 'account_short' => $accountShort, + 'account_type' => $accountType, + 'label' => $accountType !== '' ? $accountType : '未填写', + ], 200, ['Cache-Control' => 'private, no-store']); + } } diff --git a/app/Utilities/FinanceLedgerAccess.php b/app/Utilities/FinanceLedgerAccess.php index 9fb40a73..68ed953e 100644 --- a/app/Utilities/FinanceLedgerAccess.php +++ b/app/Utilities/FinanceLedgerAccess.php @@ -30,10 +30,15 @@ class FinanceLedgerAccess } /** - * 账户状态与账户映射使用同一组财务台账编辑角色。 + * 账户状态、账户类型与账户映射使用同一组财务台账编辑角色。 */ public static function canEditAccountStatus(?User $user = null): bool { return self::canEditMapping($user); } + + public static function canEditAccountType(?User $user = null): bool + { + return self::canEditMapping($user); + } } diff --git a/database/migrations/2026_10_20_140000_create_finance_ledger_account_type_overrides.php b/database/migrations/2026_10_20_140000_create_finance_ledger_account_type_overrides.php new file mode 100644 index 00000000..54c23c0b --- /dev/null +++ b/database/migrations/2026_10_20_140000_create_finance_ledger_account_type_overrides.php @@ -0,0 +1,40 @@ +connection()); + + if (! $schema->hasTable('fin_account_type_overrides')) { + $schema->create('fin_account_type_overrides', function (Blueprint $table): void { + $table->bigIncrements('id'); + $table->string('account_short', 100)->unique('fin_account_type_overrides_account_unique'); + $table->string('account_type', 50)->nullable(); + $table->string('updated_by', 100)->nullable(); + $table->timestamps(); + + $table->index(['account_type'], 'fin_account_type_overrides_type_index'); + }); + } + } + + public function down(): void + { + Schema::connection($this->connection())->dropIfExists('fin_account_type_overrides'); + } +}; diff --git a/docs/财务台账开发说明.md b/docs/财务台账开发说明.md index 05f5d291..7e7e91ce 100644 --- a/docs/财务台账开发说明.md +++ b/docs/财务台账开发说明.md @@ -17,9 +17,11 @@ | `fin_account_map` | 来源值 → 账户简称 | | `fin_account_map_events` | 映射修改审计 | | `fin_account_statuses` | 账户状态维护(正常、网银异常、限额、工商注销) | +| `fin_account_type_overrides` | 账户类型人工维护值 | 迁移:`database/migrations/2026_10_19_160000_create_finance_ledger_tables.php`、 -`database/migrations/2026_10_20_120000_create_finance_ledger_account_statuses.php`。 +`database/migrations/2026_10_20_120000_create_finance_ledger_account_statuses.php`、 +`database/migrations/2026_10_20_140000_create_finance_ledger_account_type_overrides.php`。 ## 本地导入 @@ -61,11 +63,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`,重新导入账户基础数据不会覆盖; -页面修改使用异步保存,不触发整页刷新。 +账户状态独立保存在 `fin_account_statuses`,账户类型人工维护值独立保存在 +`fin_account_type_overrides`,重新导入账户基础数据均不会覆盖;页面修改使用 +异步保存,不触发整页刷新。 生产宿主机 `/etc/cron.d` 仍需增加每日调用入口;本项目当前没有运行 `schedule:run` 的容器,调度登记不能替代宿主机 cron。 diff --git a/modules/EcommerceFinance/View/FinanceLedgerPageData.php b/modules/EcommerceFinance/View/FinanceLedgerPageData.php index 64bb5ff2..2150ca04 100644 --- a/modules/EcommerceFinance/View/FinanceLedgerPageData.php +++ b/modules/EcommerceFinance/View/FinanceLedgerPageData.php @@ -59,6 +59,7 @@ class FinanceLedgerPageData 'ledgerAccountStatusOptions' => FinanceLedgerAccountStatus::OPTIONS, 'ledgerCanEditMapping' => FinanceLedgerAccess::canEditMapping(user()), 'ledgerCanEditAccountStatus' => FinanceLedgerAccess::canEditAccountStatus(user()), + 'ledgerCanEditAccountType' => FinanceLedgerAccess::canEditAccountType(user()), ]; } @@ -102,20 +103,46 @@ class FinanceLedgerPageData } } - 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, ''); + $region = trim((string) request('accounts_region', '')); + if ($region !== '') { + if ($region === self::ACCOUNT_FILTER_EMPTY) { + $query->where(function (Builder $builder): void { + $builder->whereNull('region')->orWhere('region', ''); }); } else { - $query->where($column, $value); + $query->where('region', $region); } } + $accountType = trim((string) request('accounts_type', '')); + if ($accountType !== '') { + $query->where(function (Builder $effectiveType) use ($accountType): void { + $effectiveType->where(function (Builder $baseType) use ($accountType): void { + $baseType->whereNotIn( + 'account_short', + $this->db()->table('fin_account_type_overrides')->select('account_short'), + ); + if ($accountType === self::ACCOUNT_FILTER_EMPTY) { + $baseType->where(function (Builder $blank): void { + $blank->whereNull('account_type')->orWhere('account_type', ''); + }); + } else { + $baseType->where('account_type', $accountType); + } + }); + + $overrideType = $this->db()->table('fin_account_type_overrides')->select('account_short'); + if ($accountType === self::ACCOUNT_FILTER_EMPTY) { + $overrideType->where(function (Builder $blank): void { + $blank->whereNull('account_type')->orWhere('account_type', ''); + }); + } else { + $overrideType->where('account_type', $accountType); + } + $effectiveType->orWhereIn('account_short', $overrideType); + }); + } + $summaryQuery = clone $query; $summary = [ 'count' => (clone $query)->count(), @@ -135,8 +162,16 @@ class FinanceLedgerPageData : $this->db()->table('fin_account_statuses') ->whereIn('account_short', $accountShorts) ->pluck('status', 'account_short'); - $rows->getCollection()->transform(static function ($row) use ($statuses) { + $typeOverrides = $accountShorts === [] + ? collect() + : $this->db()->table('fin_account_type_overrides') + ->whereIn('account_short', $accountShorts) + ->pluck('account_type', 'account_short'); + $rows->getCollection()->transform(static function ($row) use ($statuses, $typeOverrides) { $row->account_status = FinanceLedgerAccountStatus::normalize($statuses[$row->account_short] ?? null); + if ($typeOverrides->has($row->account_short)) { + $row->account_type = (string) ($typeOverrides->get($row->account_short) ?? ''); + } return $row; }); @@ -319,13 +354,20 @@ class FinanceLedgerPageData private function accountFilterOptions(): array { + $baseTypes = $this->db()->table('fin_accounts') + ->whereNotNull('account_type')->where('account_type', '<>', '') + ->distinct()->orderBy('account_type')->pluck('account_type')->all(); + $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); + 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(), + 'types' => $types, ]; } diff --git a/resources/views/business/pages/financial-ledger/ledger.blade.php b/resources/views/business/pages/financial-ledger/ledger.blade.php index 9d2d1bff..448ad1fe 100644 --- a/resources/views/business/pages/financial-ledger/ledger.blade.php +++ b/resources/views/business/pages/financial-ledger/ledger.blade.php @@ -4,8 +4,10 @@ $activeTab = $ledgerTab ?? 'accounts'; $canEdit = (bool) ($ledgerCanEditMapping ?? false); $canEditAccountStatus = (bool) ($ledgerCanEditAccountStatus ?? false); + $canEditAccountType = (bool) ($ledgerCanEditAccountType ?? false); $accountStatusOptions = $ledgerAccountStatusOptions ?? \App\Utilities\FinanceLedgerAccountStatus::OPTIONS; $accountFilterOptions = $ledgerAccountFilterOptions ?? ['regions' => [], 'types' => []]; + $accountTypeOptions = $accountFilterOptions['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]); @@ -69,14 +71,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-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: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); } - .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-inline-select { display:block; 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 { width:4.15rem; min-width:4.15rem; } + .ledger-type-select { width:5.2rem; min-width:5.2rem; } + .ledger-inline-select:focus { border-color:var(--vip-content-accent-dark, #856632); outline:none; box-shadow:0 0 0 3px rgba(133,102,50,.16); } + .ledger-inline-select[data-status="normal"] { border-color:rgba(83,121,62,.38); background:#f3f7ee; color:var(--vip-success, #53793E); } + .ledger-inline-select[data-status="online_banking_abnormal"] { border-color:rgba(184,0,0,.34); background:#fff5f5; color:var(--vip-danger, #B80000); } + .ledger-inline-select[data-status="limited"] { border-color:rgba(147,95,7,.36); background:#fff8eb; color:var(--vip-warning, #935F07); } + .ledger-inline-select[data-status="business_deregistered"] { border-color:rgba(89,89,89,.32); background:#f3f1ec; color:var(--vip-content-muted, #595959); } + .ledger-inline-select.is-saving { opacity:.62; cursor:wait; } + .ledger-inline-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; 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); } @@ -143,7 +147,7 @@ {{ $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 }} @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 = [