feat: 支持维护财务台账账户类型

This commit is contained in:
shanshi 2026-10-20 11:39:02 +08:00
parent 76a940fe97
commit 8b76e100cf
8 changed files with 318 additions and 68 deletions

View File

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

View File

@ -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);
}
}

View File

@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* 财务台账账户类型人工维护值。
*
* 基础表 fin_accounts 在导入时会被全量覆盖,因此人工调整后的类型单独保存。
*/
return new class extends Migration
{
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
$schema = Schema::connection($this->connection());
if (! $schema->hasTable('fin_account_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');
}
};

View File

@ -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。

View File

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

View File

@ -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 @@
<td>{{ $row->account_short }}</td>
<td class="ledger-status-column">
@if ($canEditAccountStatus)
<select class="ledger-status-select" data-account-status data-account-short="{{ $row->account_short }}" data-status="{{ $row->account_status }}" data-url="{{ route('business.financial-ledger.accounts.status') }}" aria-label="账户 {{ $row->account_short }} 状态">
<select class="ledger-inline-select ledger-status-select" data-account-status data-account-short="{{ $row->account_short }}" data-status="{{ $row->account_status }}" data-url="{{ route('business.financial-ledger.accounts.status') }}" aria-label="账户 {{ $row->account_short }} 状态">
@foreach ($accountStatusOptions as $statusCode => $statusLabel)
<option value="{{ $statusCode }}" @selected($row->account_status === $statusCode)>{{ $statusLabel }}</option>
@endforeach
@ -152,7 +156,20 @@
<span class="ledger-status-badge" data-status="{{ $row->account_status }}">{{ $accountStatusOptions[$row->account_status] ?? '正常' }}</span>
@endif
</td>
<td>{{ $row->region }}</td><td>{{ $row->account_no }}</td><td>{{ $row->bank_name }}</td><td>{{ $row->bank_city_branch }}</td><td>{{ $row->account_type }}</td><td>{{ $row->company_plan }}</td><td>{{ $row->account_last4 }}</td><td>{{ $row->single_limit }}</td><td>{{ $row->daily_limit }}</td><td>{{ $row->monthly_limit }}</td><td>{{ $row->yearly_limit }}</td><td class="num">{{ $money($row->current_balance) }}</td><td class="num">{{ $money($row->blocked_amount) }}</td><td class="num">{{ $money($row->daily_available) }}</td><td class="num">{{ $row->clearing_days !== null ? number_format((float) $row->clearing_days, 2) : '' }}</td><td>{{ $row->raise_target }}</td><td>{{ $row->raise_status }}</td><td>{{ $row->raise_actions }}</td><td>{{ $row->done_actions }}</td><td>{{ $row->next_action }}</td><td>{{ $row->remark }}</td><td>{{ $row->owner }}</td>
<td>{{ $row->region }}</td><td>{{ $row->account_no }}</td><td>{{ $row->bank_name }}</td><td>{{ $row->bank_city_branch }}</td>
<td>
@if ($canEditAccountType)
<select class="ledger-inline-select ledger-type-select" data-account-type data-account-short="{{ $row->account_short }}" data-url="{{ route('business.financial-ledger.accounts.type') }}" aria-label="账户 {{ $row->account_short }} 类型">
<option value="" @selected(($row->account_type ?? '') === '')>未填写</option>
@foreach ($accountTypeOptions as $accountType)
<option value="{{ $accountType }}" @selected($row->account_type === $accountType)>{{ $accountType }}</option>
@endforeach
</select>
@else
{{ $row->account_type ?: '未填写' }}
@endif
</td>
<td>{{ $row->company_plan }}</td><td>{{ $row->account_last4 }}</td><td>{{ $row->single_limit }}</td><td>{{ $row->daily_limit }}</td><td>{{ $row->monthly_limit }}</td><td>{{ $row->yearly_limit }}</td><td class="num">{{ $money($row->current_balance) }}</td><td class="num">{{ $money($row->blocked_amount) }}</td><td class="num">{{ $money($row->daily_available) }}</td><td class="num">{{ $row->clearing_days !== null ? number_format((float) $row->clearing_days, 2) : '' }}</td><td>{{ $row->raise_target }}</td><td>{{ $row->raise_status }}</td><td>{{ $row->raise_actions }}</td><td>{{ $row->done_actions }}</td><td>{{ $row->next_action }}</td><td>{{ $row->remark }}</td><td>{{ $row->owner }}</td>
</tr>
@empty
<tr><td colspan="25"><div class="ledger-empty">没有匹配的账户数据。</div></td></tr>
@ -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', '账户类型已保存');
})();
</script>

View File

@ -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');

View File

@ -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 = [