178 lines
11 KiB
Vue
178 lines
11 KiB
Vue
<template>
|
||
<section class="report-page">
|
||
<header class="panel report-toolbar">
|
||
<div>
|
||
<p class="section-kicker">M10-B · 可复算口径</p>
|
||
<h3>经营统计报表</h3>
|
||
<p>所有日期按各门店时区切分;净收入等于成功实收减成功退款。</p>
|
||
</div>
|
||
<div class="report-actions">
|
||
<el-select v-model="storeId" placeholder="全部授权门店" clearable filterable>
|
||
<el-option v-for="store in stores" :key="store.id" :label="store.name" :value="store.id" />
|
||
</el-select>
|
||
<el-date-picker v-model="range" type="daterange" unlink-panels range-separator="至"
|
||
start-placeholder="开始日期" end-placeholder="结束日期" :clearable="false" />
|
||
<el-button-group>
|
||
<el-button @click="setPreset(1)">今日</el-button>
|
||
<el-button @click="setPreset(7)">近 7 日</el-button>
|
||
</el-button-group>
|
||
<el-button type="primary" :icon="RefreshCw" :loading="loading" @click="load">查询</el-button>
|
||
<el-button :icon="Download" :loading="exporting" @click="exportCsv">导出</el-button>
|
||
<el-button v-if="canRebuild" :icon="RotateCcw" :loading="rebuilding" @click="rebuild">重算日汇总</el-button>
|
||
</div>
|
||
</header>
|
||
|
||
<el-alert v-if="errorMessage" type="error" :title="errorMessage" show-icon :closable="false" />
|
||
<template v-if="report">
|
||
<section class="report-metrics">
|
||
<article><span>总净收入</span><strong>{{ money(report.summary.totalNetCents) }}</strong><small>房费 {{ money(report.summary.roomNetCents) }} · 商品 {{ money(report.summary.productNetCents) }}</small></article>
|
||
<article><span>成功退款</span><strong>{{ money(report.summary.roomRefundCents + report.summary.productRefundCents) }}</strong><small>收入均已扣减退款</small></article>
|
||
<article><span>订单 / 下单人数</span><strong>{{ report.summary.orderCount }} / {{ report.summary.customerCount }}</strong><small>{{ report.scope.storeCount }} 家门店</small></article>
|
||
<article><span>实际使用时长</span><strong>{{ duration(report.summary.usedMinutes) }}</strong><small>利用率 {{ percent(report.summary.utilizationBasisPoints) }}</small></article>
|
||
<article><span>保洁成本</span><strong>{{ money(report.summary.cleaningCostCents) }}</strong><small>贡献额 {{ money(report.summary.contributionCents) }}</small></article>
|
||
</section>
|
||
|
||
<section class="panel reconciliation-card">
|
||
<div>
|
||
<p class="section-kicker">日汇总与明细复算</p>
|
||
<h3>{{ reconciliationText }}</h3>
|
||
<p>明细复算 {{ report.reconciliation.expectedRows }} 行,日汇总 {{ report.reconciliation.persistedRows }} 行,差异 {{ report.reconciliation.mismatchRows }} 行。</p>
|
||
</div>
|
||
<el-tag :type="report.reconciliation.status === 'MATCHED' ? 'success' : report.reconciliation.status === 'MISMATCH' ? 'danger' : 'warning'">
|
||
{{ report.reconciliation.status }}
|
||
</el-tag>
|
||
</section>
|
||
|
||
<section class="report-grid">
|
||
<article class="panel">
|
||
<div class="section-heading"><div><p class="section-kicker">支付渠道</p><h3>净收入拆分</h3></div></div>
|
||
<div class="channel-grid">
|
||
<div v-for="channel in channels" :key="channel.key"><span>{{ channel.label }}</span><strong>{{ money(report.summary.channels[channel.key]) }}</strong></div>
|
||
</div>
|
||
</article>
|
||
<article class="panel">
|
||
<div class="section-heading"><div><p class="section-kicker">明细来源</p><h3>复算记录数</h3></div></div>
|
||
<div class="channel-grid">
|
||
<div v-for="(value, key) in report.detailCounts" :key="key"><span>{{ detailLabel(key) }}</span><strong>{{ value }}</strong></div>
|
||
</div>
|
||
</article>
|
||
</section>
|
||
|
||
<section class="panel report-table">
|
||
<div class="section-heading"><div><p class="section-kicker">逐店逐日</p><h3>经营明细</h3></div><span>{{ report.metricVersion }}</span></div>
|
||
<el-table :data="report.daily" stripe max-height="560">
|
||
<el-table-column prop="date" label="日期" width="112" fixed />
|
||
<el-table-column prop="storeName" label="门店" min-width="150" fixed />
|
||
<el-table-column label="净收入" width="120" align="right"><template #default="{ row }">{{ money(row.totalNetCents) }}</template></el-table-column>
|
||
<el-table-column label="商品收入" width="120" align="right"><template #default="{ row }">{{ money(row.productNetCents) }}</template></el-table-column>
|
||
<el-table-column label="保洁成本" width="120" align="right"><template #default="{ row }">{{ money(row.cleaningCostCents) }}</template></el-table-column>
|
||
<el-table-column prop="orderCount" label="订单" width="78" align="right" />
|
||
<el-table-column prop="customerCount" label="人数" width="78" align="right" />
|
||
<el-table-column label="使用时长" width="110" align="right"><template #default="{ row }">{{ duration(row.usedMinutes) }}</template></el-table-column>
|
||
<el-table-column label="利用率" width="100" align="right"><template #default="{ row }">{{ percent(row.utilizationBasisPoints) }}</template></el-table-column>
|
||
<el-table-column prop="timezone" label="时区" width="140" />
|
||
</el-table>
|
||
</section>
|
||
</template>
|
||
</section>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onMounted, ref } from 'vue';
|
||
import { Download, RefreshCw, RotateCcw } from '@lucide/vue';
|
||
import { ElMessage } from 'element-plus';
|
||
import {
|
||
ApiError, downloadBusinessReport, getBusinessReport, listManagedStores,
|
||
rebuildBusinessReport, type ApiSession
|
||
} from '../api';
|
||
import { money } from '../format';
|
||
import { adminSessionState } from '../session';
|
||
import type { BusinessReport, BusinessReportChannel, ManagedStore } from '../types';
|
||
|
||
const props = defineProps<{ session: ApiSession }>();
|
||
const stores = ref<ManagedStore[]>([]);
|
||
const storeId = ref('');
|
||
const range = ref<[Date, Date]>(presetRange(7));
|
||
const report = ref<BusinessReport | null>(null);
|
||
const loading = ref(false);
|
||
const exporting = ref(false);
|
||
const rebuilding = ref(false);
|
||
const errorMessage = ref('');
|
||
const canRebuild = computed(() => adminSessionState.access.roles.includes('PLATFORM_ADMIN')
|
||
|| adminSessionState.access.capabilities.includes('tenant.manage')
|
||
|| adminSessionState.access.capabilities.includes('report.manage'));
|
||
const channels: Array<{ key: BusinessReportChannel; label: string }> = [
|
||
{ key: 'WECHAT', label: '微信' }, { key: 'BALANCE', label: '余额' },
|
||
{ key: 'PACKAGE', label: '套餐' }, { key: 'GROUP_BUY', label: '团购平台' },
|
||
{ key: 'OTHER', label: '其他' }
|
||
];
|
||
const reconciliationText = computed(() => ({
|
||
MATCHED: '日汇总与明细一致', NOT_AGGREGATED: '尚未生成日汇总', MISMATCH: '日汇总需要重算'
|
||
})[report.value?.reconciliation.status ?? 'NOT_AGGREGATED']);
|
||
|
||
function requestInput() {
|
||
return { storeId: storeId.value || undefined, from: dateKey(range.value[0]), to: dateKey(range.value[1]) };
|
||
}
|
||
|
||
async function load() {
|
||
loading.value = true; errorMessage.value = '';
|
||
try { report.value = await getBusinessReport(props.session, requestInput()); }
|
||
catch (error) { report.value = null; capture(error); }
|
||
finally { loading.value = false; }
|
||
}
|
||
|
||
async function rebuild() {
|
||
rebuilding.value = true;
|
||
try {
|
||
const result = await rebuildBusinessReport(props.session, requestInput());
|
||
ElMessage.success(`已加入 ${result.queued} 个日汇总任务;Worker 完成后刷新即可复核。`);
|
||
} catch (error) { capture(error); }
|
||
finally { rebuilding.value = false; }
|
||
}
|
||
|
||
async function exportCsv() {
|
||
exporting.value = true;
|
||
try {
|
||
const blob = await downloadBusinessReport(props.session, requestInput());
|
||
const url = URL.createObjectURL(blob); const anchor = document.createElement('a');
|
||
anchor.href = url; anchor.download = `business-report-${dateKey(range.value[0])}-${dateKey(range.value[1])}.csv`;
|
||
anchor.click(); URL.revokeObjectURL(url);
|
||
} catch (error) { capture(error); }
|
||
finally { exporting.value = false; }
|
||
}
|
||
|
||
function setPreset(days: number) { range.value = presetRange(days); void load(); }
|
||
function presetRange(days: number): [Date, Date] { const to = new Date(); const from = new Date(to); from.setDate(from.getDate() - days + 1); return [from, to]; }
|
||
function dateKey(value: Date) { const year = value.getFullYear(); const month = String(value.getMonth() + 1).padStart(2, '0'); const day = String(value.getDate()).padStart(2, '0'); return `${year}-${month}-${day}`; }
|
||
function duration(minutes: number) { return minutes >= 60 ? `${Math.floor(minutes / 60)} 小时 ${minutes % 60} 分` : `${minutes} 分钟`; }
|
||
function percent(basisPoints: number) { return `${(basisPoints / 100).toFixed(2)}%`; }
|
||
function detailLabel(key: string) { return ({ payments: '房费支付', refunds: '房费退款', productPayments: '商品支付', productRefunds: '商品退款', orders: '房间订单', cleaningEntries: '保洁成本/冲正' } as Record<string, string>)[key] ?? key; }
|
||
function capture(error: unknown) { errorMessage.value = error instanceof ApiError ? `${error.message}(${error.code})` : error instanceof Error ? error.message : '报表请求失败'; }
|
||
|
||
onMounted(async () => {
|
||
try { stores.value = await listManagedStores(props.session); await load(); }
|
||
catch (error) { capture(error); }
|
||
});
|
||
</script>
|
||
|
||
<style scoped>
|
||
.report-page { display: grid; gap: 18px; }
|
||
.report-toolbar { display: flex; justify-content: space-between; gap: 20px; align-items: flex-end; padding: 22px; }
|
||
.report-toolbar h3, .reconciliation-card h3 { margin: 4px 0; }
|
||
.report-toolbar p, .reconciliation-card p { margin: 0; color: var(--text-muted); }
|
||
.report-actions { display: flex; flex-wrap: wrap; gap: 10px; justify-content: flex-end; }
|
||
.report-actions > .el-select { width: 190px; }
|
||
.report-metrics { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 14px; }
|
||
.report-metrics article { background: var(--surface); border: 1px solid var(--border); border-radius: 16px; padding: 18px; display: grid; gap: 7px; }
|
||
.report-metrics span, .report-metrics small { color: var(--text-muted); }
|
||
.report-metrics strong { font-size: 24px; }
|
||
.reconciliation-card { display: flex; justify-content: space-between; align-items: center; padding: 20px; }
|
||
.report-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
|
||
.report-grid > article, .report-table { padding: 20px; }
|
||
.channel-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 10px; }
|
||
.channel-grid > div { background: var(--surface-soft); border-radius: 12px; padding: 14px; display: grid; gap: 6px; }
|
||
.channel-grid span { color: var(--text-muted); font-size: 13px; }
|
||
@media (max-width: 1180px) { .report-toolbar { align-items: stretch; flex-direction: column; } .report-actions { justify-content: flex-start; } .report-metrics { grid-template-columns: repeat(2, 1fr); } }
|
||
@media (max-width: 720px) { .report-metrics, .report-grid { grid-template-columns: 1fr; } .report-actions > * { width: 100% !important; } }
|
||
</style>
|