Files
qipai/admin/src/components/BusinessReportsPanel.vue
T

178 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>