From f973153f762fa1b3ff242ded52b1c8abbc539bca Mon Sep 17 00:00:00 2001 From: yueyang <2495544247@qq.com> Date: Fri, 23 Oct 2026 18:49:03 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=B4=A2=E5=8A=A1=E5=88=86=E6=9E=90?= =?UTF-8?q?=E6=8C=89=E5=BC=80=E7=A5=A8=E5=8F=A3=E5=BE=84=E6=8B=86=E5=88=86?= =?UTF-8?q?=E8=B4=B9=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Http/Controllers/Business/Pages.php | 49 +++- .../View/BillingDetailsPageData.php | 210 +++++++++++++++++- .../financial-analysis.blade.php | 13 +- tests/Feature/Settings/NavigationTest.php | 29 ++- 4 files changed, 281 insertions(+), 20 deletions(-) diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index c80a77ce..26dfef09 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -48,6 +48,7 @@ use PhpOffice\PhpSpreadsheet\Writer\Xlsx; use Modules\EcommerceFinance\Repositories\AlibabaAccountBalanceRepository; use Modules\EcommerceFinance\Repositories\AlibabaBillRepository; use Modules\EcommerceFinance\View\BillOffsetPageData; +use Modules\EcommerceFinance\View\BillingDetailsPageData; use Modules\EcommerceFinance\Repositories\CourierInformationMirrorRepository; use Modules\EcommerceFinance\Repositories\CourierSurchargeSettlementMirrorRepository; use Modules\EcommerceFinance\Repositories\ShippingOrderRepository; @@ -2398,6 +2399,7 @@ class Pages extends Controller * 收入 = 业绩明细里(收入金额 + 优惠金额 + 冲减金额 + 退款金额)按各字段记账符号求和 * (冲减、退款取数本身带负号,直接相加即为净收入); * 成本 = 商品成本表的商品成本汇总;运费暂不取数,列上留空。 + * 费用 = 店铺收款账户账单中财务类型为「费用」的支出减收入,按票据口径拆为开票 / 不开票。 */ protected function contentForFinancialAnalysis(): array { @@ -2441,11 +2443,15 @@ class Pages extends Controller $incomeTotal = 0.0; $costTotal = 0.0; + $invoiceExpenseTotal = 0.0; + $nonInvoiceExpenseTotal = 0.0; /** @var array $platformOptions */ $platformOptions = []; foreach ($rows as $row) { $incomeTotal += (float) $row['income']; $costTotal += (float) $row['cost']; + $invoiceExpenseTotal += (float) $row['invoice_expense']; + $nonInvoiceExpenseTotal += (float) $row['non_invoice_expense']; if ($row['platform'] !== '') { $platformOptions[(string) $row['platform']] = true; } @@ -2466,6 +2472,8 @@ class Pages extends Controller 'rows' => count($rows), 'income_total' => round($incomeTotal, 2), 'cost_total' => round($costTotal, 2), + 'invoice_expense_total' => round($invoiceExpenseTotal, 2), + 'non_invoice_expense_total' => round($nonInvoiceExpenseTotal, 2), ], 'financialAnalysisMonths' => $months, 'financialAnalysisPlatforms' => $platformOptions, @@ -2479,7 +2487,7 @@ class Pages extends Controller * 页面与导出共用,保证两边口径一致。 * * @param array{month:string,platform:string,store_name:string} $filters - * @return list + * @return list */ private function financialAnalysisRows(array $filters): array { @@ -2534,14 +2542,14 @@ class Pages extends Controller * * 两张表都是几十万行,聚合结果按月份缓存 5 分钟;筛选在内存里做,避免每次开页重跑分组。 * - * @return array + * @return array */ private function financialAnalysisAggregates(string $reportMonth): array { $connection = (string) config('upstream_mirrors.connection', 'business_mirror'); return cache()->remember( - 'financial-analysis-aggregates-v1:' . $reportMonth, + 'financial-analysis-aggregates-v3:' . $reportMonth . ':' . $this->financialAnalysisMappingSignature(), now()->addMinutes(5), static function () use ($connection, $reportMonth): array { $rows = []; @@ -2559,9 +2567,27 @@ class Pages extends Controller 'store_name' => (string) $row->store_name, 'income' => round((float) $row->income, 2), 'cost' => 0.0, + 'invoice_expense' => 0.0, + 'non_invoice_expense' => 0.0, ]; } + $expenseBuckets = app(BillingDetailsPageData::class) + ->expenseBucketsForMonth(substr($reportMonth, 0, 7)); + foreach ($expenseBuckets as $bucket) { + $key = $bucket['platform'] . "\0" . $bucket['store_name']; + $rows[$key] ??= [ + 'platform' => $bucket['platform'], + 'store_name' => $bucket['store_name'], + 'income' => 0.0, + 'cost' => 0.0, + 'invoice_expense' => 0.0, + 'non_invoice_expense' => 0.0, + ]; + $rows[$key]['invoice_expense'] = round((float) $bucket['invoice_expense'], 2); + $rows[$key]['non_invoice_expense'] = round((float) $bucket['non_invoice_expense'], 2); + } + $costRows = DB::connection($connection)->table('store_receipt_product_cost_rows') ->where('report_month', $reportMonth) ->selectRaw('platform, store_name, SUM(COALESCE(product_cost, 0)) as cost') @@ -2574,6 +2600,8 @@ class Pages extends Controller 'store_name' => $rows[$key]['store_name'] ?? (string) $row->store_name, 'income' => $rows[$key]['income'] ?? 0.0, 'cost' => round((float) $row->cost, 2), + 'invoice_expense' => $rows[$key]['invoice_expense'] ?? 0.0, + 'non_invoice_expense' => $rows[$key]['non_invoice_expense'] ?? 0.0, ]; } @@ -2582,6 +2610,17 @@ class Pages extends Controller ); } + /** 科目映射变化时自动换缓存键,避免费用拆票结果在 5 分钟缓存内继续使用旧口径。 */ + private function financialAnalysisMappingSignature(): string + { + $rules = app(AccountMappingStore::class)->all(); + + return substr(hash( + 'sha256', + (string) json_encode($rules, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) + ), 0, 16); + } + /** * 财务经营分析表导出:按当前筛选条件导出「时间 / 平台 / 店铺 / 收入 / 成本 / 运费」。 */ @@ -2607,7 +2646,7 @@ class Pages extends Controller @set_time_limit(0); $out = fopen('php://output', 'w'); fwrite($out, "\xEF\xBB\xBF"); - fputcsv($out, ['时间', '平台', '店铺', '收入', '成本', '运费']); + fputcsv($out, ['时间', '平台', '店铺', '收入', '成本', '运费', '开票费用', '不开票费用']); foreach ($rows as $row) { fputcsv($out, [ $row['month'], @@ -2617,6 +2656,8 @@ class Pages extends Controller number_format((float) $row['cost'], 2, '.', ''), // 运费暂不取数,先留空列,等口径确认后补。 '', + number_format((float) $row['invoice_expense'], 2, '.', ''), + number_format((float) $row['non_invoice_expense'], 2, '.', ''), ]); } fclose($out); diff --git a/modules/EcommerceFinance/View/BillingDetailsPageData.php b/modules/EcommerceFinance/View/BillingDetailsPageData.php index 4d8220e3..802a2aa2 100644 --- a/modules/EcommerceFinance/View/BillingDetailsPageData.php +++ b/modules/EcommerceFinance/View/BillingDetailsPageData.php @@ -42,6 +42,16 @@ class BillingDetailsPageData '唯品会MP' => '唯品会MP', '唯品会JX' => '唯品会JX', '支付宝' => '支付宝', ]; + /** 账单来源页签 → 财务经营分析表使用的平台列。 */ + private const ACCOUNT_LABELS = [ + '淘系支付宝' => '淘系支付宝', + '淘系微信' => '淘系微信', + '拼多多' => '拼多多', + '京东钱包' => '京东网银钱包', + '抖音' => '抖音', + '得物' => '得物', + ]; + /** * 平台筛选:按「账户 / 数据来源」分类,与「店铺收款账户账单」各页签一致。 * source 对应 self::SOURCES 的分支;prefix 非空时再按店铺前缀切分(京东账单 / 京喜账单同属一张表)。 @@ -330,6 +340,193 @@ class BillingDetailsPageData ]; } + /** + * 财务类型为「费用」的账单金额,按财务经营分析表的「平台-店铺」及票据口径汇总。 + * + * 金额口径为账单「支出 - 收入」,费用返还/退回以负数抵减费用。 + * 票据口径 0.06 / 0.09 归入开票费用,不开票归入不开票费用。 + * + * @return array + */ + public function expenseBucketsForMonth(string $reportMonth): array + { + if (preg_match('/^\d{4}-\d{2}$/', $reportMonth) !== 1) { + return []; + } + + $filters = $this->parseFilters(['month' => $reportMonth]); + $filters['platform'] = []; + $filters['store'] = []; + $filters['item'] = []; + $filters['type'] = []; + $filters['remark'] = []; + $filters['keyword'] = ''; + + $connection = $this->connection(); + $buckets = []; + $rulesByPlatform = $this->expenseRulesByPlatform(); + + foreach (self::SOURCES as $label => $meta) { + $branch = $this->branch($label, $meta, $filters); + $conditions = ['invoice_expense' => [], 'non_invoice_expense' => []]; + foreach (self::BRANCH_PLATFORMS[$label] ?? [] as $platform) { + foreach ($conditions as $field => $_) { + $items = array_keys($rulesByPlatform[$platform][$field] ?? []); + if ($items === []) { + continue; + } + $conditions[$field][] = [ + 'sql' => '(' . $this->storePrefixCondition($platform) + . ' AND bill_item IN (' . $this->literals($this->expenseItemVariants($items)) . '))', + 'account' => $this->accountLabelForPlatform($label, $platform), + ]; + } + } + if ($conditions['invoice_expense'] === [] && $conditions['non_invoice_expense'] === []) { + continue; + } + + $amount = 'COALESCE(' . $branch['expense'] . ', 0) - COALESCE(' . $branch['income'] . ', 0)'; + + foreach ($branch['scopes'] as $scope) { + $parts = []; + $bind = []; + foreach ($conditions as $field => $items) { + foreach ($items as $condition) { + $parts[] = 'SELECT ' . $this->literal((string) $condition['account']) . ' AS account_label, store_name, ' + . ($field === 'invoice_expense' ? 'COALESCE(SUM(' . $amount . '), 0)' : '0') . ' AS invoice_expense, ' + . ($field === 'non_invoice_expense' ? 'COALESCE(SUM(' . $amount . '), 0)' : '0') . ' AS non_invoice_expense ' + . 'FROM ' . $branch['table'] . ' WHERE ' . $scope['where'] + . ' AND ' . $condition['sql'] . ' GROUP BY store_name'; + $bind = array_merge($bind, $scope['bind']); + } + } + $rows = $connection->select( + 'SELECT account_label, store_name, ' + . 'COALESCE(SUM(invoice_expense), 0) AS invoice_expense, ' + . 'COALESCE(SUM(non_invoice_expense), 0) AS non_invoice_expense ' + . 'FROM (' . implode(' UNION ALL ', $parts) . ') expense_rows ' + . 'GROUP BY account_label, store_name', + $bind, + ); + foreach ($rows as $row) { + $store = trim((string) ($row->store_name ?? '')); + $account = trim((string) ($row->account_label ?? '')); + if ($store === '' || $account === '') { + continue; + } + $key = $account . "\0" . $store; + $buckets[$key] ??= [ + 'platform' => $account, + 'store_name' => $store, + 'invoice_expense' => 0.0, + 'non_invoice_expense' => 0.0, + ]; + $buckets[$key]['invoice_expense'] = round( + (float) $buckets[$key]['invoice_expense'] + (float) $row->invoice_expense, + 2, + ); + $buckets[$key]['non_invoice_expense'] = round( + (float) $buckets[$key]['non_invoice_expense'] + (float) $row->non_invoice_expense, + 2, + ); + } + } + } + + foreach ($buckets as &$bucket) { + $bucket['invoice_expense'] = round((float) $bucket['invoice_expense'], 2); + $bucket['non_invoice_expense'] = round((float) $bucket['non_invoice_expense'], 2); + } + unset($bucket); + + return $buckets; + } + + private function accountLabelForPlatform(string $source, string $platform): string + { + if ($source === '京东账单') { + return $platform === '京喜' ? '京喜账单' : '京东账单'; + } + + return self::ACCOUNT_LABELS[$source] ?? $source; + } + + /** + * 财务类型为「费用」的规则,按业务平台和票据口径分组。 + * + * 当前科目映射保证同一平台同一账单项目的「费用」规则票据口径一致; + * 若以后出现同项目既开票又不开票,直接报错,避免静默算错。 + * + * @return array,non_invoice_expense:array}> + */ + private function expenseRulesByPlatform(): array + { + $itemMeta = []; + $feeRules = []; + foreach (app(AccountMappingStore::class)->all() as $rule) { + $platform = trim((string) ($rule['platform'] ?? '')); + $item = $this->normalizeItem((string) ($rule['item'] ?? '')); + $category = trim((string) ($rule['category'] ?? '')); + $invoice = trim((string) ($rule['invoice'] ?? '')); + if ($platform === '' || $item === '') { + continue; + } + + $key = mb_strtolower($platform) . "\0" . mb_strtolower($item); + $itemMeta[$key]['categories'][$category] = true; + $itemMeta[$key]['invoices'][$invoice] = true; + if ($category === '费用') { + $feeRules[] = [$platform, $item, $invoice]; + } + } + + foreach ($feeRules as [$platform, $item, $invoice]) { + $item = $this->normalizeItem($item); + $meta = $itemMeta[mb_strtolower($platform) . "\0" . mb_strtolower($item)] ?? []; + if (count($meta['categories'] ?? []) > 1 || count($meta['invoices'] ?? []) > 1) { + throw new \RuntimeException( + '费用票据口径无法唯一确定:' . $platform . ' / ' . $item, + ); + } + + $bucket = match ($invoice) { + '0.06', '0.09' => 'invoice_expense', + '不开票' => 'non_invoice_expense', + default => throw new \RuntimeException( + '费用票据口径未支持:' . $platform . ' / ' . $item . ' / ' . $invoice, + ), + }; + $groups[$platform][$bucket][$item] = true; + } + + return $groups ?? []; + } + + /** + * 科目映射表里的账单项目同时生成带 / 不带 wx 前缀的查找值, + * 保留各账单表的 bill_item 索引,避免对整月数据执行 SUBSTRING。 + * + * @param list $items + * @return list + */ + private function expenseItemVariants(array $items): array + { + $variants = []; + foreach ($items as $item) { + $item = trim((string) $item); + if ($item === '') { + continue; + } + $variants[$item] = true; + $variants[preg_match('/^wx/i', $item) === 1 ? $this->normalizeItem($item) : 'wx' . $item] = true; + } + $variants = array_keys($variants); + sort($variants); + + return $variants; + } + /** * 筛选联动:按「除该筛选项以外的其它条件」统计各筛选项的可选值与行数,行数为 0 的取值不返回。 * 结果按筛选条件签名缓存 60 秒,避免连续勾选时反复扫同一批数据。 @@ -594,7 +791,7 @@ class BillingDetailsPageData /** * 账单项目 → [平台 => ['memo'=>..,'category'=>..]] 预索引,避免逐项遍历全部规则。 * - * @param array $rules + * @param array $rules * @return array> */ private function ruleIndexByItem(array $rules): array @@ -896,6 +1093,7 @@ class BillingDetailsPageData $row['platform'] = $platform; $row['finance_remark'] = (string) ($rule['memo'] ?? ''); $row['finance_type'] = (string) ($rule['category'] ?? ''); + $row['finance_invoice'] = (string) ($rule['invoice'] ?? ''); $row['matched'] = $rule !== null; // 同一账单项目多条规则时按业务备注/商品名称关键词取最具体的一条(与店铺收款账户账单页同一口径) @@ -921,6 +1119,7 @@ class BillingDetailsPageData if ($matched !== null) { $row['finance_remark'] = (string) ($matched['rule']['memo'] ?? ''); $row['finance_type'] = (string) ($matched['rule']['category'] ?? ''); + $row['finance_invoice'] = (string) ($matched['rule']['invoice'] ?? ''); $row['matched'] = true; } } @@ -1140,7 +1339,7 @@ class BillingDetailsPageData return $this->columnCache[$table]; } - /** 科目映射表:平台|账单项目 → [memo, category](共享数据库,随页面请求实时读取) */ + /** 科目映射表:平台|账单项目 → [memo, category, invoice](共享数据库,随页面请求实时读取) */ private function rules(): array { static $cache = null; @@ -1156,7 +1355,12 @@ class BillingDetailsPageData $prev = $cache[$key] ?? null; $isCatchAll = trim((string) ($rule['memo'] ?? '')) === '' && trim((string) ($rule['goods'] ?? '')) === ''; if ($prev === null || ($isCatchAll && ! $prev['catch'])) { - $cache[$key] = ['memo' => (string) ($rule['memo'] ?? ''), 'category' => (string) ($rule['category'] ?? ''), 'catch' => $isCatchAll]; + $cache[$key] = [ + 'memo' => (string) ($rule['memo'] ?? ''), + 'category' => (string) ($rule['category'] ?? ''), + 'invoice' => (string) ($rule['invoice'] ?? ''), + 'catch' => $isCatchAll, + ]; } } return $cache; diff --git a/resources/views/business/pages/financial-operations/financial-analysis.blade.php b/resources/views/business/pages/financial-operations/financial-analysis.blade.php index 5629516a..08a10dd9 100644 --- a/resources/views/business/pages/financial-operations/financial-analysis.blade.php +++ b/resources/views/business/pages/financial-operations/financial-analysis.blade.php @@ -27,7 +27,7 @@ .financial-analysis-summary .item span { color:var(--vip-content-muted,#595959); font-size:.7rem; } .financial-analysis-summary .item b { font-size:.95rem; font-variant-numeric:tabular-nums; } .financial-analysis-table-wrap { overflow:auto; border:1px solid var(--vip-border,#D8D2C7); border-radius:.5rem; background:#fff; } - .financial-analysis-table { width:100%; min-width:56rem; border-collapse:separate; border-spacing:0; font-size:.78rem; } + .financial-analysis-table { width:100%; min-width:68rem; border-collapse:separate; border-spacing:0; font-size:.78rem; } .financial-analysis-table th,.financial-analysis-table td { padding:.55rem .65rem; border-right:1px solid var(--vip-border,#D8D2C7); border-bottom:1px solid var(--vip-border,#D8D2C7); white-space:nowrap; vertical-align:top; } .financial-analysis-table th { position:sticky; top:0; z-index:1; background:var(--vip-content-accent-soft,#F7F0DE); text-align:left; font-weight:700; } .financial-analysis-table tr:hover td { background:#fcfaf4; } @@ -75,7 +75,8 @@
口径:一行 = 一个月的一个平台的一个店铺。收入 = 业绩明细的「收入金额 + 优惠金额 + 冲减金额 + 退款金额」, 按各字段自身的记账符号求和(冲减、退款在取数时已是负数,相加即为净收入); - 成本 = 商品成本表按店铺汇总的商品成本(四舍五入两位小数);运费暂不取数,列上留空。 + 成本 = 商品成本表按店铺汇总的商品成本(四舍五入两位小数);运费暂不取数,列上留空; + 开票费用 / 不开票费用 = 店铺收款账户账单中财务类型为「费用」的支出减收入,按科目映射表票据口径 0.06 / 0.09 与不开票分别汇总。
@@ -83,6 +84,8 @@
收入合计{{ $money($summary['income_total'] ?? null) }}
成本合计{{ $money($summary['cost_total'] ?? null) }}
运费合计暂不取数
+
开票费用合计{{ $money($summary['invoice_expense_total'] ?? null) }}
+
不开票费用合计{{ $money($summary['non_invoice_expense_total'] ?? null) }}
@@ -94,6 +97,8 @@ 收入 成本 运费 + 开票费用 + 不开票费用 @forelse ($rows ?? [] as $row) @@ -104,9 +109,11 @@ {{ $money($row['income'] ?? null) }} {{ $money($row['cost'] ?? null) }} — + {{ $money($row['invoice_expense'] ?? null) }} + {{ $money($row['non_invoice_expense'] ?? null) }} @empty -
+
{{ $error ? '镜像读取失败,请稍后重试。' : '暂无符合条件的财务经营分析数据;请确认业绩明细与商品成本已生成。' }}
@endforelse diff --git a/tests/Feature/Settings/NavigationTest.php b/tests/Feature/Settings/NavigationTest.php index 20068c09..57fbd4f9 100644 --- a/tests/Feature/Settings/NavigationTest.php +++ b/tests/Feature/Settings/NavigationTest.php @@ -83,7 +83,7 @@ class NavigationTest extends FeatureTestCase public function testPerformanceDetailsPageIsAvailable(): void { - $response = $this->loginAs() + $this->loginAs() ->get(route('business.pages.show', [ 'section' => 'financial-operations', 'page' => 'performance-details', @@ -121,17 +121,26 @@ class NavigationTest extends FeatureTestCase ->assertSee('name="match_status"', false) ->assertSee('/workspace/financial-operations/performance-details/export', false) ->assertSeeText('店铺收款匹配订单明细尚未同步,请先执行 store-receipt:order-match:sync。'); + } - $html = (string) $response->getContent(); - $offsetPosition = strpos($html, 'data-column-key="offset_amount"'); - $compensationPosition = strpos($html, 'data-column-key="default_compensation_amount"'); - $refundPosition = strpos($html, 'data-column-key="refund_amount"'); + public function testFinancialAnalysisPageIncludesInvoiceExpenseColumns(): void + { + $this->loginAs() + ->get(route('business.pages.show', [ + 'section' => 'financial-operations', + 'page' => 'financial-analysis', + ])) + ->assertOk() + ->assertSeeTextInOrder(['收入', '成本', '运费', '开票费用', '不开票费用']) + ->assertSeeText('开票费用合计') + ->assertSeeText('不开票费用合计') + ->assertSee('/workspace/financial-operations/financial-analysis/export', false); - $this->assertNotFalse($offsetPosition); - $this->assertNotFalse($compensationPosition); - $this->assertNotFalse($refundPosition); - $this->assertLessThan($compensationPosition, $offsetPosition); - $this->assertLessThan($refundPosition, $compensationPosition); + $csv = $this->loginAs() + ->get(route('business.financial-operations.financial-analysis.export')) + ->streamedContent(); + + $this->assertStringContainsString('时间,平台,店铺,收入,成本,运费,开票费用,不开票费用', $csv); } public function testFinancialRiskControlPageIsAvailableBetweenFinancialReports(): void