财务经营分析表:按「时间-平台-店铺」聚合收入与成本
财务经营报表新增「财务经营分析表」页面,一行 = 一个月的一个平台的一个店铺, 表头:时间、平台、店铺、收入、成本、运费。 - 收入 = 业绩明细的(收入金额 + 优惠金额 + 冲减金额 + 退款金额)按各字段记账符号求和 - 成本 = 商品成本表按店铺汇总的商品成本 - 运费暂不取数,页面显示「—」、导出留空列 支持月份(含全部月份)/平台/店铺筛选、汇总卡片与 CSV 导出,聚合结果按月份缓存 5 分钟。
This commit is contained in:
parent
597104430e
commit
d9646b4ac9
@ -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(),
|
||||
|
||||
@ -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>
|
||||
@ -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');
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user