feat(财务台账): 移入财务经营报表并兼容旧链接 #419

Merged
DoDo merged 7 commits from feature/dodo-move-financial-ledger into main 2026-10-21 11:39:31 +08:00
7 changed files with 165 additions and 26 deletions

View File

@ -10,6 +10,8 @@ class SidebarPermissions
{
private const LEGACY_PAGE_KEYS = [
'refunds/small-payment-work-orders' => 'financial-operations/small-payment-work-orders',
'financial-ledger' => 'financial-operations/ledger',
'financial-ledger/ledger' => 'financial-operations/ledger',
];
private const FINAL_SECTION_KEYS = [

View File

@ -881,6 +881,7 @@ $navigation = [
['slug' => 'bookkeeping-details', 'title' => '记账明细'],
['slug' => 'non-operating-expenses', 'title' => '营业外支出'],
['slug' => 'financial-analysis', 'title' => '财务经营分析表'],
['slug' => 'ledger', 'title' => '财务台账'],
],
],
[
@ -943,14 +944,6 @@ $navigation = [
['slug' => 'account-mapping', 'title' => '科目映射表'],
],
],
[
'slug' => 'financial-ledger',
'title' => '财务台账',
'icon' => 'menu_book',
'pages' => [
['slug' => 'ledger', 'title' => '财务台账'],
],
],
[
'slug' => 'data-monitoring',
'title' => '数据监控',

View File

@ -61,7 +61,7 @@ docker compose -f docker-compose.local.yml exec -u www-data app \
## 页面
- 路由:`/workspace/financial-ledger/ledger`
- 路由:`/workspace/financial-operations/ledger`(旧地址会自动跳转)
- 页面:账户总表、店铺台账、每日资金流水、动账明细、账户映射、数据来源
- 账户总表支持导出空白 Excel 录入模板,包含页面列、状态和账户类型选项及填写说明
- 写权限:账户映射、账户状态和账户类型修改仅系统管理员及配置的财务台账管理员角色;其他账号只读

View File

@ -17,7 +17,7 @@
], 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') : '';
$ledgerPageUrl = route('business.pages.show', ['section' => 'financial-ledger', 'page' => 'ledger']);
$ledgerPageUrl = route('business.pages.show', ['section' => 'financial-operations', 'page' => 'ledger']);
$tabUrl = static fn (string $tab): string => $ledgerPageUrl . '?' . http_build_query(['tab' => $tab]);
$sourceLabels = ['dingtalk' => '钉钉审批', 'shop' => '店铺提现', 'payable' => '供应商应付', 'store' => '店铺开店', 'workorder' => '聚工单'];
@endphp
@ -345,7 +345,7 @@
</div>
<div class="ledger-table-wrap">
<table class="ledger-table" style="min-width:64rem"><thead><tr><th>来源</th><th>原始值</th><th class="num">笔数</th><th class="num">收入合计</th><th class="num">支出合计</th><th>映射账户简称</th><th>状态</th></tr></thead>
<tbody>@forelse (($mapping['rows'] ?? collect()) as $row)<tr data-mapping-row data-id="{{ $row->id }}"><td><span class="ledger-source-tag">{{ $sourceLabels[$row->source] ?? $row->source }}</span></td><td>{{ $row->source_value }}</td><td class="num">{{ number_format((int) $row->txn_count) }}</td><td class="num">{{ $money($row->income_sum) }}</td><td class="num">{{ $money($row->expense_sum) }}</td><td>@if ($canEdit)<input class="ledger-map-input" list="ledger-account-list" value="{{ $row->account_short }}" placeholder="选择账户简称" data-mapping-input data-id="{{ $row->id }}" data-url="{{ route('business.financial-ledger.mapping.update', ['id' => $row->id]) }}">@else{{ $row->account_short ?: '未匹配' }}@endif</td><td data-mapping-state class="{{ $row->account_short ? 'success' : 'danger' }}">{{ $row->account_short ? '已映射' : '未匹配' }}</td></tr>@empty<tr><td colspan="7"><div class="ledger-empty">没有匹配的映射记录。</div></td></tr>@endforelse</tbody>
<tbody>@forelse (($mapping['rows'] ?? collect()) as $row)<tr data-mapping-row data-id="{{ $row->id }}"><td><span class="ledger-source-tag">{{ $sourceLabels[$row->source] ?? $row->source }}</span></td><td>{{ $row->source_value }}</td><td class="num">{{ number_format((int) $row->txn_count) }}</td><td class="num">{{ $money($row->income_sum) }}</td><td class="num">{{ $money($row->expense_sum) }}</td><td>@if ($canEdit)<input class="ledger-map-input" list="ledger-account-list" value="{{ $row->account_short }}" placeholder="选择账户简称" data-mapping-input data-id="{{ $row->id }}" data-url="{{ route('business.financial-operations.ledger.mapping.update', ['id' => $row->id]) }}">@else{{ $row->account_short ?: '未匹配' }}@endif</td><td data-mapping-state class="{{ $row->account_short ? 'success' : 'danger' }}">{{ $row->account_short ? '已映射' : '未匹配' }}</td></tr>@empty<tr><td colspan="7"><div class="ledger-empty">没有匹配的映射记录。</div></td></tr>@endforelse</tbody>
</table>
<div class="ledger-pagination"><span>共 {{ number_format($mapping['total'] ?? 0) }} 条 · 第 {{ ($mapping['rows'] ?? null)?->currentPage() ?? 1 }} / {{ ($mapping['rows'] ?? null)?->lastPage() ?? 1 }} 页</span><span>@if (($mapping['rows'] ?? null) && !$mapping['rows']->onFirstPage())<a href="{{ $mapping['rows']->previousPageUrl() }}">上一页</a>@endif @if (($mapping['rows'] ?? null) && $mapping['rows']->hasMorePages())<a href="{{ $mapping['rows']->nextPageUrl() }}">下一页</a>@endif</span></div>
</div>

View File

@ -15,6 +15,7 @@ use App\Http\Controllers\Business\SupplierPayableActions;
use App\Http\Controllers\Business\PayablesFinalActions;
use App\Http\Controllers\Business\SupplierReconciliation as SupplierReconciliationActions;
use App\Http\Controllers\Business\WithdrawalManagement;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Modules\EcommerceFinance\Http\Controllers\AccountBills;
use Modules\EcommerceFinance\Http\Controllers\AlipayBills;
@ -649,39 +650,60 @@ Route::patch('workspace/supplier-payables/supplier-management/free-shipping-manu
->middleware('business.page:supplier-payables,supplier-management')
->name('business.supplier-management.free-shipping.update');
Route::post('workspace/financial-operations/ledger/mapping/{id}', [\App\Http\Controllers\Business\FinanceLedger::class, 'updateMapping'])
->whereNumber('id')
->middleware('business.page:financial-operations,ledger')
->name('business.financial-operations.ledger.mapping.update');
// Keep already-open ledger pages working while they post to the previous URL.
Route::post('workspace/financial-ledger/mapping/{id}', [\App\Http\Controllers\Business\FinanceLedger::class, 'updateMapping'])
->whereNumber('id')
->middleware('business.page:financial-ledger,ledger')
->middleware('business.page:financial-operations,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')
->middleware('business.page:financial-operations,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')
->middleware('business.page:financial-operations,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')
->middleware('business.page:financial-operations,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')
->middleware('business.page:financial-operations,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')
->middleware('business.page:financial-operations,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')
->middleware('business.page:financial-operations,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')
->middleware('business.page:financial-operations,ledger')
->name('business.financial-ledger.mapping.payable.apply');
Route::get('workspace/financial-ledger/{page?}/{tab?}', function (Request $request, ?string $page = null, ?string $tab = null) {
abort_unless($page === null || $page === 'ledger', 404);
$query = $request->query();
if ($tab !== null) {
$query['tab'] = $tab;
}
$target = route('business.pages.show', [
'section' => 'financial-operations',
'page' => 'ledger',
]);
return redirect()->to($query === [] ? $target : $target . '?' . http_build_query($query));
})->name('business.financial-ledger.legacy');
Route::get('workspace/{section}/{page?}/{tab?}', 'Business\Pages@show')
->name('business.pages.show');

View File

@ -28,7 +28,7 @@ class FinanceLedgerAccountStatusTest extends FeatureTestCase
{
return route('business.pages.show', [
'company_id' => $this->company->id,
'section' => 'financial-ledger',
'section' => 'financial-operations',
'page' => 'ledger',
]);
}
@ -110,7 +110,7 @@ class FinanceLedgerAccountStatusTest extends FeatureTestCase
$this->loginAs()->get(route('business.pages.show', [
'company_id' => $this->company->id,
'section' => 'financial-ledger',
'section' => 'financial-operations',
'page' => 'ledger',
'accounts_short' => 'page-test',
]))
@ -146,7 +146,7 @@ class FinanceLedgerAccountStatusTest extends FeatureTestCase
$this->loginAs()->get(route('business.pages.show', [
'company_id' => $this->company->id,
'section' => 'financial-ledger',
'section' => 'financial-operations',
'page' => 'ledger',
'accounts_short' => '筛选账户',
'accounts_status' => 'limited',
@ -162,7 +162,7 @@ class FinanceLedgerAccountStatusTest extends FeatureTestCase
$this->loginAs()->get(route('business.pages.show', [
'company_id' => $this->company->id,
'section' => 'financial-ledger',
'section' => 'financial-operations',
'page' => 'ledger',
'accounts_short' => '筛选账户',
'accounts_status' => 'normal',
@ -173,7 +173,7 @@ class FinanceLedgerAccountStatusTest extends FeatureTestCase
$this->loginAs()->get(route('business.pages.show', [
'company_id' => $this->company->id,
'section' => 'financial-ledger',
'section' => 'financial-operations',
'page' => 'ledger',
'accounts_short' => '测试账户',
'accounts_status' => 'normal',
@ -227,7 +227,7 @@ class FinanceLedgerAccountStatusTest extends FeatureTestCase
$this->loginAs()->get(route('business.pages.show', [
'company_id' => $this->company->id,
'section' => 'financial-ledger',
'section' => 'financial-operations',
'page' => 'ledger',
'accounts_type' => '个人卡',
]))
@ -239,7 +239,7 @@ class FinanceLedgerAccountStatusTest extends FeatureTestCase
$this->loginAs()->get(route('business.pages.show', [
'company_id' => $this->company->id,
'section' => 'financial-ledger',
'section' => 'financial-operations',
'page' => 'ledger',
'accounts_type' => '公户',
]))

View File

@ -0,0 +1,122 @@
<?php
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use App\Models\Auth\Role;
use App\Models\Auth\User;
use App\Utilities\BusinessNavigation;
use App\Utilities\SidebarPermissions;
use Tests\Feature\FeatureTestCase;
class FinanceLedgerNavigationTest extends FeatureTestCase
{
public function test_ledger_is_the_last_page_under_financial_operations(): void
{
$navigation = collect(BusinessNavigation::all())->keyBy('slug');
$financialOperations = $navigation->get('financial-operations');
$this->assertNotNull($financialOperations);
$this->assertSame('ledger', collect($financialOperations['pages'])->last()['slug']);
$this->assertFalse($navigation->has('financial-ledger'));
}
public function test_ledger_page_and_legacy_link_open_under_financial_operations(): void
{
$this->loginAs();
$target = route('business.pages.show', [
'section' => 'financial-operations',
'page' => 'ledger',
]);
$this->get($target . '?tab=flows')
->assertOk()
->assertSeeText('财务经营报表')
->assertSeeText('财务台账')
->assertSee('data-ledger-page', false)
->assertSee('data-ledger-tab="flows"', false)
->assertSee($target . '?tab=accounts', false);
$legacy = route('business.pages.show', [
'section' => 'financial-ledger',
'page' => 'ledger',
]);
$this->get($legacy . '?tab=flows')->assertRedirect($target . '?tab=flows');
$this->get($legacy . '/flows')->assertRedirect($target . '?tab=flows');
$this->get(route('business.financial-ledger.accounts.template'))->assertOk();
}
public function test_ledger_actions_use_financial_operations_permission(): void
{
foreach ([
'business.financial-operations.ledger.mapping.update',
'business.financial-ledger.mapping.update',
'business.financial-ledger.accounts.status',
'business.financial-ledger.accounts.type',
'business.financial-ledger.accounts.template',
'business.financial-ledger.mapping.shop.preview',
'business.financial-ledger.mapping.shop.apply',
'business.financial-ledger.mapping.payable.preview',
'business.financial-ledger.mapping.payable.apply',
] as $routeName) {
$route = app('router')->getRoutes()->getByName($routeName);
$this->assertNotNull($route, $routeName);
$this->assertContains(
'business.page:financial-operations,ledger',
$route->gatherMiddleware(),
$routeName
);
}
}
public function test_legacy_ledger_permissions_follow_the_page(): void
{
$regular = $this->regularAccount();
$subject = 'user_' . $regular->id;
foreach (['financial-ledger', 'financial-ledger/ledger'] as $legacyKey) {
setting()->set('system.sidebar_permissions', [
'configured' => [$subject],
'subjects' => [$subject => [$legacyKey]],
]);
$this->assertSame(
['financial-operations/ledger'],
SidebarPermissions::allowedKeys($regular)
);
$navigation = SidebarPermissions::navigationFor($regular);
$this->assertSame(['financial-operations'], array_column($navigation, 'slug'));
$this->assertSame(['ledger'], array_column($navigation[0]['pages'], 'slug'));
}
$this->loginAs($regular)
->get(route('business.pages.show', [
'section' => 'financial-operations',
'page' => 'ledger',
]))
->assertOk()
->assertSee('data-ledger-page', false);
}
private function regularAccount(): User
{
$name = 'fixture-finance-ledger-' . User::count();
$user = User::create([
'name' => $name,
'email' => $name . '@example.test',
'password' => 'fixture-unused-password',
'enabled' => true,
'locale' => 'zh-CN',
'landing_page' => 'dashboard',
]);
$user->roles()->attach(Role::where('name', 'user')->firstOrFail()->id);
$user->permissions()->attach(Permission::where('name', 'read-admin-panel')->firstOrFail()->id);
$user->companies()->attach($this->company->id);
return $user;
}
}