From b5ff23a6aaf51a18e86dd6f02cf343382f790482 Mon Sep 17 00:00:00 2001 From: yueyang <2495544247@qq.com> Date: Sat, 24 Oct 2026 19:15:47 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B4=B9=E7=94=A8=E6=98=8E=E7=BB=86=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E6=8C=89=E6=80=BB=E8=B4=A6=E5=A4=87=E6=B3=A8=E5=88=86?= =?UTF-8?q?=E7=B1=BB=E5=B9=B6=E7=B2=BE=E7=AE=80=E5=88=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Http/Controllers/Business/Pages.php | 18 ++- .../View/BillingDetailsPageData.php | 110 +++++++++++++----- .../billing-details.blade.php | 35 ++++-- tests/Feature/Settings/NavigationTest.php | 5 +- .../BillingDetailsExpenseFilterTest.php | 28 +++-- 5 files changed, 145 insertions(+), 51 deletions(-) diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index fae9124d..943cc316 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -1100,17 +1100,27 @@ class Pages extends Controller $section = collect(SidebarPermissions::navigationFor(user()))->firstWhere('slug', 'financial-operations'); abort_unless($section && collect($section['pages'] ?? [])->contains('slug', 'expense-details'), 403, '当前账号没有该页面权限。'); - return $this->streamBillingDetailsExport($request, ['type' => ['费用']], '费用明细'); + return $this->streamBillingDetailsExport( + $request, + ['type' => ['费用']], + '费用明细', + \Modules\EcommerceFinance\View\BillingDetailsPageData::EXPENSE_COLUMNS, + ); } - private function streamBillingDetailsExport(Request $request, array $forcedFilters, string $filenamePrefix): StreamedResponse + private function streamBillingDetailsExport( + Request $request, + array $forcedFilters, + string $filenamePrefix, + ?array $columnDefinitions = null, + ): StreamedResponse { $pageData = app(\Modules\EcommerceFinance\View\BillingDetailsPageData::class); $filters = $pageData->exportFilters($request->all(), $forcedFilters); $columns = $pageData->exportColumns(array_values(array_filter( (array) $request->get('columns', []), static fn ($key): bool => is_string($key) && $key !== '', - ))); + )), $columnDefinitions); $cell = static function (array $row, string $key): string { $income = (float) ($row['income'] ?? 0); @@ -1206,7 +1216,7 @@ class Pages extends Controller try { $summary = app(\Modules\EcommerceFinance\View\BillingDetailsPageData::class) - ->billItemSummary($request->all(), ['type' => ['费用']]); + ->expenseCategorySummary($request->all(), ['type' => ['费用']]); } catch (\Throwable $exception) { report($exception); diff --git a/modules/EcommerceFinance/View/BillingDetailsPageData.php b/modules/EcommerceFinance/View/BillingDetailsPageData.php index 61b8aea8..3ecaba64 100644 --- a/modules/EcommerceFinance/View/BillingDetailsPageData.php +++ b/modules/EcommerceFinance/View/BillingDetailsPageData.php @@ -74,7 +74,7 @@ class BillingDetailsPageData return [ 'view' => 'business.pages.financial-operations.billing-details', 'options' => $this->options(), - 'columns' => self::COLUMNS, + 'columns' => self::EXPENSE_COLUMNS, ] + $this->data(); } @@ -110,6 +110,16 @@ class BillingDetailsPageData 'remark' => ['备注', 'text'], ]; + /** 费用明细精简列:平台、店铺、线上单号、账单项目、总账备注、收支净额。 */ + public const EXPENSE_COLUMNS = [ + 'platform' => ['平台', 'text'], + 'store_name' => ['店铺', 'text'], + 'online_order_no' => ['线上单号', 'text'], + 'bill_item' => ['账单项目', 'tag'], + 'finance_remark' => ['总账备注', 'text'], + 'amount' => ['金额(收-支)', 'num'], + ]; + /** 导出用:按请求解析筛选条件(与页面同一套口径) */ public function exportFilters(array $input, array $forcedFilters = []): array { @@ -147,20 +157,21 @@ class BillingDetailsPageData * @param list $keys * @return array */ - public function exportColumns(array $keys): array + public function exportColumns(array $keys, ?array $definitions = null): array { + $definitions ??= self::COLUMNS; if ($keys === []) { - return self::COLUMNS; + return $definitions; } $wanted = array_flip(array_map(static fn ($key): string => trim((string) $key), $keys)); $columns = []; - foreach (self::COLUMNS as $key => $definition) { + foreach ($definitions as $key => $definition) { if (isset($wanted[$key])) { $columns[$key] = $definition; } } - return $columns === [] ? self::COLUMNS : $columns; + return $columns === [] ? $definitions : $columns; } /** @@ -355,33 +366,35 @@ class BillingDetailsPageData } /** - * 费用明细的账单项目汇总:只响应汇总请求,避免拖慢主表首屏。 + * 费用明细的总账备注分类汇总:只响应汇总请求,避免拖慢主表首屏。 * * @return array{ - * items:list, - * item_count:int, + * categories:list, + * category_count:int, * invoice_net:float, * non_invoice_net:float * } */ - public function billItemSummary(?array $input = null, array $forcedFilters = []): array + public function expenseCategorySummary(?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-v2:' . $signature, + 'billing-details-item-summary-v3:' . $signature, now()->addMinutes(2), function () use ($filters): array { $connection = $this->connection(); $rulesByPlatform = $this->expenseRulesByPlatform(); + $memoByPlatformItem = $this->expenseMemoByPlatformItem(); $parts = []; $bind = []; foreach (self::SOURCES as $label => $meta) { $branch = $this->branch($label, $meta, $filters); $invoiceConditions = []; $nonInvoiceConditions = []; + $memoConditions = []; foreach (self::BRANCH_PLATFORMS[$label] ?? [] as $platform) { $invoiceItems = array_keys($rulesByPlatform[$platform]['invoice_expense'] ?? []); if ($invoiceItems !== []) { @@ -395,11 +408,26 @@ class BillingDetailsPageData . ' AND ' . $this->normalizedItemSql() . ' IN (' . $this->literals($nonInvoiceItems) . '))'; } + $memoItems = []; + foreach ($memoByPlatformItem[$platform] ?? [] as $item => $memo) { + $memoItems[$memo][$item] = true; + } + foreach ($memoItems as $memo => $items) { + $memoConditions[$memo][] = '(' . $this->storePrefixCondition($platform) + . ' AND ' . $this->normalizedItemSql() + . ' IN (' . $this->literals(array_keys($items)) . '))'; + } } - if ($invoiceConditions === [] && $nonInvoiceConditions === []) { + if ($invoiceConditions === [] && $nonInvoiceConditions === [] && $memoConditions === []) { continue; } + $memoSql = 'CASE'; + foreach ($memoConditions as $memo => $conditions) { + $memoSql .= ' WHEN (' . implode(' OR ', $conditions) . ') THEN ' . $this->literal($memo); + } + $memoSql .= ' ELSE ' . $this->literal('未填写总账备注') . ' END'; + $bucketSql = 'CASE'; if ($invoiceConditions !== []) { $bucketSql .= ' WHEN (' . implode(' OR ', $invoiceConditions) @@ -412,11 +440,12 @@ class BillingDetailsPageData $bucketSql .= " ELSE 'unknown' END"; foreach ($branch['scopes'] as $scope) { - $parts[] = 'SELECT ' . $bucketSql . ' AS invoice_bucket, bill_item, ' + $parts[] = 'SELECT ' . $memoSql . ' AS ledger_memo, ' + . $bucketSql . ' AS invoice_bucket, ' . 'COALESCE(SUM(' . $branch['income'] . '),0) AS inc, ' . 'COALESCE(SUM(' . $branch['expense'] . '),0) AS exp ' . 'FROM ' . $branch['table'] . ' WHERE ' . $scope['where'] - . ' GROUP BY invoice_bucket, bill_item'; + . ' GROUP BY ledger_memo, invoice_bucket'; $bind = array_merge($bind, $scope['bind']); } } @@ -435,19 +464,19 @@ class BillingDetailsPageData } else { $nonInvoiceNet += $net; } - $this->accumulateBillItemTotal( + $this->accumulateLedgerMemoTotal( $totals, - (string) ($part->bill_item ?? ''), + (string) ($part->ledger_memo ?? ''), $net, $bucket, ); } - $items = $this->sortedBillItemTotals($totals); + $items = $this->sortedLedgerMemoTotals($totals); return [ - 'items' => $items, - 'item_count' => count($items), + 'categories' => $items, + 'category_count' => count($items), 'invoice_net' => round($invoiceNet, 2), 'non_invoice_net' => round($nonInvoiceNet, 2), ]; @@ -456,30 +485,30 @@ class BillingDetailsPageData } /** @param array $totals */ - private function accumulateBillItemTotal( + private function accumulateLedgerMemoTotal( array &$totals, - string $billItem, + string $ledgerMemo, float $net, string $invoiceBucket, ): void { - $billItem = $this->normalizeItem($billItem) ?: '未填写账单项目'; - $totals[$billItem] ??= ['invoice_net' => 0.0, 'non_invoice_net' => 0.0]; + $ledgerMemo = trim($ledgerMemo) ?: '未填写总账备注'; + $totals[$ledgerMemo] ??= ['invoice_net' => 0.0, 'non_invoice_net' => 0.0]; $key = $invoiceBucket === 'invoice' ? 'invoice_net' : 'non_invoice_net'; - $totals[$billItem][$key] += $net; + $totals[$ledgerMemo][$key] += $net; } /** * @param array $totals - * @return list + * @return list */ - private function sortedBillItemTotals(array $totals): array + private function sortedLedgerMemoTotals(array $totals): array { $items = []; - foreach ($totals as $item => $total) { + foreach ($totals as $memo => $total) { $invoiceNet = round((float) $total['invoice_net'], 2); $nonInvoiceNet = round((float) $total['non_invoice_net'], 2); $items[] = [ - 'item' => $item, + 'memo' => $memo, 'invoice_net' => $invoiceNet, 'non_invoice_net' => $nonInvoiceNet, 'net' => round($invoiceNet + $nonInvoiceNet, 2), @@ -490,7 +519,7 @@ class BillingDetailsPageData return $left['net'] <=> $right['net']; } - return strnatcasecmp($left['item'], $right['item']); + return strnatcasecmp($left['memo'], $right['memo']); }); return $items; @@ -659,6 +688,31 @@ class BillingDetailsPageData return $groups ?? []; } + /** + * 费用分类汇总使用:平台+账单项目 → 总账备注。 + * + * @return array> + */ + private function expenseMemoByPlatformItem(): array + { + $groups = []; + foreach (app(AccountMappingStore::class)->all() as $rule) { + $platform = trim((string) ($rule['platform'] ?? '')); + $item = $this->normalizeItem((string) ($rule['item'] ?? '')); + if ($platform === '' || $item === '' || trim((string) ($rule['category'] ?? '')) !== '费用') { + continue; + } + $memo = trim((string) ($rule['memo'] ?? '')) ?: '未填写总账备注'; + $previous = $groups[$platform][$item] ?? null; + if ($previous !== null && $previous !== $memo) { + throw new \RuntimeException('费用分类无法唯一确定:' . $platform . ' / ' . $item); + } + $groups[$platform][$item] = $memo; + } + + return $groups; + } + /** * 科目映射表里的账单项目同时生成带 / 不带 wx 前缀的查找值, * 保留各账单表的 bill_item 索引,避免对整月数据执行 SUBSTRING。 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 f507a985..fba899dd 100644 --- a/resources/views/business/pages/financial-operations/billing-details.blade.php +++ b/resources/views/business/pages/financial-operations/billing-details.blade.php @@ -137,7 +137,7 @@
数据源=各平台已导入账单(与「店铺收款账户账单」同源);财务备注、财务类型由科目映射表按「平台+账单项目(wx 自动去前缀)」带出,匹配不到的留空并标红。 @if ($expenseOnly) - 本页只展示科目映射表中财务类型=费用的账单;账单项目按当前筛选汇总,金额单位为万。 + 本页只展示科目映射表中财务类型=费用的账单;费用按总账备注分类汇总,金额单位为万。 @else 同一交易单号跨月合并后已与订单实付对平的,差额备注优先显示为差额=跨月收款(合并已对平);未对平仍显示差额待核实。 @endif @@ -257,7 +257,7 @@ @if ($expenseOnly)
- 账单项目汇总 + 费用分类汇总 单位:万;汇总金额 = 收入 - 支出 正在汇总…
@@ -265,7 +265,7 @@ - + @@ -315,16 +315,30 @@
账单项目总账备注 开票金额(万) 不开票金额(万)
+ @if ($expenseOnly) + + + + @else + @endif @forelse ($rows as $row) + @if ($expenseOnly) + + + + + + + @else @@ -339,9 +353,10 @@ + @endif @empty - + @endforelse
平台店铺线上单号账单项目总账备注金额(收-支)记录编码店铺财务备注 线上单号金额账单项目 商户订单号自定义备注 财务类型类型发生时间 收入金额支出金额备注
{{ $row['platform'] }}{{ $row['store_name'] }}{{ $row['online_order_no'] }}{{ $row['bill_item'] }}{!! $row['finance_remark'] !== '' ? e($row['finance_remark']) : '—' !!}{{ number_format((float) $row['income'] - (float) $row['expense'], 2) }}{{ $row['record_code'] }} {{ $row['store_name'] }} {!! $row['finance_remark'] !== '' ? e($row['finance_remark']) : '—' !!}{{ $row['income'] > 0 ? number_format((float) $row['income'], 2) : '' }} {{ $row['expense'] > 0 ? number_format((float) $row['expense'], 2) : '' }} {{ $row['remark'] }}
当前条件下没有数据。
当前条件下没有数据。
@@ -624,7 +639,7 @@ }).join('\r\n'); var link = document.createElement('a'); link.href = URL.createObjectURL(new Blob(['\ufeff', csv], { type: 'text/csv;charset=utf-8' })); - link.download = '账单明细_当前页.csv'; + link.download = @json($pageTitle, JSON_UNESCAPED_UNICODE) + '_当前页.csv'; link.click(); URL.revokeObjectURL(link.href); } @@ -673,9 +688,9 @@ }) .then(function (summary) { summaryRows.replaceChildren(); - (summary.items || []).forEach(function (item) { + (summary.categories || []).forEach(function (item) { var tr = document.createElement('tr'); - [item.item, formatWan(item.invoice_net), formatWan(item.non_invoice_net)] + [item.memo, formatWan(item.invoice_net), formatWan(item.non_invoice_net)] .forEach(function (value, index) { var td = document.createElement('td'); td.textContent = value; @@ -686,10 +701,10 @@ }); summaryTotalInvoice.textContent = formatWan(summary.invoice_net); summaryTotalNonInvoice.textContent = formatWan(summary.non_invoice_net); - summaryStatus.textContent = Number(summary.item_count || 0) > 0 - ? (Number(summary.item_count).toLocaleString('zh-CN') + ' 个账单项目') + summaryStatus.textContent = Number(summary.category_count || 0) > 0 + ? (Number(summary.category_count).toLocaleString('zh-CN') + ' 个费用分类') : '当前筛选没有费用项目'; - summaryWrap.hidden = Number(summary.item_count || 0) === 0; + summaryWrap.hidden = Number(summary.category_count || 0) === 0; }) .catch(function (error) { summaryStatus.textContent = error.message || '汇总暂时无法读取'; diff --git a/tests/Feature/Settings/NavigationTest.php b/tests/Feature/Settings/NavigationTest.php index 6836fe98..c439431b 100644 --- a/tests/Feature/Settings/NavigationTest.php +++ b/tests/Feature/Settings/NavigationTest.php @@ -139,15 +139,18 @@ class NavigationTest extends FeatureTestCase ])) ->assertOk() ->assertSeeText('费用明细') - ->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) ->assertSee('pd-compact-summary', false) + ->assertSeeText('总账备注') ->assertSeeText('开票金额(万)') ->assertSeeText('不开票金额(万)') ->assertDontSee('
', false) + ->assertDontSee('data-col="record_code"', false) ->assertDontSee('id="pdType"', false); } diff --git a/tests/Unit/EcommerceFinance/BillingDetailsExpenseFilterTest.php b/tests/Unit/EcommerceFinance/BillingDetailsExpenseFilterTest.php index 398fdddc..9d8e7cbb 100644 --- a/tests/Unit/EcommerceFinance/BillingDetailsExpenseFilterTest.php +++ b/tests/Unit/EcommerceFinance/BillingDetailsExpenseFilterTest.php @@ -16,26 +16,38 @@ class BillingDetailsExpenseFilterTest extends TestCase ); $this->assertSame(['费用'], $filters['type']); + $this->assertSame([ + 'platform', + 'store_name', + 'online_order_no', + 'bill_item', + 'finance_remark', + 'amount', + ], array_keys(app(BillingDetailsPageData::class)->exportColumns( + [], + BillingDetailsPageData::EXPENSE_COLUMNS, + ))); } - public function test_bill_item_totals_merge_wx_prefix_and_split_invoice_buckets(): void + public function test_ledger_memo_totals_split_invoice_buckets(): void { $data = app(BillingDetailsPageData::class); $totals = []; - $accumulate = new ReflectionMethod(BillingDetailsPageData::class, 'accumulateBillItemTotal'); + $accumulate = new ReflectionMethod(BillingDetailsPageData::class, 'accumulateLedgerMemoTotal'); $accumulate->setAccessible(true); - $accumulate->invokeArgs($data, [&$totals, 'wx技术服务费', -90.0, 'invoice']); - $accumulate->invokeArgs($data, [&$totals, '技术服务费', -10.0, 'invoice']); - $accumulate->invokeArgs($data, [&$totals, '技术服务费', -5.0, 'non_invoice']); + $accumulate->invokeArgs($data, [&$totals, '技术服务费', -90.0, 'invoice']); + $accumulate->invokeArgs($data, [&$totals, '技术服务费', -10.0, 'non_invoice']); + $accumulate->invokeArgs($data, [&$totals, '推广费用', -5.0, 'invoice']); $accumulate->invokeArgs($data, [&$totals, '', -0.5, 'non_invoice']); - $sorted = new ReflectionMethod(BillingDetailsPageData::class, 'sortedBillItemTotals'); + $sorted = new ReflectionMethod(BillingDetailsPageData::class, 'sortedLedgerMemoTotals'); $sorted->setAccessible(true); $rows = $sorted->invoke($data, $totals); $this->assertSame([ - ['item' => '技术服务费', 'invoice_net' => -100.0, 'non_invoice_net' => -5.0, 'net' => -105.0], - ['item' => '未填写账单项目', 'invoice_net' => 0.0, 'non_invoice_net' => -0.5, 'net' => -0.5], + ['memo' => '技术服务费', 'invoice_net' => -90.0, 'non_invoice_net' => -10.0, 'net' => -100.0], + ['memo' => '推广费用', 'invoice_net' => -5.0, 'non_invoice_net' => 0.0, 'net' => -5.0], + ['memo' => '未填写总账备注', 'invoice_net' => 0.0, 'non_invoice_net' => -0.5, 'net' => -0.5], ], $rows); } } -- 2.47.2