feat(M10-B): 完成可复算经营报表与日汇总
This commit is contained in:
@@ -101,6 +101,7 @@
|
||||
import { computed, onBeforeUnmount, onMounted, ref, type Component } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import {
|
||||
ChartNoAxesCombined,
|
||||
ClipboardList,
|
||||
BellRing,
|
||||
Handshake,
|
||||
@@ -151,6 +152,7 @@ const visibleNavigation = computed(() => ADMIN_NAVIGATION.filter(
|
||||
(item) => isNavigationAllowed(adminSessionState.access, item)
|
||||
));
|
||||
const navigationIcons: Record<string, Component> = {
|
||||
ChartNoAxesCombined,
|
||||
BellRing,
|
||||
ClipboardList,
|
||||
Handshake,
|
||||
|
||||
@@ -24,6 +24,7 @@ import type {
|
||||
DeviceTopology,
|
||||
DeviceType,
|
||||
BusinessStatistics,
|
||||
BusinessReport,
|
||||
ManagedRoom,
|
||||
MemberCard,
|
||||
MemberDetail,
|
||||
@@ -353,6 +354,38 @@ export function getBusinessStatistics(
|
||||
return request<BusinessStatistics>(session, `/statistics?${params}`);
|
||||
}
|
||||
|
||||
export function getBusinessReport(
|
||||
session: ApiSession,
|
||||
input: { storeId?: string; from: string; to: string }
|
||||
) {
|
||||
const params = new URLSearchParams({ from: input.from, to: input.to });
|
||||
if (input.storeId) params.set('storeId', input.storeId);
|
||||
return request<BusinessReport>(session, `/reports/business?${params}`);
|
||||
}
|
||||
|
||||
export function rebuildBusinessReport(
|
||||
session: ApiSession,
|
||||
input: { storeId?: string; from: string; to: string }
|
||||
) {
|
||||
return request<{ queued: number; storeCount: number; dayCount: number }>(
|
||||
session, '/reports/business/rebuild', { method: 'POST', body: JSON.stringify(input) }
|
||||
);
|
||||
}
|
||||
|
||||
export async function downloadBusinessReport(
|
||||
session: ApiSession,
|
||||
input: { storeId?: string; from: string; to: string }
|
||||
) {
|
||||
const params = new URLSearchParams({ from: input.from, to: input.to });
|
||||
if (input.storeId) params.set('storeId', input.storeId);
|
||||
const response = await authorizedFetch(`/reports/business/export?${params}`, {}, session.token.trim());
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
throw new ApiError(String(payload.code || `HTTP_${response.status}`), String(payload.message || '导出失败'), String(payload.traceId || ''));
|
||||
}
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
export function listNotificationTemplates(session: ApiSession, input: { storeId?: string; eventType?: string } = {}) {
|
||||
const params = new URLSearchParams();
|
||||
if (input.storeId) params.set('storeId', input.storeId);
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
<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>
|
||||
@@ -1,5 +1,6 @@
|
||||
export type AdminMenuKey =
|
||||
| 'overview'
|
||||
| 'reports'
|
||||
| 'stores'
|
||||
| 'orders'
|
||||
| 'products'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const ADMIN_NAVIGATION = Object.freeze([
|
||||
{ menuKey: 'reports', path: '/reports', stage: 'M10-B', title: '经营统计、日汇总与明细复算', label: '经营报表', icon: 'ChartNoAxesCombined', capabilities: ['report.read', 'tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
|
||||
{ menuKey: 'overview', path: '/overview', stage: 'M08-D', title: '平台运营总览', label: '运营总览', icon: 'LayoutDashboard', capabilities: ['store.operation.read', 'tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
|
||||
{ menuKey: 'stores', path: '/stores', stage: 'M08-D', title: '门店与房间管理', label: '门店房间', icon: 'Store', capabilities: ['store.operation.read', 'tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
|
||||
{ menuKey: 'orders', path: '/orders', stage: 'M08-D', title: '订单筛选与运营处置', label: '订单运营', icon: 'ClipboardList', capabilities: ['store.operation.read', 'tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
|
||||
|
||||
@@ -9,6 +9,7 @@ import { adminSessionState } from './session';
|
||||
|
||||
const routeComponents = {
|
||||
overview: () => import('./components/OperationsOverviewPanel.vue'),
|
||||
reports: () => import('./components/BusinessReportsPanel.vue'),
|
||||
stores: () => import('./components/StoresRoomsPanel.vue'),
|
||||
orders: () => import('./components/OrdersPanel.vue'),
|
||||
products: () => import('./components/ProductOperationsPanel.vue'),
|
||||
|
||||
@@ -404,6 +404,46 @@ export interface BusinessStatistics {
|
||||
dailyRevenue: Array<{ date: string; total: number; amountCents: number }>;
|
||||
}
|
||||
|
||||
export type BusinessReportChannel = 'WECHAT' | 'BALANCE' | 'PACKAGE' | 'GROUP_BUY' | 'OTHER';
|
||||
|
||||
export interface BusinessReportDaily {
|
||||
storeId: string;
|
||||
storeName: string;
|
||||
date: string;
|
||||
timezone: string;
|
||||
orderCount: number;
|
||||
customerCount: number;
|
||||
usedMinutes: number;
|
||||
capacityMinutes: number;
|
||||
utilizationBasisPoints: number;
|
||||
roomGrossCents: number;
|
||||
roomRefundCents: number;
|
||||
roomNetCents: number;
|
||||
productGrossCents: number;
|
||||
productRefundCents: number;
|
||||
productNetCents: number;
|
||||
totalNetCents: number;
|
||||
cleaningCostCents: number;
|
||||
contributionCents: number;
|
||||
channels: Record<BusinessReportChannel, number>;
|
||||
sourceChecksum: string;
|
||||
}
|
||||
|
||||
export interface BusinessReport {
|
||||
scope: { storeId: string | null; storeCount: number; from: string; to: string };
|
||||
summary: Omit<BusinessReportDaily, 'storeId' | 'storeName' | 'date' | 'timezone' | 'sourceChecksum'>;
|
||||
daily: BusinessReportDaily[];
|
||||
detailCounts: Record<string, number>;
|
||||
reconciliation: {
|
||||
status: 'MATCHED' | 'NOT_AGGREGATED' | 'MISMATCH';
|
||||
expectedRows: number;
|
||||
persistedRows: number;
|
||||
mismatchRows: number;
|
||||
lastCalculatedAt: string | null;
|
||||
};
|
||||
metricVersion: string;
|
||||
}
|
||||
|
||||
export interface ManagedUser {
|
||||
id: string;
|
||||
userType: string;
|
||||
|
||||
@@ -9,13 +9,14 @@ import {
|
||||
|
||||
assert.deepEqual(
|
||||
ADMIN_NAVIGATION.map(({ path }) => path),
|
||||
['/overview', '/stores', '/orders', '/products', '/notifications', '/payments', '/third-party', '/people',
|
||||
['/reports', '/overview', '/stores', '/orders', '/products', '/notifications', '/payments', '/third-party', '/people',
|
||||
'/cleaning', '/devices', '/apps', '/content', '/franchise', '/system']
|
||||
);
|
||||
assert.equal(pathForMenu('platformApps'), '/apps');
|
||||
assert.equal(pathForMenu('thirdParty'), '/third-party');
|
||||
assert.equal(pathForMenu('products'), '/products');
|
||||
assert.equal(pathForMenu('notifications'), '/notifications');
|
||||
assert.equal(pathForMenu('reports'), '/reports');
|
||||
|
||||
const storeOperator = {
|
||||
roles: ['STORE_ADMIN'],
|
||||
@@ -39,6 +40,11 @@ const notificationOperator = {
|
||||
};
|
||||
assert.equal(resolveAuthorizedPath(notificationOperator, 'notifications'), '/notifications');
|
||||
|
||||
const reportOperator = {
|
||||
roles: ['STORE_ADMIN'], capabilities: ['report.read'], menus: ['reports']
|
||||
};
|
||||
assert.equal(resolveAuthorizedPath(reportOperator, 'reports'), '/reports');
|
||||
|
||||
const forgedSystemMenu = {
|
||||
roles: ['STORE_ADMIN'],
|
||||
capabilities: ['store.operation.read'],
|
||||
|
||||
Reference in New Issue
Block a user