流程数据导航、OA流程改名及分页选项 #689

Merged
yueyang merged 8 commits from feature/yueyang-process-data-oa-process into main 2026-10-27 16:29:04 +08:00
13 changed files with 350 additions and 41 deletions

View File

@ -4693,6 +4693,7 @@ class Pages extends Controller
'online-daifa-price' => $this->contentForOnlineDaifaPrice(),
'product-costs' => $this->contentForProductCosts(),
'financial-analysis' => $this->contentForFinancialAnalysis(),
'oa-process' => $this->contentForProcessDetails(),
'labor-expenses' => $this->contentForLaborExpenses(),
'courier-surcharge' => $this->contentForCourierSurcharge($tab),
'courier-surcharge-settlement' => $this->contentForCourierSurchargeSettlementPage(),
@ -4722,6 +4723,17 @@ class Pages extends Controller
};
}
protected function contentForProcessDetails(): array
{
$data = app(\Modules\EcommerceFinance\View\ProcessDetailsPageData::class)->page(request());
$data['view'] = 'business.pages.financial-operations.process-details-content';
$data['scopeLabel'] = ($data['scope'] ?? null) === 'personal_permission'
? '当前账号权限范围'
: ($data['scope'] ?? '');
return $data;
}
protected function contentForLaborExpenses(): array
{
return [

View File

@ -12,6 +12,30 @@ class BusinessNavigation
$navigation = config('business_navigation', []);
$pageExtensions = (array) config('business_navigation_page_extensions', []);
foreach ((array) ($pageExtensions['module_sections'] ?? []) as $definition) {
$section = (array) ($definition['section'] ?? []);
$sectionSlug = trim((string) ($section['slug'] ?? ''));
if ($sectionSlug === ''
|| collect($navigation)->contains(
static fn (array $existing): bool => ($existing['slug'] ?? null) === $sectionSlug,
)) {
continue;
}
$positionAfter = trim((string) ($definition['position_after'] ?? ''));
$anchorIndex = $positionAfter === ''
? false
: collect($navigation)->search(
static fn (array $existing): bool => ($existing['slug'] ?? null) === $positionAfter,
);
array_splice(
$navigation,
is_int($anchorIndex) ? $anchorIndex + 1 : count($navigation),
0,
[$section],
);
}
foreach ($navigation as &$section) {
$sectionSlug = (string) ($section['slug'] ?? '');

View File

@ -10,10 +10,17 @@ return [
| Entries are appended to an existing section in configuration order.
|
*/
'financial-operations' => [
'module_sections' => [
[
'slug' => 'process-details',
'title' => '流程明细',
'position_after' => 'inventory-management',
'section' => [
'slug' => 'process-data',
'title' => '流程数据',
'icon' => 'account_tree',
'pages' => [
['slug' => 'oa-process', 'title' => 'OA流程'],
],
],
],
],
];

View File

@ -8,6 +8,7 @@ use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Routing\Controller;
use Modules\EcommerceFinance\Repositories\DingProcessDetailMirrorRepository;
use Modules\EcommerceFinance\Services\DingProcessDetailMirrorImporter;
use Modules\EcommerceFinance\View\ProcessDetailsPageData;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Throwable;
@ -21,15 +22,15 @@ class ProcessDetails extends Controller
{
$filters = $this->filters($request);
$page = max((int) $request->query('page', 1), 1);
$perPage = 50;
$perPage = ProcessDetailsPageData::normalizePerPage($request->query('per_page'));
try {
$result = $this->repository->paginate($filters, $page, $perPage);
$options = $this->repository->filterOptions();
return view('business.pages.financial-operations.process-details', [
'section' => ['slug' => 'financial-operations', 'title' => '财务经营报表'],
'page' => ['slug' => 'process-details', 'title' => '流程明细'],
'section' => ['slug' => 'process-data', 'title' => '流程数据'],
'page' => ['slug' => 'oa-process', 'title' => 'OA流程'],
'processRows' => new LengthAwarePaginator(
$result['data'],
$result['total'],
@ -53,8 +54,8 @@ class ProcessDetails extends Controller
report($exception);
return view('business.pages.financial-operations.process-details', [
'section' => ['slug' => 'financial-operations', 'title' => '财务经营报表'],
'page' => ['slug' => 'process-details', 'title' => '流程明细'],
'section' => ['slug' => 'process-data', 'title' => '流程数据'],
'page' => ['slug' => 'oa-process', 'title' => 'OA流程'],
'processRows' => new LengthAwarePaginator([], 0, $perPage, $page, [
'path' => $request->url(),
'query' => $request->except('page'),
@ -74,7 +75,7 @@ class ProcessDetails extends Controller
'mirrorStale' => false,
'mirrorStaleAfterMinutes' => 15,
'scope' => $this->repository->scope(),
'error' => '流程明细镜像尚未完成导入,请稍后重试。',
'error' => 'OA流程镜像尚未完成导入,请稍后重试。',
]);
}
}
@ -84,10 +85,10 @@ class ProcessDetails extends Controller
if (! $this->repository->ready()) {
return redirect()
->route('business.pages.show', [
'section' => 'financial-operations',
'page' => 'process-details',
'section' => 'process-data',
'page' => 'oa-process',
])
->withErrors(['export' => '流程明细镜像尚未完成导入,暂时不能导出。']);
->withErrors(['export' => 'OA流程镜像尚未完成导入,暂时不能导出。']);
}
$filters = $this->filters($request);
@ -124,7 +125,7 @@ class ProcessDetails extends Controller
}
fclose($stream);
}, '流程明细_' . now()->format('Ymd_His') . '.csv', [
}, 'OA流程_' . now()->format('Ymd_His') . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
]);
}

View File

@ -103,7 +103,7 @@ class DingProcessDetailMirrorRepository
}
$page = max($page, 1);
$perPage = min(max($perPage, 1), 100);
$perPage = min(max($perPage, 1), 200);
$query = $this->filteredQuery($filters);
$total = (clone $query)->count();

View File

@ -11,15 +11,34 @@ Route::admin('ecommerce-finance', function () {
// 模块路由在 register 阶段先注册,因此这里的精确路由会优先命中。沿用
// business.pages.show 名称,侧边栏仍能生成与通用页面一致的可访问 URL。
Route::admin('ecommerce-finance', function (): void {
Route::get('workspace/financial-operations/process-details', [ProcessDetails::class, 'show'])
Route::get('workspace/process-data/oa-process', [ProcessDetails::class, 'show'])
->middleware('business.page.viewer')
->defaults('section', 'financial-operations')
->defaults('page', 'process-details')
->defaults('section', 'process-data')
->defaults('page', 'oa-process')
->name('business.pages.show');
Route::get('workspace/financial-operations/process-details/export', [ProcessDetails::class, 'export'])
Route::get('workspace/process-data/oa-process/export', [ProcessDetails::class, 'export'])
->middleware('business.page.viewer')
->defaults('section', 'process-data')
->defaults('page', 'oa-process')
->name('business.process-details.export');
Route::get('workspace/financial-operations/process-details', function (\Illuminate\Http\Request $request) {
return redirect()->route('business.pages.show', array_merge($request->query(), [
'section' => 'process-data',
'page' => 'oa-process',
]));
})
->middleware('business.page.viewer')
->defaults('section', 'financial-operations')
->defaults('page', 'process-details')
->name('business.process-details.export');
->name('business.process-details.legacy');
Route::get('workspace/financial-operations/process-details/export', function (\Illuminate\Http\Request $request) {
return redirect()->route('business.process-details.export', $request->query());
})
->middleware('business.page.viewer')
->defaults('section', 'financial-operations')
->defaults('page', 'process-details')
->name('business.process-details.legacy.export');
}, ['prefix' => '', 'as' => '']);

View File

@ -9,16 +9,29 @@ use Throwable;
class ProcessDetailsPageData
{
public const PER_PAGE_OPTIONS = [50, 100, 200];
public const DEFAULT_PER_PAGE = 50;
public function __construct(
private readonly DingProcessDetailMirrorRepository $repository,
) {}
public static function normalizePerPage(mixed $value): int
{
$perPage = (int) $value;
return in_array($perPage, self::PER_PAGE_OPTIONS, true)
? $perPage
: self::DEFAULT_PER_PAGE;
}
/** @return array<string,mixed> */
public function page(Request $request): array
{
$filters = $this->filters($request);
$page = max((int) $request->query('page', 1), 1);
$perPage = 50;
$perPage = self::normalizePerPage($request->query('per_page'));
try {
$result = $this->repository->paginate($filters, $page, $perPage);
@ -67,7 +80,7 @@ class ProcessDetailsPageData
'mirrorStale' => false,
'mirrorStaleAfterMinutes' => 15,
'scope' => $this->repository->scope(),
'error' => '流程明细镜像尚未完成导入,请稍后重试。',
'error' => 'OA流程镜像尚未完成导入,请稍后重试。',
];
}
}

View File

@ -1,7 +1,28 @@
@php
$filters ??= [];
$summary ??= [];
$documentTypes ??= [];
$statuses ??= [];
$error ??= null;
$paymentStates = $paymentStates ?? ($pageData['paymentStates'] ?? []);
$mirrorStale = $mirrorStale ?? ($pageData['mirrorStale'] ?? false);
$mirrorStaleAfterMinutes = $mirrorStaleAfterMinutes ?? ($pageData['mirrorStaleAfterMinutes'] ?? 15);
$perPageOptions = \Modules\EcommerceFinance\View\ProcessDetailsPageData::PER_PAGE_OPTIONS;
$scopeLabel ??= (($scope ?? null) === 'personal_permission'
? '当前账号权限范围'
: ($scope ?? ''));
$money ??= static fn (mixed $value): string => $value === null || $value === ''
? '-'
: number_format((float) $value, 2);
$text ??= static fn (mixed $value): string => $value === null || trim((string) $value) === ''
? '-'
: (string) $value;
$statusClass ??= static fn (string $status): string => match ($status) {
'已完成' => 'is-success',
'审批中' => 'is-warning',
'已终止', '已撤销' => 'is-danger',
default => 'is-neutral',
};
$paymentStateClass = static fn (string $state): string => match ($state) {
'出纳已打款' => 'is-success',
'出纳待打款', '出纳退回' => 'is-warning',
@ -9,6 +30,16 @@
'出纳未开始', '流程终止', '不涉及付款' => 'is-neutral',
default => 'is-neutral',
};
$pageUrl ??= route('business.pages.show', [
'section' => 'process-data',
'page' => 'oa-process',
]);
$exportFilters = array_filter(
$filters,
static fn (mixed $value): bool => $value !== '' && $value !== null,
);
$exportUrl ??= url('/' . company_id() . '/workspace/process-data/oa-process/export')
. ($exportFilters === [] ? '' : '?' . http_build_query($exportFilters));
@endphp
<style>
@ -356,6 +387,32 @@
font-variant-numeric: tabular-nums;
}
.process-details-page-size {
display: inline-flex;
align-items: center;
gap: .3rem;
margin: 0;
}
.process-details-page-size select {
width: 4.5rem;
height: 2rem;
padding: 0 .35rem;
border: 1px solid var(--vip-border, #D8D2C7);
border-radius: .45rem;
background: #fff;
color: var(--vip-content-ink, #171717);
font: inherit;
font-size: .75rem;
font-variant-numeric: tabular-nums;
}
.process-details-page-size select:focus {
border-color: var(--vip-content-accent-dark, #856632);
outline: 0;
box-shadow: 0 0 0 3px rgba(133, 102, 50, .2);
}
.process-details-pagination {
display: inline-flex;
align-items: center;
@ -522,6 +579,10 @@
flex-wrap: wrap;
}
.process-details-page-size {
width: 100%;
}
.process-details-page-jump {
margin-left: 0;
}
@ -551,7 +612,8 @@
</div>
@endif
<form method="GET" action="{{ $pageUrl }}" class="process-details-toolbar" aria-label="流程明细筛选">
<form method="GET" action="{{ $pageUrl }}" class="process-details-toolbar" aria-label="OA流程筛选">
<input type="hidden" name="per_page" value="{{ $processRows->perPage() }}">
<div class="process-details-field">
<label for="process-details-paid-date">打款日期</label>
<input id="process-details-paid-date" type="date" name="paid_date" value="{{ $filters['paid_date'] ?? '' }}">
@ -619,7 +681,7 @@
金额合计按审批费用明细汇总,付款合计按结构化付款明细汇总,同一笔付款不会因关联多条费用而重复计算。
付款状态按钉钉“出纳打款”节点判断;付款差额按“费用合计 − 已付款合计”单独计算,不参与付款状态判断。
“出纳状态待同步”表示尚未取得出纳节点任务,不能据此推断已付款或未付款。
当前展示的是当前账号权限范围内可读取的钉钉审批流程明细;审批中的金额和行项目仍可能随流程变化。
当前展示的是当前账号权限范围内可读取的钉钉OA审批流程;审批中的金额和行项目仍可能随流程变化。
企业级全量数据待领导开通 OA 权限后切换。
</p>
@ -675,7 +737,7 @@
@empty
<tr>
<td colspan="17" class="process-details-empty">
当前筛选条件没有流程明细。可以调整月份、单据类型、状态或关键词后重试。
当前筛选条件没有OA流程数据。可以调整月份、单据类型、状态或关键词后重试。
</td>
</tr>
@endforelse
@ -692,9 +754,25 @@
<span>共 <b>{{ number_format($processRows->total()) }}</b> 行</span>
<span>第 <b>{{ number_format($processDetailsCurrentPage) }}</b> / {{ number_format($processDetailsLastPage) }} 页</span>
<span>本页 <b>{{ number_format($processRows->count()) }}</b> 行</span>
<form class="process-details-page-size" method="GET" action="{{ $pageUrl }}">
@foreach ($filters as $filterName => $filterValue)
@if ($filterValue !== '')
<input type="hidden" name="{{ $filterName }}" value="{{ $filterValue }}">
@endif
@endforeach
<label for="process-details-per-page">每页</label>
<select id="process-details-per-page" name="per_page" onchange="this.form.submit()">
@foreach ($perPageOptions as $perPageOption)
<option value="{{ $perPageOption }}" @selected($processRows->perPage() === $perPageOption)>
{{ $perPageOption }}
</option>
@endforeach
</select>
<span>行</span>
</form>
</div>
<nav class="process-details-pagination" aria-label="流程明细分页">
<nav class="process-details-pagination" aria-label="OA流程分页">
@if ($processRows->onFirstPage())
<span class="process-details-page-link is-disabled" aria-disabled="true" title="首页">
<span class="material-icons-outlined" aria-hidden="true">first_page</span>
@ -733,6 +811,7 @@
@if ($processDetailsLastPage > 1)
<form class="process-details-page-jump" method="GET" action="{{ $pageUrl }}">
<input type="hidden" name="per_page" value="{{ $processRows->perPage() }}">
@foreach ($filters as $filterName => $filterValue)
@if ($filterValue !== '')
<input type="hidden" name="{{ $filterName }}" value="{{ $filterValue }}">

View File

@ -32,14 +32,14 @@
default => 'is-neutral',
};
$pageUrl = route('business.pages.show', [
'section' => 'financial-operations',
'page' => 'process-details',
'section' => 'process-data',
'page' => 'oa-process',
]);
$exportFilters = array_filter(
$filters,
static fn (mixed $value): bool => $value !== '' && $value !== null,
);
$exportUrl = url('/' . company_id() . '/workspace/financial-operations/process-details/export');
$exportUrl = url('/' . company_id() . '/workspace/process-data/oa-process/export');
if ($exportFilters !== []) {
$exportUrl .= '?' . http_build_query($exportFilters);
}
@ -56,7 +56,7 @@
@else
<x-layouts.admin>
<x-slot name="title">
流程明细
OA流程
</x-slot>
<x-slot name="content">

View File

@ -0,0 +1,44 @@
<?php
namespace Tests\Feature\Business;
use App\Utilities\BusinessNavigation;
use Tests\Feature\FeatureTestCase;
class ProcessDetailsNavigationTest extends FeatureTestCase
{
public function test_oa_process_is_in_dedicated_process_data_section(): void
{
$navigation = BusinessNavigation::all();
$slugs = array_column($navigation, 'slug');
$inventoryIndex = array_search('inventory-management', $slugs, true);
$processDataIndex = array_search('process-data', $slugs, true);
$financeIndex = array_search('financial-operations', $slugs, true);
$this->assertIsInt($inventoryIndex);
$this->assertIsInt($processDataIndex);
$this->assertIsInt($financeIndex);
$this->assertSame($inventoryIndex + 1, $processDataIndex);
$this->assertSame($processDataIndex + 1, $financeIndex);
$section = $navigation[$processDataIndex];
$this->assertSame('流程数据', $section['title']);
$this->assertSame(
[['slug' => 'oa-process', 'title' => 'OA流程']],
array_map(
static fn (array $page): array => [
'slug' => $page['slug'],
'title' => $page['title'],
],
$section['pages'],
),
);
$finance = $navigation[$financeIndex];
$this->assertNotContains(
'process-details',
array_column($finance['pages'] ?? [], 'slug'),
);
}
}

View File

@ -15,7 +15,9 @@ class ProcessDetailsPageTest extends FeatureTestCase
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
$repository = Mockery::mock(DingProcessDetailMirrorRepository::class);
$repository->shouldReceive('paginate')->once()->andReturn([
$repository->shouldReceive('paginate')->once()
->with(Mockery::type('array'), 1, 100)
->andReturn([
'data' => [
(object) [
'business_id' => '202601011200000000001',
@ -65,18 +67,20 @@ class ProcessDetailsPageTest extends FeatureTestCase
]);
$this->app->instance(DingProcessDetailMirrorRepository::class, $repository);
$financeSection = collect(\App\Utilities\BusinessNavigation::all())
->firstWhere('slug', 'financial-operations');
$processSection = collect(\App\Utilities\BusinessNavigation::all())
->firstWhere('slug', 'process-data');
$this->assertContains(
'process-details',
array_column($financeSection['pages'] ?? [], 'slug'),
'oa-process',
array_column($processSection['pages'] ?? [], 'slug'),
);
$this->loginAs()->get(route('business.pages.show', [
'section' => 'financial-operations',
'page' => 'process-details',
'section' => 'process-data',
'page' => 'oa-process',
'per_page' => 100,
]))->assertOk()
->assertSee('流程明细')
->assertSee('流程数据')
->assertSee('OA流程')
->assertSee('审批编号')
->assertSee('发起人')
->assertSee('单据类型')
@ -99,6 +103,10 @@ class ProcessDetailsPageTest extends FeatureTestCase
->assertSee('100.00')
->assertSee('当前账号权限范围')
->assertSee('同一笔付款不会因关联多条费用而重复计算')
->assertSee('name="per_page"', false)
->assertSee('value="50"', false)
->assertSee('value="100" selected', false)
->assertSee('value="200"', false)
->assertSee('导出');
}
@ -112,13 +120,37 @@ class ProcessDetailsPageTest extends FeatureTestCase
$this->app->instance(DingProcessDetailMirrorRepository::class, $repository);
$this->loginAs()->get(route('business.pages.show', [
'section' => 'financial-operations',
'page' => 'process-details',
'section' => 'process-data',
'page' => 'oa-process',
]))->assertOk()
->assertSee('流程明细镜像尚未完成导入')
->assertSee('OA流程镜像尚未完成导入')
->assertSee('当前账号权限范围');
}
public function test_legacy_process_details_url_redirects_to_oa_process(): void
{
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
$this->loginAs()->get(
'/1/workspace/financial-operations/process-details?status=' . urlencode('已完成'),
)->assertRedirect(route('business.pages.show', [
'section' => 'process-data',
'page' => 'oa-process',
'status' => '已完成',
]));
}
public function test_legacy_process_details_export_url_redirects_to_oa_process(): void
{
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
$this->loginAs()->get(
'/1/workspace/financial-operations/process-details/export?status=' . urlencode('已完成'),
)->assertRedirect(route('business.process-details.export', [
'status' => '已完成',
]));
}
public function test_export_streams_filtered_csv(): void
{
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());

View File

@ -194,6 +194,32 @@ class DingProcessDetailMirrorRepositoryTest extends TestCase
$this->assertSame('202601011200000000001', (string) $rows[0]['business_id']);
}
public function test_paginate_supports_two_hundred_rows_per_page(): void
{
$rows = [];
for ($index = 1; $index <= 97; $index++) {
$rows[] = $this->row(
'20260301' . str_pad((string) $index, 14, '0', STR_PAD_LEFT),
1,
'202603',
'批量测试',
'审批中',
'公摊日常报销',
'分页容量测试',
1.00,
'2026-03-01 00:00',
);
}
DB::connection('business_mirror')
->table('ding_process_detail_mirror')
->insert($rows);
$result = $this->repository->paginate([], 1, 200);
$this->assertSame(101, $result['total']);
$this->assertCount(101, $result['data']);
}
public function test_not_ready_when_state_is_missing(): void
{
DB::connection('business_mirror')

View File

@ -6,6 +6,7 @@ use Illuminate\Http\Request;
use Illuminate\Pagination\LengthAwarePaginator;
use Modules\EcommerceFinance\Repositories\DingProcessDetailMirrorRepository;
use Modules\EcommerceFinance\View\ProcessDetailsPageData;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
class ProcessDetailsPageDataTest extends TestCase
@ -30,6 +31,9 @@ class ProcessDetailsPageDataTest extends TestCase
$this->assertStringContainsString('class="process-details-table-card"', $content);
$this->assertStringContainsString('class="process-details-pagination"', $content);
$this->assertStringContainsString('class="process-details-page-jump"', $content);
$this->assertStringContainsString('$pageUrl ??=', $content);
$this->assertStringContainsString('$exportUrl ??=', $content);
$this->assertStringContainsString('$statusClass ??=', $content);
$this->assertStringNotContainsString('$processRows->links()', $content);
}
@ -80,7 +84,7 @@ class ProcessDetailsPageDataTest extends TestCase
]);
$data = (new ProcessDetailsPageData($repository))->page(Request::create(
'/1/workspace/financial-operations/process-details',
'/1/workspace/process-data/oa-process',
'GET',
['paid_date' => '2026-10-24', 'payment_state' => '出纳已打款', 'page' => 2],
));
@ -111,4 +115,52 @@ class ProcessDetailsPageDataTest extends TestCase
$this->assertArrayNotHasKey('months', $data);
$this->assertNull($data['error']);
}
#[DataProvider('perPageProvider')]
public function test_page_size_only_accepts_supported_options(mixed $requested, int $expected): void
{
$repository = $this->createMock(DingProcessDetailMirrorRepository::class);
$repository->expects($this->once())
->method('paginate')
->with($this->isType('array'), 1, $expected)
->willReturn([
'data' => [],
'total' => 0,
'summary' => [
'rows' => 0,
'approvals' => 0,
'amount_total' => 0,
'bank_amount_total' => 0,
],
'status' => 'succeeded',
'last_synced_at' => null,
'scope' => 'personal_permission',
]);
$repository->expects($this->once())
->method('filterOptions')
->willReturn([
'document_types' => [],
'statuses' => [],
'payment_states' => [],
]);
$data = (new ProcessDetailsPageData($repository))->page(Request::create(
'/1/workspace/process-data/oa-process',
'GET',
['per_page' => $requested],
));
$this->assertSame($expected, $data['processRows']->perPage());
}
/** @return array<string,array{mixed,int}> */
public static function perPageProvider(): array
{
return [
'50 条' => ['50', 50],
'100 条' => ['100', 100],
'200 条' => ['200', 200],
'非法值回退' => ['500', 50],
];
}
}