diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index feaea397..fae9124d 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -1198,6 +1198,30 @@ class Pages extends Controller ]); } + /** 费用明细的账单项目汇总(异步请求,避免拖慢主表首屏)。 */ + public function expenseDetailsItemSummary(Request $request): \Illuminate\Http\JsonResponse + { + $section = collect(SidebarPermissions::navigationFor(user()))->firstWhere('slug', 'financial-operations'); + abort_unless($section && collect($section['pages'] ?? [])->contains('slug', 'expense-details'), 403, '当前账号没有该页面权限。'); + + try { + $summary = app(\Modules\EcommerceFinance\View\BillingDetailsPageData::class) + ->billItemSummary($request->all(), ['type' => ['费用']]); + } catch (\Throwable $exception) { + report($exception); + + return response()->json([ + 'message' => '账单项目汇总暂时无法读取,请稍后重试。', + ], 503, [ + 'Cache-Control' => 'private, no-store', + ]); + } + + return response()->json($summary, 200, [ + 'Cache-Control' => 'private, no-store', + ]); + } + /** 账单明细导出进度(前端每秒轮询一次) */ public function billingDetailsExportProgress(Request $request): \Illuminate\Http\JsonResponse { diff --git a/modules/EcommerceFinance/View/BillingDetailsPageData.php b/modules/EcommerceFinance/View/BillingDetailsPageData.php index 85aa99e3..ff9c3465 100644 --- a/modules/EcommerceFinance/View/BillingDetailsPageData.php +++ b/modules/EcommerceFinance/View/BillingDetailsPageData.php @@ -354,6 +354,106 @@ class BillingDetailsPageData ]; } + /** + * 费用明细的账单项目汇总:只响应汇总请求,避免拖慢主表首屏。 + * + * @return array{items:list,item_count:int,rows:int,net:float} + */ + public function billItemSummary(?array $input = null, array $forcedFilters = []): array + { + $input ??= request()->all(); + $filters = array_replace($this->parseFilters($input), $forcedFilters); + $signature = md5((string) json_encode($filters, JSON_UNESCAPED_UNICODE)); + + return cache()->remember( + 'billing-details-item-summary-v1:' . $signature, + now()->addMinutes(2), + function () use ($filters): array { + $connection = $this->connection(); + $parts = []; + $bind = []; + foreach (self::SOURCES as $label => $meta) { + $branch = $this->branch($label, $meta, $filters); + foreach ($branch['scopes'] as $scope) { + $parts[] = 'SELECT bill_item, COUNT(*) AS c, ' + . 'COALESCE(SUM(' . $branch['income'] . '),0) AS inc, ' + . 'COALESCE(SUM(' . $branch['expense'] . '),0) AS exp ' + . 'FROM ' . $branch['table'] . ' WHERE ' . $scope['where'] + . ' GROUP BY bill_item'; + $bind = array_merge($bind, $scope['bind']); + } + } + + $totals = []; + $rows = 0; + $net = 0.0; + foreach ($connection->select(implode(' UNION ALL ', $parts), $bind) as $part) { + $partRows = (int) $part->c; + $partIncome = (float) $part->inc; + $partExpense = (float) $part->exp; + $rows += $partRows; + $net += $partIncome - $partExpense; + $this->accumulateBillItemTotal( + $totals, + (string) ($part->bill_item ?? ''), + $partRows, + $partIncome, + $partExpense, + ); + } + + $items = $this->sortedBillItemTotals($totals); + + return [ + 'items' => $items, + 'item_count' => count($items), + 'rows' => $rows, + 'net' => round($net, 2), + ]; + }, + ); + } + + /** @param array $totals */ + private function accumulateBillItemTotal( + array &$totals, + string $billItem, + int $rows, + float $income, + float $expense, + ): void { + $billItem = $this->normalizeItem($billItem) ?: '未填写账单项目'; + $totals[$billItem] ??= ['rows' => 0, 'income' => 0.0, 'expense' => 0.0]; + $totals[$billItem]['rows'] += $rows; + $totals[$billItem]['income'] += $income; + $totals[$billItem]['expense'] += $expense; + } + + /** + * @param array $totals + * @return list + */ + private function sortedBillItemTotals(array $totals): array + { + $items = []; + foreach ($totals as $item => $total) { + $items[] = [ + 'item' => $item, + 'rows' => (int) $total['rows'], + 'net' => round((float) $total['income'] - (float) $total['expense'], 2), + ]; + } + usort($items, static function (array $left, array $right): int { + if (abs($left['net'] - $right['net']) >= 0.005) { + return $left['net'] <=> $right['net']; + } + + return strnatcasecmp($left['item'], $right['item']); + }); + + return $items; + } + /** * 财务类型为「费用」的账单金额,按财务经营分析表的「平台-店铺」及票据口径汇总。 * diff --git a/resources/views/business/pages/financial-operations/billing-details.blade.php b/resources/views/business/pages/financial-operations/billing-details.blade.php index 5ec93c07..175601a4 100644 --- a/resources/views/business/pages/financial-operations/billing-details.blade.php +++ b/resources/views/business/pages/financial-operations/billing-details.blade.php @@ -10,6 +10,9 @@ $exportProgressRoute = $expenseOnly ? route('business.financial-operations.expense-details.export-progress') : route('business.financial-operations.billing-details.export-progress'); + $expenseItemSummaryRoute = $expenseOnly + ? route('business.financial-operations.expense-details.item-summary') + : null; @endphp
@include('business.pages.partials.upstream-data-styles') @@ -23,6 +26,17 @@ .pd-stat__note { margin-top:.1875rem; color:#595959; font-size:.75rem; } .pd-stat .is-income { color:#53793e; } .pd-stat .is-expense { color:#b80000; } + .pd-item-summary { margin:0 0 1rem; border:1px solid #d8d2c7; border-radius:.75rem; background:#fff; overflow:hidden; } + .pd-item-summary__head { display:flex; flex-wrap:wrap; align-items:center; gap:.5rem .875rem; padding:.75rem 1rem; border-bottom:1px solid #e8e0d2; background:#faf6ee; } + .pd-item-summary__head strong { color:#2f2a23; } + .pd-item-summary__head span { color:#7c7468; font-size:.75rem; } + .pd-item-summary__wrap { max-height:19rem; overflow:auto; } + .pd-item-summary table { width:100%; border-collapse:collapse; font-size:.8125rem; } + .pd-item-summary th { position:sticky; top:0; z-index:1; padding:.55rem .8rem; border-bottom:1px solid #d8d2c7; background:#f3ece0; color:#3d3a35; text-align:left; white-space:nowrap; } + .pd-item-summary th.num, .pd-item-summary td.num { text-align:right; } + .pd-item-summary td { padding:.5rem .8rem; border-bottom:1px solid #eee7da; color:#3d3a35; } + .pd-item-summary td.num { font-variant-numeric:tabular-nums; white-space:nowrap; } + .pd-item-summary tfoot td { position:sticky; bottom:0; z-index:1; border-top:1px solid #d8d2c7; background:#fff8e9; font-weight:700; } /* 筛选栏:12 列栅格对齐——一行 4 个下拉;账单项目与关键词同一行;发生时间单独一行 */ .pd-filter-grid { display:grid; grid-template-columns:repeat(12, minmax(0, 1fr)); gap:.75rem 1rem; align-items:end; } .pd-filter-grid .upstream-data-field { min-width:0; } @@ -101,7 +115,7 @@
数据源=各平台已导入账单(与「店铺收款账户账单」同源);财务备注、财务类型由科目映射表按「平台+账单项目(wx 自动去前缀)」带出,匹配不到的留空并标红。 @if ($expenseOnly) - 本页只展示科目映射表中财务类型=费用的账单。 + 本页只展示科目映射表中财务类型=费用的账单;账单项目按当前筛选汇总,金额单位为万。 @else 同一交易单号跨月合并后已与订单实付对平的,差额备注优先显示为差额=跨月收款(合并已对平);未对平仍显示差额待核实。 @endif @@ -215,6 +229,35 @@
+ @if ($expenseOnly) +
+
+ 账单项目汇总 + 单位:万;汇总金额 = 收入 - 支出 + 正在汇总… +
+ +
+ @endif + @if (! empty($error))
{{ $error }}
@else @@ -576,6 +619,55 @@ syncButtonLabel(); applyColumns(); + var itemSummaryBox = document.getElementById('pdItemSummary'); + if (itemSummaryBox) { + var summaryStatus = document.getElementById('pdItemSummaryStatus'); + var summaryWrap = document.getElementById('pdItemSummaryWrap'); + var summaryRows = document.getElementById('pdItemSummaryRows'); + var summaryTotalNet = document.getElementById('pdItemSummaryTotalNet'); + var summaryTotalRows = document.getElementById('pdItemSummaryTotalRows'); + var summaryParams = new URLSearchParams(window.location.search); + summaryParams.delete('page'); + var summaryUrl = itemSummaryBox.getAttribute('data-endpoint') + + (summaryParams.toString() ? '?' + summaryParams.toString() : ''); + var formatWan = function (value) { + return (Number(value || 0) / 10000).toLocaleString('zh-CN', { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + }); + }; + + fetch(summaryUrl, { headers: { Accept: 'application/json' } }) + .then(function (response) { + return response.json().then(function (payload) { + if (!response.ok) throw new Error(payload.message || '汇总读取失败'); + return payload; + }); + }) + .then(function (summary) { + summaryRows.replaceChildren(); + (summary.items || []).forEach(function (item) { + var tr = document.createElement('tr'); + [item.item, formatWan(item.net), Number(item.rows || 0).toLocaleString('zh-CN')] + .forEach(function (value) { + var td = document.createElement('td'); + td.textContent = value; + tr.appendChild(td); + }); + summaryRows.appendChild(tr); + }); + summaryTotalNet.textContent = formatWan(summary.net); + summaryTotalRows.textContent = Number(summary.rows || 0).toLocaleString('zh-CN'); + summaryStatus.textContent = Number(summary.item_count || 0) > 0 + ? (Number(summary.item_count).toLocaleString('zh-CN') + ' 个账单项目') + : '当前筛选没有费用项目'; + summaryWrap.hidden = Number(summary.item_count || 0) === 0; + }) + .catch(function (error) { + summaryStatus.textContent = error.message || '汇总暂时无法读取'; + }); + } + // 每页条数:改完回到第 1 页 var perPageEl = document.getElementById('pdPerPage'); if (perPageEl) { diff --git a/routes/admin.php b/routes/admin.php index 2e0e7bab..f37b73aa 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -599,6 +599,10 @@ Route::get('workspace/financial-operations/expense-details/filter-options', 'Bus ->middleware('business.page:financial-operations,expense-details') ->name('business.financial-operations.expense-details.filter-options'); +Route::get('workspace/financial-operations/expense-details/item-summary', 'Business\Pages@expenseDetailsItemSummary') + ->middleware('business.page:financial-operations,expense-details') + ->name('business.financial-operations.expense-details.item-summary'); + Route::get('workspace/financial-operations/billing-details/export', 'Business\Pages@exportBillingDetails') ->middleware('business.page:financial-operations,billing-details') ->name('business.financial-operations.billing-details.export'); diff --git a/tests/Feature/Settings/NavigationTest.php b/tests/Feature/Settings/NavigationTest.php index 5dc7d994..540901d5 100644 --- a/tests/Feature/Settings/NavigationTest.php +++ b/tests/Feature/Settings/NavigationTest.php @@ -139,9 +139,11 @@ class NavigationTest extends FeatureTestCase ])) ->assertOk() ->assertSeeText('费用明细') - ->assertSeeText('本页只展示科目映射表中财务类型=费用的账单。') + ->assertSeeText('本页只展示科目映射表中财务类型=费用的账单;账单项目按当前筛选汇总,金额单位为万。') ->assertSee('/workspace/financial-operations/expense-details/export', false) ->assertSee('/workspace/financial-operations/expense-details/filter-options', false) + ->assertSee('/workspace/financial-operations/expense-details/item-summary', false) + ->assertSee('id="pdItemSummary"', false) ->assertDontSee('id="pdType"', false); } diff --git a/tests/Unit/EcommerceFinance/BillingDetailsExpenseFilterTest.php b/tests/Unit/EcommerceFinance/BillingDetailsExpenseFilterTest.php index ec808105..d4be968b 100644 --- a/tests/Unit/EcommerceFinance/BillingDetailsExpenseFilterTest.php +++ b/tests/Unit/EcommerceFinance/BillingDetailsExpenseFilterTest.php @@ -3,6 +3,7 @@ namespace Tests\Unit\EcommerceFinance; use Modules\EcommerceFinance\View\BillingDetailsPageData; +use ReflectionMethod; use Tests\TestCase; class BillingDetailsExpenseFilterTest extends TestCase @@ -16,4 +17,24 @@ class BillingDetailsExpenseFilterTest extends TestCase $this->assertSame(['费用'], $filters['type']); } + + public function test_bill_item_totals_merge_wx_prefix_and_keep_income_minus_expense_basis(): void + { + $data = app(BillingDetailsPageData::class); + $totals = []; + $accumulate = new ReflectionMethod(BillingDetailsPageData::class, 'accumulateBillItemTotal'); + $accumulate->setAccessible(true); + $accumulate->invokeArgs($data, [&$totals, 'wx技术服务费', 2, 10.0, 100.0]); + $accumulate->invokeArgs($data, [&$totals, '技术服务费', 1, 5.0, 20.0]); + $accumulate->invokeArgs($data, [&$totals, '', 1, 0.0, 0.5]); + + $sorted = new ReflectionMethod(BillingDetailsPageData::class, 'sortedBillItemTotals'); + $sorted->setAccessible(true); + $rows = $sorted->invoke($data, $totals); + + $this->assertSame([ + ['item' => '技术服务费', 'rows' => 3, 'net' => -105.0], + ['item' => '未填写账单项目', 'rows' => 1, 'net' => -0.5], + ], $rows); + } }