财务经营分析表:按「时间-平台-店铺」聚合收入与成本

财务经营报表新增「财务经营分析表」页面,一行 = 一个月的一个平台的一个店铺,
表头:时间、平台、店铺、收入、成本、运费。
- 收入 = 业绩明细的(收入金额 + 优惠金额 + 冲减金额 + 退款金额)按各字段记账符号求和
- 成本 = 商品成本表按店铺汇总的商品成本
- 运费暂不取数,页面显示「—」、导出留空列
支持月份(含全部月份)/平台/店铺筛选、汇总卡片与 CSV 导出,聚合结果按月份缓存 5 分钟。
This commit is contained in:
chenxi 2026-10-11 22:04:54 +08:00
parent 597104430e
commit d9646b4ac9
3 changed files with 356 additions and 0 deletions

View File

@ -1392,6 +1392,234 @@ class Pages extends Controller
];
}
/**
* 财务经营分析表:按「时间(账期)- 平台 - 店铺」聚合。
*
* 收入 = 业绩明细里(收入金额 + 优惠金额 + 冲减金额 + 退款金额)按各字段记账符号求和
* (冲减、退款取数本身带负号,直接相加即为净收入);
* 成本 = 商品成本表的商品成本汇总;运费暂不取数,列上留空。
*/
protected function contentForFinancialAnalysis(): array
{
$limit = min(max((int) setting('default.list_limit', 25), 1), 200);
$page = max((int) request('page', 1), 1);
$months = $this->financialAnalysisMonths();
$monthInput = trim((string) request('month', ''));
$allMonths = $monthInput === 'all';
$month = preg_match('/^\d{4}-\d{2}$/', $monthInput) === 1
? $monthInput . '-01'
: ($months !== [] ? $months[0] . '-01' : null);
if ($allMonths) {
$month = null;
}
$filters = [
'month' => $allMonths ? 'all' : ($month === null ? '' : substr($month, 0, 7)),
'platform' => trim((string) request('platform', '')),
'store_name' => trim((string) request('store_name', '')),
];
$empty = new LengthAwarePaginator([], 0, $limit, $page, [
'path' => request()->url(),
'query' => request()->query(),
]);
if ($month === null && ! $allMonths) {
return [
'view' => 'business.pages.financial-operations.financial-analysis',
'financialAnalysisRows' => $empty,
'financialAnalysisFilters' => $filters,
'financialAnalysisSummary' => [],
'financialAnalysisMonths' => [],
'financialAnalysisPlatforms' => [],
'financialAnalysisError' => null,
];
}
$rows = $this->financialAnalysisRows($filters);
$incomeTotal = 0.0;
$costTotal = 0.0;
/** @var array<string,true> $platformOptions */
$platformOptions = [];
foreach ($rows as $row) {
$incomeTotal += (float) $row['income'];
$costTotal += (float) $row['cost'];
if ($row['platform'] !== '') {
$platformOptions[(string) $row['platform']] = true;
}
}
$platformOptions = array_keys($platformOptions);
sort($platformOptions);
$items = array_slice($rows, ($page - 1) * $limit, $limit);
return [
'view' => 'business.pages.financial-operations.financial-analysis',
'financialAnalysisRows' => new LengthAwarePaginator($items, count($rows), $limit, $page, [
'path' => request()->url(),
'query' => request()->query(),
]),
'financialAnalysisFilters' => $filters,
'financialAnalysisSummary' => [
'rows' => count($rows),
'income_total' => round($incomeTotal, 2),
'cost_total' => round($costTotal, 2),
],
'financialAnalysisMonths' => $months,
'financialAnalysisPlatforms' => $platformOptions,
'financialAnalysisError' => null,
];
}
/**
* 财务经营分析表的行集合:按月(或全部月份)取聚合结果,再套平台 / 店铺筛选并按收入排序。
*
* 页面与导出共用,保证两边口径一致。
*
* @param array{month:string,platform:string,store_name:string} $filters
* @return list<array{month:string,platform:string,store_name:string,income:float,cost:float}>
*/
private function financialAnalysisRows(array $filters): array
{
$months = $this->financialAnalysisMonths();
$sourceMonths = $filters['month'] === 'all'
? array_map(static fn (string $value): string => $value . '-01', $months)
: ($filters['month'] !== '' ? [$filters['month'] . '-01'] : []);
$rows = [];
foreach ($sourceMonths as $sourceMonth) {
foreach ($this->financialAnalysisAggregates($sourceMonth) as $values) {
if ($filters['platform'] !== '' && $values['platform'] !== $filters['platform']) {
continue;
}
if ($filters['store_name'] !== ''
&& ! str_contains((string) $values['store_name'], $filters['store_name'])) {
continue;
}
$rows[] = array_merge(['month' => substr($sourceMonth, 0, 7)], $values);
}
}
usort($rows, static function (array $a, array $b): int {
return [$b['month'], -$a['income'], $a['platform'], $a['store_name']]
<=> [$a['month'], -$b['income'], $b['platform'], $b['store_name']];
});
return $rows;
}
/** 财务经营分析表可选的月份(镜像里实际有业绩明细的账期,倒序)。 */
private function financialAnalysisMonths(): array
{
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
try {
return cache()->remember(
'financial-analysis-months-v1',
now()->addMinutes(10),
static fn () => DB::connection($connection)->table('store_receipt_order_match_rows')
->distinct()->orderByDesc('report_month')->pluck('report_month')
->map(static fn ($value): string => substr((string) $value, 0, 7))->all(),
);
} catch (Throwable) {
return [];
}
}
/**
* 单个月份的「时间-平台-店铺」聚合:收入来自业绩明细,成本来自商品成本表。
*
* 两张表都是几十万行,聚合结果按月份缓存 5 分钟;筛选在内存里做,避免每次开页重跑分组。
*
* @return array<string,array{platform:string,store_name:string,income:float,cost:float}>
*/
private function financialAnalysisAggregates(string $reportMonth): array
{
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
return cache()->remember(
'financial-analysis-aggregates-v1:' . $reportMonth,
now()->addMinutes(5),
static function () use ($connection, $reportMonth): array {
$rows = [];
$incomeRows = DB::connection($connection)->table('store_receipt_order_match_rows')
->where('report_month', $reportMonth)
->selectRaw('platform, store_name, SUM(COALESCE(income_amount, 0) + COALESCE(discount_amount, 0)'
. ' + COALESCE(offset_amount, 0) + COALESCE(refund_amount, 0)) as income')
->groupBy('platform', 'store_name')
->get();
foreach ($incomeRows as $row) {
$key = ((string) $row->platform) . "\0" . ((string) $row->store_name);
$rows[$key] = [
'platform' => (string) $row->platform,
'store_name' => (string) $row->store_name,
'income' => round((float) $row->income, 2),
'cost' => 0.0,
];
}
$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')
->groupBy('platform', 'store_name')
->get();
foreach ($costRows as $row) {
$key = ((string) $row->platform) . "\0" . ((string) $row->store_name);
$rows[$key] = [
'platform' => $rows[$key]['platform'] ?? (string) $row->platform,
'store_name' => $rows[$key]['store_name'] ?? (string) $row->store_name,
'income' => $rows[$key]['income'] ?? 0.0,
'cost' => round((float) $row->cost, 2),
];
}
return $rows;
},
);
}
/**
* 财务经营分析表导出:按当前筛选条件导出「时间 / 平台 / 店铺 / 收入 / 成本 / 运费」。
*/
public function exportFinancialAnalysis(Request $request): StreamedResponse
{
$monthInput = trim((string) $request->query('month', ''));
$months = $this->financialAnalysisMonths();
$filters = [
'month' => $monthInput === 'all'
? 'all'
: (preg_match('/^\d{4}-\d{2}$/', $monthInput) === 1
? $monthInput
: ($months[0] ?? 'all')),
'platform' => trim((string) $request->query('platform', '')),
'store_name' => trim((string) $request->query('store_name', '')),
];
$rows = $this->financialAnalysisRows($filters);
$filename = '财务经营分析表_'
. ($filters['month'] === 'all' ? '全部月份' : $filters['month'])
. '.csv';
return response()->streamDownload(function () use ($rows): void {
@set_time_limit(0);
$out = fopen('php://output', 'w');
fwrite($out, "\xEF\xBB\xBF");
fputcsv($out, ['时间', '平台', '店铺', '收入', '成本', '运费']);
foreach ($rows as $row) {
fputcsv($out, [
$row['month'],
$row['platform'],
$row['store_name'],
number_format((float) $row['income'], 2, '.', ''),
number_format((float) $row['cost'], 2, '.', ''),
// 运费暂不取数,先留空列,等口径确认后补。
'',
]);
}
fclose($out);
}, $filename, ['Content-Type' => 'text/csv; charset=UTF-8']);
}
/**
* 商品成本导出:按当前筛选条件流式导出 CSV,列序与页面一致。
*/
@ -2416,6 +2644,7 @@ class Pages extends Controller
'payable-cloud-warehouse-vehicle-fee' => $this->contentForDingPayableWorkflows(),
'online-daifa-price' => $this->contentForOnlineDaifaPrice(),
'product-costs' => $this->contentForProductCosts(),
'financial-analysis' => $this->contentForFinancialAnalysis(),
'courier-surcharge' => $this->contentForCourierSurcharge(),
'freight-difference', 'other-surcharge-summary', 'return-fee' => $this->contentForCourierPayableSummary($page),
'shipping-data' => $this->contentForShippingData(),

View File

@ -0,0 +1,123 @@
@php
$rows = $financialAnalysisRows ?? null;
$filters = $financialAnalysisFilters ?? [];
$summary = $financialAnalysisSummary ?? [];
$months = $financialAnalysisMonths ?? [];
$platforms = $financialAnalysisPlatforms ?? [];
$error = $financialAnalysisError ?? null;
$money = static fn (mixed $value): string => $value === null ? '-' : number_format((float) $value, 2);
$text = static fn (mixed $value): string => $value === null || $value === '' ? '-' : (string) $value;
@endphp
<div class="financial-analysis-page">
<style>
.financial-analysis-page { color: var(--vip-content-ink, #171717); font-size: .8125rem; }
.financial-analysis-toolbar { display:flex; flex-wrap:wrap; align-items:flex-end; gap:.75rem; padding:1rem; margin-bottom:1rem; border:1px solid var(--vip-border,#D8D2C7); border-radius:.5rem; background:var(--vip-surface,#fff); }
.financial-analysis-field { display:flex; flex-direction:column; gap:.35rem; min-width:12rem; }
.financial-analysis-field label { color:var(--vip-content-muted,#595959); font-size:.75rem; font-weight:600; }
.financial-analysis-field input,.financial-analysis-field select { height:2.25rem; padding:0 .65rem; border:1px solid var(--vip-border,#D8D2C7); border-radius:.5rem; background:#fff; color:var(--vip-content-ink,#171717); }
.financial-analysis-field input:focus,.financial-analysis-field select:focus { border-color:var(--vip-content-accent-dark,#856632); outline:none; box-shadow:0 0 0 3px rgba(133,102,50,.2); }
.financial-analysis-actions { display:flex; gap:.5rem; }
.financial-analysis-button { display:inline-flex; align-items:center; gap:.35rem; height:2.25rem; padding:0 .85rem; border:1px solid var(--vip-border,#D8D2C7); border-radius:.5rem; background:#fff; color:var(--vip-content-ink,#171717); text-decoration:none; font-weight:600; }
.financial-analysis-button.primary { border-color:var(--vip-content-accent-dark,#856632); background:var(--vip-content-accent-dark,#856632); color:#fff; }
.financial-analysis-alert { padding:.75rem 1rem; margin-bottom:1rem; border:1px solid var(--vip-danger,#B80000); border-radius:.5rem; background:#fff5f5; color:var(--vip-danger,#B80000); }
.financial-analysis-note { margin-bottom:.75rem; color:var(--vip-content-muted,#595959); font-size:.75rem; line-height:1.6; }
.financial-analysis-summary { display:flex; flex-wrap:wrap; gap:1rem; padding:.75rem 1rem; margin-bottom:1rem; border:1px solid var(--vip-border,#D8D2C7); border-radius:.5rem; background:var(--vip-content-accent-soft,#F7F0DE); }
.financial-analysis-summary .item { display:flex; flex-direction:column; gap:.15rem; }
.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 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; }
.financial-analysis-table .num { text-align:right; font-variant-numeric:tabular-nums; }
.financial-analysis-table .muted { color:var(--vip-content-muted,#595959); }
.financial-analysis-empty { padding:4rem 1rem; text-align:center; color:var(--vip-content-muted,#595959); }
.financial-analysis-pagination { display:flex; justify-content:space-between; padding:.75rem 1rem; color:var(--vip-content-muted,#595959); }
.financial-analysis-pagination a { color:var(--vip-content-accent-dark,#856632); margin-left:.75rem; }
</style>
<form method="GET" class="financial-analysis-toolbar" aria-label="财务经营分析表筛选">
<div class="financial-analysis-field">
<label for="financial-analysis-month">时间(账期)</label>
<select id="financial-analysis-month" name="month">
<option value="all" @selected(($filters['month'] ?? '') === 'all')>全部月份</option>
@foreach ($months as $option)
<option value="{{ $option }}" @selected(($filters['month'] ?? '') === $option)>{{ $option }}</option>
@endforeach
</select>
</div>
<div class="financial-analysis-field">
<label for="financial-analysis-platform">平台</label>
<select id="financial-analysis-platform" name="platform">
<option value="">全部</option>
@foreach ($platforms as $option)
<option value="{{ $option }}" @selected(($filters['platform'] ?? '') === $option)>{{ $option }}</option>
@endforeach
</select>
</div>
<div class="financial-analysis-field">
<label for="financial-analysis-store">店铺</label>
<input id="financial-analysis-store" name="store_name" value="{{ $filters['store_name'] ?? '' }}" placeholder="支持包含查询">
</div>
<div class="financial-analysis-actions">
<button class="financial-analysis-button primary" type="submit"><span class="material-icons-outlined" aria-hidden="true">search</span>查询</button>
<a class="financial-analysis-button" href="{{ route('business.financial-operations.financial-analysis.export', array_merge(['company_id' => company_id()], request()->except('page'))) }}" aria-label="导出当前筛选结果" title="导出当前筛选结果"><span class="material-icons-outlined" aria-hidden="true">download</span>导出</a>
<a class="financial-analysis-button" href="{{ route('business.pages.show', ['section' => $section['slug'], 'page' => $page['slug']]) }}"><span class="material-icons-outlined" aria-hidden="true">refresh</span>重置</a>
</div>
</form>
@if ($error)
<div class="financial-analysis-alert" role="alert">{{ $error }}</div>
@endif
<div class="financial-analysis-note">
口径:一行 = 一个月的一个平台的一个店铺。收入 = 业绩明细的「收入金额 + 优惠金额 + 冲减金额 + 退款金额」,
按各字段自身的记账符号求和(冲减、退款在取数时已是负数,相加即为净收入);
成本 = 商品成本表按店铺汇总的商品成本(四舍五入两位小数);运费暂不取数,列上留空。
</div>
<div class="financial-analysis-summary">
<div class="item"><span>分组行数</span><b>{{ number_format((int) ($summary['rows'] ?? 0)) }}</b></div>
<div class="item"><span>收入合计</span><b>{{ $money($summary['income_total'] ?? null) }}</b></div>
<div class="item"><span>成本合计</span><b>{{ $money($summary['cost_total'] ?? null) }}</b></div>
<div class="item"><span>运费合计</span><b class="muted">暂不取数</b></div>
</div>
<div class="financial-analysis-table-wrap">
<table class="financial-analysis-table">
<thead><tr>
<th>时间</th>
<th>平台</th>
<th>店铺</th>
<th class="num">收入</th>
<th class="num">成本</th>
<th class="num">运费</th>
</tr></thead>
<tbody>
@forelse ($rows ?? [] as $row)
<tr>
<td>{{ $text($row['month'] ?? null) }}</td>
<td>{{ $text($row['platform'] ?? null) }}</td>
<td>{{ $text($row['store_name'] ?? null) }}</td>
<td class="num">{{ $money($row['income'] ?? null) }}</td>
<td class="num">{{ $money($row['cost'] ?? null) }}</td>
<td class="num muted">—</td>
</tr>
@empty
<tr><td colspan="6"><div class="financial-analysis-empty">
{{ $error ? '镜像读取失败,请稍后重试。' : '暂无符合条件的财务经营分析数据;请确认业绩明细与商品成本已生成。' }}
</div></td></tr>
@endforelse
</tbody>
</table>
<div class="financial-analysis-pagination">
<span>当前页 {{ $rows?->count() ?? 0 }} 条 · 第 {{ $rows?->currentPage() ?? 1 }} 页 · 共 {{ $rows?->total() ?? 0 }} 条</span>
<span>
@if ($rows && ! $rows->onFirstPage())<a href="{{ $rows->previousPageUrl() }}">上一页</a>@endif
@if ($rows && $rows->hasMorePages())<a href="{{ $rows->nextPageUrl() }}">下一页</a>@endif
</span>
</div>
</div>
</div>

View File

@ -205,6 +205,10 @@ Route::get('workspace/product-inventory/monthly-weighted-average-price/export',
->middleware('business.page:financial-operations,product-costs')
->name('business.financial-operations.product-costs.export');
Route::get('workspace/financial-operations/financial-analysis/export', 'Business\Pages@exportFinancialAnalysis')
->middleware('business.page:financial-operations,financial-analysis')
->name('business.financial-operations.financial-analysis.export');
Route::get('workspace/planting-data/jst-order-labels/export', 'Business\Pages@exportJstOrderLabels')
->middleware('business.page:planting-data,jst-order-labels')
->name('business.planting-data.jst-order-labels.export');