feat(M10-B): 完成可复算经营报表与日汇总

This commit is contained in:
Codex
2026-08-11 09:50:05 +08:00
parent 5d70114bab
commit ee7b16c932
35 changed files with 1355 additions and 65 deletions
+4 -4
View File
@@ -136,9 +136,9 @@ sh scripts/setup-git-hooks.sh
项目已开发部分模块。具体完成度不得从 README 猜测,必须以现有代码、测试、数据库迁移、Git 历史以及 `docs/current-baseline.md``docs/module-status.md``docs/feature-status.md` 为准。 项目已开发部分模块。具体完成度不得从 README 猜测,必须以现有代码、测试、数据库迁移、Git 历史以及 `docs/current-baseline.md``docs/module-status.md``docs/feature-status.md` 为准。
- 当前执行游标:`M10-B`TODO);M10-A 统一通知中心已完成内部工程,真实微信/企微/云喇叭网关凭据及现场验收保持 `BLOCKED_EXTERNAL` - 当前执行游标:`M10-C`TODO);M10-A 统一通知中心与 M10-B 统计报表已完成内部工程,真实通知渠道凭据仍保持 `BLOCKED_EXTERNAL`
- 最近工程结果:已推送 `41b9cf7` 完成 M09 商品模块回归;当前 M10-A 随本次中文工程提交固化通知模板、事件路由、订阅授权、站内信、HTTPS 渠道、幂等投递、有限重试、人工补发、静默时段、脱敏和后台追踪 - 最近工程结果:已推送 `5d70114` 完成 M10-A;当前 M10-B 随本次中文工程提交固化门店时区报表、收入减退款、房间实际时长/利用率、商品收入、保洁成本、支付渠道、日汇总 checksum 复算、CSV 与后台工作台
- 下一工程目标:执行 `M10-B`补齐可由明细复算的收入、退款、房间使用时长、利用率、商品收入、保洁成本和支付渠道报表 - 下一工程目标:执行 `M10-C`完成多 AppID/租户隔离回归、加盟开通工作流与受控扩展适配器骨架
## 版本递进 ## 版本递进
@@ -147,7 +147,7 @@ sh scripts/setup-git-hooks.sh
- **V5.3**:增加固定模块队列、执行游标、会话内自动推进、可中断恢复和跨模块回归检查点。 - **V5.3**:增加固定模块队列、执行游标、会话内自动推进、可中断恢复和跨模块回归检查点。
- **V5.4**:固化 WSL/生产 MySQL 账号密码、配置位置、真实登录检测和特殊字符处理规则。 - **V5.4**:固化 WSL/生产 MySQL 账号密码、配置位置、真实登录检测和特殊字符处理规则。
- **V5.6**:接管最新源码与在制 M09-A,固化协议返工、后台架构返工、M09/M10 收口队列和 `CODE_COMPLETE` 门禁。 - **V5.6**:接管最新源码与在制 M09-A,固化协议返工、后台架构返工、M09/M10 收口队列和 `CODE_COMPLETE` 门禁。
- **V5.7**:增加中文 Git 提交门禁,完成 M09 商品域M10-A 统一通知中心内部闭环,游标推进至 M10-B - **V5.7**:增加中文 Git 提交门禁,完成 M09 商品域M10-A 通知中心与 M10-B 统计报表内部闭环,游标推进至 M10-C
## Codex 入口 ## Codex 入口
+25 -15
View File
@@ -7072,14 +7072,14 @@ TARGET=CODE_COMPLETE_THEN_RELEASE_CANDIDATE_THEN_PRODUCTION_ACCEPTED
execution_cursor_v57: execution_cursor_v57:
document_version: V5.7 document_version: V5.7
audited_snapshot_sha256: cc802fecbea399698e0a9c1b38127bc12ff260d8f4d6a084828108307f557d6f audited_snapshot_sha256: cc802fecbea399698e0a9c1b38127bc12ff260d8f4d6a084828108307f557d6f
evidence_head: 41b9cf7349f1bcf6eb91fbe9cbf1fb972158622f evidence_head: 5d70114babe9b965055658d678d9dd8b8c2bfb1d
evidence_origin_main: 41b9cf7349f1bcf6eb91fbe9cbf1fb972158622f evidence_origin_main: 5d70114babe9b965055658d678d9dd8b8c2bfb1d
base_branch: main base_branch: main
remote: ssh://git@git.txyundm.cn:2222/panda/qipai.git remote: ssh://git@git.txyundm.cn:2222/panda/qipai.git
current_stage: M10-B current_stage: M10-C
current_stage_status: TODO_AFTER_M10_A_CURRENT_COMMIT current_stage_status: TODO_AFTER_M10_B_CURRENT_COMMIT
last_completed_functional_stage: M10-A last_completed_functional_stage: M10-B
last_engineering_commit: 41b9cf7349f1bcf6eb91fbe9cbf1fb972158622f last_engineering_commit: 5d70114babe9b965055658d678d9dd8b8c2bfb1d
last_tooling_commit: 2de796143ff418d681fdb972040b8555a8e058d1 last_tooling_commit: 2de796143ff418d681fdb972040b8555a8e058d1
m09_d1_business_commit: 93af128002c27ea548e9ed7d0c9af7b8f264002a m09_d1_business_commit: 93af128002c27ea548e9ed7d0c9af7b8f264002a
m09_d1_static_gate: PASS m09_d1_static_gate: PASS
@@ -7113,7 +7113,7 @@ execution_cursor_v57:
m09_regression_mysql_84_roundtrip: PASS_217_UP_159_VERIFY_199_DOWN m09_regression_mysql_84_roundtrip: PASS_217_UP_159_VERIFY_199_DOWN
m09_regression_platform_manage_scope: PASS m09_regression_platform_manage_scope: PASS
m09_regression_order_payment_refund_inventory_storage_reconciliation: PASS m09_regression_order_payment_refund_inventory_storage_reconciliation: PASS
m10_a_business_commit: PENDING_CURRENT_CHINESE_ENGINEERING_COMMIT m10_a_business_commit: 5d70114babe9b965055658d678d9dd8b8c2bfb1d
m10_a_static_gate: PASS m10_a_static_gate: PASS
m10_a_windows_full_test: PASS_76_7_SECONDS m10_a_windows_full_test: PASS_76_7_SECONDS
m10_a_backend_build_and_full_test: PASS m10_a_backend_build_and_full_test: PASS
@@ -7123,7 +7123,17 @@ execution_cursor_v57:
m10_a_outbox_consent_redaction_idempotency: PASS m10_a_outbox_consent_redaction_idempotency: PASS
m10_a_retry_manual_attribution_immutable_attempts: PASS m10_a_retry_manual_attribution_immutable_attempts: PASS
m10_a_configured_http_adapters: PASS m10_a_configured_http_adapters: PASS
default_next_stage: M10-B m10_b_business_commit: PENDING_CURRENT_CHINESE_ENGINEERING_COMMIT
m10_b_static_gate: PASS
m10_b_windows_full_test: PASS_77_5_SECONDS
m10_b_backend_build_and_full_test: PASS
m10_b_admin_build_and_test: PASS
m10_b_mysql_84_roundtrip: PASS_233_UP_171_VERIFY_213_DOWN
m10_b_mysql_cleanup_probe: PASS
m10_b_timezone_dst_boundaries: PASS
m10_b_revenue_refund_product_cleaning_channels: PASS
m10_b_daily_checksum_reconciliation_export: PASS
default_next_stage: M10-C
actual_head_policy: REAL_REPOSITORY_EVIDENCE_WINS actual_head_policy: REAL_REPOSITORY_EVIDENCE_WINS
worktree_protected: true worktree_protected: true
unresolved_deletions_policy: EXCLUDE_FROM_STAGE_COMMIT_UNTIL_USER_CONFIRMS unresolved_deletions_policy: EXCLUDE_FROM_STAGE_COMMIT_UNTIL_USER_CONFIRMS
@@ -7137,7 +7147,7 @@ execution_cursor_v57:
auto_continue_until_code_complete: true auto_continue_until_code_complete: true
``` ```
真实 `D:\qipai` 若有 `41b9cf7` 之后的提交,Codex 必须以 `git merge-base`、diff、迁移、代码、测试、提交和远端 SHA 判断进度,自动跳到第一个未完成阶段。当前 M10-A 的推进依据是通知领域迁移、统一 outbox、渠道适配、权限与客户授权、后台工作台、静态门禁、Windows 全量与真实 MySQL 共同证据,不是 docs-only 状态变更;伴随业务提交完成后进入 M10-B 真实 `D:\qipai` 若有 `5d70114` 之后的提交,Codex 必须以 `git merge-base`、diff、迁移、代码、测试、提交和远端 SHA 判断进度,自动跳到第一个未完成阶段。当前 M10-B 的推进依据是报表迁移、明细复算服务、时区边界、日汇总 Worker、权限与导出、后台工作台、静态门禁、Windows 全量与真实 MySQL 共同证据,不是 docs-only 状态变更;伴随业务提交完成后进入 M10-C
## 30.2 最终唯一队列 ## 30.2 最终唯一队列
@@ -7152,25 +7162,25 @@ M10-A
→ PRODUCTION_ACCEPTED → PRODUCTION_ACCEPTED
``` ```
Git 中文门禁、M09-D1/D2/D3/D4M09-REGRESSION 已由真实工程提交闭环;M10-A 当前伴随业务提交必须完成中文 commit、push 和远端校验后继续 M10-B。不能依据文档中的 `DONE` 单独跳过其他阶段;已完成阶段若出现回归,只做针对性修复,不得重启整个模块。 Git 中文门禁、M09-D1/D2/D3/D4M09-REGRESSION 与 M10-A 已由真实工程提交闭环;M10-B 当前伴随业务提交必须完成中文 commit、push 和远端校验后继续 M10-C。不能依据文档中的 `DONE` 单独跳过其他阶段;已完成阶段若出现回归,只做针对性修复,不得重启整个模块。
M10-A 的唯一允许推进条件: M10-B 的唯一允许推进条件:
```text ```text
M10_A_WORKTREE_PROTECTED=PASS M10_B_WORKTREE_PROTECTED=PASS
M10_A_STATIC_GATE=PASS M10_B_STATIC_GATE=PASS
BACKEND_BUILD=PASS BACKEND_BUILD=PASS
BACKEND_FULL_TEST=PASS BACKEND_FULL_TEST=PASS
MYSQL_84_ROUNDTRIP=PASS MYSQL_84_ROUNDTRIP=PASS
MYSQL_CLEANUP_PROBE=PASS MYSQL_CLEANUP_PROBE=PASS
ADMIN_BUILD_AND_TEST=PASS ADMIN_BUILD_AND_TEST=PASS
NOTIFICATION_DELIVERY_GATES=PASS BUSINESS_REPORT_RECONCILIATION_GATES=PASS
COMMIT_SUBJECT_CN=PASS COMMIT_SUBJECT_CN=PASS
PUSH_VERIFIED=PASS PUSH_VERIFIED=PASS
REMOTE_COMMIT_SUBJECT_CN=PASS REMOTE_COMMIT_SUBJECT_CN=PASS
``` ```
任一项未通过时保持 `CURRENT_STAGE=M10-A`,修复后重跑;不能先更新为 DONE 或进入 M10-B 任一项未通过时保持 `CURRENT_STAGE=M10-B`,修复后重跑;不能先更新为 DONE 或进入 M10-C
## 30.3 每次提交和推送的最终语言门禁 ## 30.3 每次提交和推送的最终语言门禁
+2
View File
@@ -101,6 +101,7 @@
import { computed, onBeforeUnmount, onMounted, ref, type Component } from 'vue'; import { computed, onBeforeUnmount, onMounted, ref, type Component } from 'vue';
import { ElMessage } from 'element-plus'; import { ElMessage } from 'element-plus';
import { import {
ChartNoAxesCombined,
ClipboardList, ClipboardList,
BellRing, BellRing,
Handshake, Handshake,
@@ -151,6 +152,7 @@ const visibleNavigation = computed(() => ADMIN_NAVIGATION.filter(
(item) => isNavigationAllowed(adminSessionState.access, item) (item) => isNavigationAllowed(adminSessionState.access, item)
)); ));
const navigationIcons: Record<string, Component> = { const navigationIcons: Record<string, Component> = {
ChartNoAxesCombined,
BellRing, BellRing,
ClipboardList, ClipboardList,
Handshake, Handshake,
+33
View File
@@ -24,6 +24,7 @@ import type {
DeviceTopology, DeviceTopology,
DeviceType, DeviceType,
BusinessStatistics, BusinessStatistics,
BusinessReport,
ManagedRoom, ManagedRoom,
MemberCard, MemberCard,
MemberDetail, MemberDetail,
@@ -353,6 +354,38 @@ export function getBusinessStatistics(
return request<BusinessStatistics>(session, `/statistics?${params}`); 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 } = {}) { export function listNotificationTemplates(session: ApiSession, input: { storeId?: string; eventType?: string } = {}) {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (input.storeId) params.set('storeId', input.storeId); 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
View File
@@ -1,5 +1,6 @@
export type AdminMenuKey = export type AdminMenuKey =
| 'overview' | 'overview'
| 'reports'
| 'stores' | 'stores'
| 'orders' | 'orders'
| 'products' | 'products'
+1
View File
@@ -1,4 +1,5 @@
export const ADMIN_NAVIGATION = Object.freeze([ 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: '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: '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'] }, { menuKey: 'orders', path: '/orders', stage: 'M08-D', title: '订单筛选与运营处置', label: '订单运营', icon: 'ClipboardList', capabilities: ['store.operation.read', 'tenant.manage', 'platform.manage'], roles: ['PLATFORM_ADMIN'] },
+1
View File
@@ -9,6 +9,7 @@ import { adminSessionState } from './session';
const routeComponents = { const routeComponents = {
overview: () => import('./components/OperationsOverviewPanel.vue'), overview: () => import('./components/OperationsOverviewPanel.vue'),
reports: () => import('./components/BusinessReportsPanel.vue'),
stores: () => import('./components/StoresRoomsPanel.vue'), stores: () => import('./components/StoresRoomsPanel.vue'),
orders: () => import('./components/OrdersPanel.vue'), orders: () => import('./components/OrdersPanel.vue'),
products: () => import('./components/ProductOperationsPanel.vue'), products: () => import('./components/ProductOperationsPanel.vue'),
+40
View File
@@ -404,6 +404,46 @@ export interface BusinessStatistics {
dailyRevenue: Array<{ date: string; total: number; amountCents: number }>; 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 { export interface ManagedUser {
id: string; id: string;
userType: string; userType: string;
+7 -1
View File
@@ -9,13 +9,14 @@ import {
assert.deepEqual( assert.deepEqual(
ADMIN_NAVIGATION.map(({ path }) => path), 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'] '/cleaning', '/devices', '/apps', '/content', '/franchise', '/system']
); );
assert.equal(pathForMenu('platformApps'), '/apps'); assert.equal(pathForMenu('platformApps'), '/apps');
assert.equal(pathForMenu('thirdParty'), '/third-party'); assert.equal(pathForMenu('thirdParty'), '/third-party');
assert.equal(pathForMenu('products'), '/products'); assert.equal(pathForMenu('products'), '/products');
assert.equal(pathForMenu('notifications'), '/notifications'); assert.equal(pathForMenu('notifications'), '/notifications');
assert.equal(pathForMenu('reports'), '/reports');
const storeOperator = { const storeOperator = {
roles: ['STORE_ADMIN'], roles: ['STORE_ADMIN'],
@@ -39,6 +40,11 @@ const notificationOperator = {
}; };
assert.equal(resolveAuthorizedPath(notificationOperator, 'notifications'), '/notifications'); assert.equal(resolveAuthorizedPath(notificationOperator, 'notifications'), '/notifications');
const reportOperator = {
roles: ['STORE_ADMIN'], capabilities: ['report.read'], menus: ['reports']
};
assert.equal(resolveAuthorizedPath(reportOperator, 'reports'), '/reports');
const forgedSystemMenu = { const forgedSystemMenu = {
roles: ['STORE_ADMIN'], roles: ['STORE_ADMIN'],
capabilities: ['store.operation.read'], capabilities: ['store.operation.read'],
+1 -1
View File
@@ -17,7 +17,7 @@
"db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify", "db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify",
"db:migrate:down": "npm run build && node dist/db/migrate-cli.js down", "db:migrate:down": "npm run build && node dist/db/migrate-cli.js down",
"test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs", "test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs",
"pretest": "npm run build && node tests/product-storage-service.test.mjs && node tests/product-storage-route.test.mjs && node tests/notification-adapter.test.mjs && node tests/notification-route.test.mjs", "pretest": "npm run build && node tests/product-storage-service.test.mjs && node tests/product-storage-route.test.mjs && node tests/notification-adapter.test.mjs && node tests/notification-route.test.mjs && node tests/business-report.test.mjs",
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/mqtt-service.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/admin-auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/franchise.test.mjs && node tests/system-operations.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-query.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs && node tests/profit-sharing.test.mjs && node tests/device.test.mjs && node tests/iot-protocol.test.mjs && node tests/device-control.test.mjs && node tests/order-device-automation.test.mjs && node tests/hardware-smoke-runner.test.mjs && node tests/wallet-ledger.test.mjs && node tests/recharge-service.test.mjs && node tests/recharge-route.test.mjs && node tests/marketing-benefit-service.test.mjs && node tests/member-profile-service.test.mjs && node tests/member-profile-route.test.mjs && node tests/cleaning-payout-service.test.mjs && node tests/cleaning-route.test.mjs && node tests/business-statistics.test.mjs && node tests/product-catalog.test.mjs && node tests/product-route.test.mjs && node tests/inventory-service.test.mjs && node tests/inventory-route.test.mjs && node tests/product-order-service.test.mjs && node tests/product-order-route.test.mjs && node tests/product-reconciliation.test.mjs" "test": "npm run build && node tests/backend-contract.test.mjs && node tests/mqtt-service.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/admin-auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/franchise.test.mjs && node tests/system-operations.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-query.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs && node tests/profit-sharing.test.mjs && node tests/device.test.mjs && node tests/iot-protocol.test.mjs && node tests/device-control.test.mjs && node tests/order-device-automation.test.mjs && node tests/hardware-smoke-runner.test.mjs && node tests/wallet-ledger.test.mjs && node tests/recharge-service.test.mjs && node tests/recharge-route.test.mjs && node tests/marketing-benefit-service.test.mjs && node tests/member-profile-service.test.mjs && node tests/member-profile-route.test.mjs && node tests/cleaning-payout-service.test.mjs && node tests/cleaning-route.test.mjs && node tests/business-statistics.test.mjs && node tests/product-catalog.test.mjs && node tests/product-route.test.mjs && node tests/inventory-service.test.mjs && node tests/inventory-route.test.mjs && node tests/product-order-service.test.mjs && node tests/product-order-route.test.mjs && node tests/product-reconciliation.test.mjs"
}, },
"dependencies": { "dependencies": {
+4 -2
View File
@@ -153,7 +153,8 @@ export class AuthRepository {
'inventory.read', 'inventory.adjust', 'inventory.read', 'inventory.adjust',
'goods.order.read', 'goods.order.manage', 'goods.order.read', 'goods.order.manage',
'goods.storage.read', 'goods.storage.manage', 'goods.storage.read', 'goods.storage.manage',
'notification.read', 'notification.manage')) 'notification.read', 'notification.manage',
'report.read', 'report.export'))
OR (r.code IN ('TENANT_ADMIN', 'PLATFORM_ADMIN') OR (r.code IN ('TENANT_ADMIN', 'PLATFORM_ADMIN')
AND p.code IN ('user.read', 'staff.manage', 'session.reset', 'tenant.manage', AND p.code IN ('user.read', 'staff.manage', 'session.reset', 'tenant.manage',
'device.read', 'device.write', 'device.read', 'device.write',
@@ -161,7 +162,8 @@ export class AuthRepository {
'inventory.read', 'inventory.adjust', 'inventory.read', 'inventory.adjust',
'goods.order.read', 'goods.order.manage', 'goods.order.read', 'goods.order.manage',
'goods.storage.read', 'goods.storage.manage', 'goods.storage.read', 'goods.storage.manage',
'notification.read', 'notification.manage')) 'notification.read', 'notification.manage',
'report.read', 'report.export', 'report.manage'))
WHERE r.tenant_id = ?`, WHERE r.tenant_id = ?`,
[input.context.tenantId, input.context.tenantId] [input.context.tenantId, input.context.tenantId]
); );
+2
View File
@@ -53,6 +53,7 @@ export class RbacRepository {
'goods.order.read', 'goods.order.manage', 'goods.order.read', 'goods.order.manage',
'goods.storage.read', 'goods.storage.manage', 'goods.storage.read', 'goods.storage.manage',
'notification.read', 'notification.manage', 'notification.read', 'notification.manage',
'report.read', 'report.export',
'cleaning.task.read', 'cleaning.task.write', 'cleaning.task.read', 'cleaning.task.write',
'cleaning.statistics.read')) 'cleaning.statistics.read'))
OR (r.code IN ('TENANT_ADMIN', 'PLATFORM_ADMIN') OR (r.code IN ('TENANT_ADMIN', 'PLATFORM_ADMIN')
@@ -63,6 +64,7 @@ export class RbacRepository {
'goods.order.read', 'goods.order.manage', 'goods.order.read', 'goods.order.manage',
'goods.storage.read', 'goods.storage.manage', 'goods.storage.read', 'goods.storage.manage',
'notification.read', 'notification.manage', 'notification.read', 'notification.manage',
'report.read', 'report.export', 'report.manage',
'cleaning.task.read', 'cleaning.task.write', 'cleaning.task.read', 'cleaning.task.write',
'cleaning.statistics.read')) 'cleaning.statistics.read'))
WHERE r.tenant_id = ?`, WHERE r.tenant_id = ?`,
+5 -2
View File
@@ -74,7 +74,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026081107_m09d1_product_inventory_foundation.up.sql', 'database/migrations/2026081107_m09d1_product_inventory_foundation.up.sql',
'database/migrations/2026081108_m09d2_product_order_payment_inventory.up.sql', 'database/migrations/2026081108_m09d2_product_order_payment_inventory.up.sql',
'database/migrations/2026081109_m09d3_product_storage.up.sql', 'database/migrations/2026081109_m09d3_product_storage.up.sql',
'database/migrations/2026081110_m10a_notification_center.up.sql' 'database/migrations/2026081110_m10a_notification_center.up.sql',
'database/migrations/2026081111_m10b_business_reports.up.sql'
], ],
verify: [ verify: [
'database/migrations/2026061601_m01b_core_schema.verify.sql', 'database/migrations/2026061601_m01b_core_schema.verify.sql',
@@ -115,9 +116,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026081107_m09d1_product_inventory_foundation.verify.sql', 'database/migrations/2026081107_m09d1_product_inventory_foundation.verify.sql',
'database/migrations/2026081108_m09d2_product_order_payment_inventory.verify.sql', 'database/migrations/2026081108_m09d2_product_order_payment_inventory.verify.sql',
'database/migrations/2026081109_m09d3_product_storage.verify.sql', 'database/migrations/2026081109_m09d3_product_storage.verify.sql',
'database/migrations/2026081110_m10a_notification_center.verify.sql' 'database/migrations/2026081110_m10a_notification_center.verify.sql',
'database/migrations/2026081111_m10b_business_reports.verify.sql'
], ],
down: [ down: [
'database/migrations/2026081111_m10b_business_reports.down.sql',
'database/migrations/2026081110_m10a_notification_center.down.sql', 'database/migrations/2026081110_m10a_notification_center.down.sql',
'database/migrations/2026081109_m09d3_product_storage.down.sql', 'database/migrations/2026081109_m09d3_product_storage.down.sql',
'database/migrations/2026081108_m09d2_product_order_payment_inventory.down.sql', 'database/migrations/2026081108_m09d2_product_order_payment_inventory.down.sql',
@@ -0,0 +1,482 @@
import { createHash } from 'node:crypto';
import type { ResultSetHeader, RowDataPacket } from 'mysql2/promise';
import type { ManagementActor } from '../auth/user-management-repository.js';
import type { MySqlPool } from '../db/mysql.js';
import type { AsyncTask } from '../tasks/task-repository.js';
export type ReportChannel = 'WECHAT' | 'BALANCE' | 'PACKAGE' | 'GROUP_BUY' | 'OTHER';
export interface BusinessDailyMetric {
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<ReportChannel, number>;
sourceChecksum: string;
}
interface StoreRow extends RowDataPacket { id: string; name: string; timezone: string }
interface MoneyRow extends RowDataPacket {
provider: string; amountCents: number; occurredAt: string | Date;
}
interface OrderUsageRow extends RowDataPacket {
id: string; userId: string | null; status: string; createdAt: string | Date;
bookedStart: string | Date; bookedEnd: string | Date;
actualStart: string | Date | null; actualEnd: string | Date | null;
}
interface CostRow extends RowDataPacket { amountCents: number; occurredAt: string | Date }
interface AggregateRow extends RowDataPacket {
storeId: string; businessDate: string | Date; sourceChecksum: string; calculatedAt: string | Date;
}
export class BusinessReportError extends Error {
constructor(public readonly code: string) { super(code); }
}
export class BusinessReportService {
private nextAutomaticEnqueueAt = 0;
constructor(private readonly pool: MySqlPool, private readonly now = () => new Date()) {}
async report(actor: ManagementActor, input: { storeId?: string; from: string; to: string }) {
this.assertCapability(actor, 'report.read');
assertDateRange(input.from, input.to);
const stores = await this.resolveStores(actor, input.storeId);
const daily: BusinessDailyMetric[] = [];
const customerIds = new Set<string>();
const detailCounts = { payments: 0, refunds: 0, productPayments: 0, productRefunds: 0, orders: 0, cleaningEntries: 0 };
for (const store of stores) {
const result = await this.calculateStore(store, actor.tenantId, input.from, input.to);
daily.push(...result.daily);
for (const customerId of result.customerIds) customerIds.add(customerId);
for (const key of Object.keys(detailCounts) as Array<keyof typeof detailCounts>) {
detailCounts[key] += result.counts[key];
}
}
daily.sort((left, right) => left.date.localeCompare(right.date)
|| left.storeName.localeCompare(right.storeName) || left.storeId.localeCompare(right.storeId));
const persisted = await this.loadPersisted(actor.tenantId, stores.map((store) => String(store.id)), input.from, input.to);
const persistedByKey = new Map(persisted.map((row) => [
`${row.storeId}:${formatDate(row.businessDate)}`, row
]));
const mismatches = daily.filter((row) => persistedByKey.get(`${row.storeId}:${row.date}`)?.sourceChecksum !== row.sourceChecksum);
return {
scope: { storeId: input.storeId ?? null, storeCount: stores.length, from: input.from, to: input.to },
summary: { ...summarize(daily), customerCount: customerIds.size },
daily,
detailCounts,
reconciliation: {
status: mismatches.length === 0 ? 'MATCHED' : persisted.length === 0 ? 'NOT_AGGREGATED' : 'MISMATCH',
expectedRows: daily.length,
persistedRows: persisted.length,
mismatchRows: mismatches.length,
lastCalculatedAt: persisted.length
? persisted.map((row) => new Date(row.calculatedAt).toISOString()).sort().at(-1) ?? null : null
},
metricVersion: 'M10-B-v1'
};
}
async enqueueRange(actor: ManagementActor, input: { storeId?: string; from: string; to: string }) {
this.assertCapability(actor, 'report.manage');
assertDateRange(input.from, input.to);
const stores = await this.resolveStores(actor, input.storeId);
let queued = 0;
for (const store of stores) {
for (const date of dateKeys(input.from, input.to)) {
const [result] = await this.pool.execute<ResultSetHeader>(
`INSERT IGNORE INTO qipai_async_tasks
(tenant_id, task_type, idempotency_key, payload, priority, available_at, max_attempts)
VALUES (?, 'statistics.aggregate', ?, CAST(? AS JSON), 10, UTC_TIMESTAMP(3), 5)`,
[actor.tenantId, `report:${store.id}:${date}:${actor.traceId}`,
JSON.stringify({ storeId: String(store.id), date })]
);
queued += result.affectedRows;
}
}
return { queued, storeCount: stores.length, dayCount: dateKeys(input.from, input.to).length };
}
async enqueueDueAggregations() {
const now = this.now();
if (now.getTime() < this.nextAutomaticEnqueueAt) return 0;
this.nextAutomaticEnqueueAt = now.getTime() + 5 * 60_000;
const [stores] = await this.pool.execute<StoreRow[]>(
`SELECT id, name, timezone FROM qipai_stores
WHERE business_status <> 'SUSPENDED' AND deleted_at IS NULL`
);
const hourBucket = now.toISOString().slice(0, 13).replace(/\D/g, '');
let queued = 0;
for (const store of stores) {
const today = localDateKey(now, store.timezone);
for (let offset = -6; offset <= 0; offset += 1) {
const date = shiftDate(today, offset);
const [result] = await this.pool.execute<ResultSetHeader>(
`INSERT IGNORE INTO qipai_async_tasks
(tenant_id, task_type, idempotency_key, payload, priority, available_at, max_attempts)
SELECT tenant_id, 'statistics.aggregate', ?, CAST(? AS JSON), 5, UTC_TIMESTAMP(3), 5
FROM qipai_stores WHERE id = ? AND deleted_at IS NULL`,
[`report:auto:${store.id}:${date}:${hourBucket}`,
JSON.stringify({ storeId: String(store.id), date }), store.id]
);
queued += result.affectedRows;
}
}
return queued;
}
async handleTask(task: AsyncTask) {
const payload = task.payload as { storeId?: unknown; date?: unknown };
const storeId = typeof payload?.storeId === 'string' ? payload.storeId : '';
const date = typeof payload?.date === 'string' ? payload.date : '';
if (!/^[1-9]\d{0,19}$/.test(storeId) || !isDateKey(date)) {
throw new BusinessReportError('BUSINESS_REPORT_TASK_INVALID');
}
const [stores] = await this.pool.execute<StoreRow[]>(
`SELECT id, name, timezone FROM qipai_stores
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL`, [task.tenantId, storeId]
);
if (!stores[0]) throw new BusinessReportError('BUSINESS_REPORT_STORE_NOT_FOUND');
const result = await this.calculateStore(stores[0], task.tenantId, date, date);
await this.persist(task.tenantId, result.daily[0]);
}
private async resolveStores(actor: ManagementActor, storeId?: string) {
if (storeId && !this.canAccessStore(actor, storeId)) {
throw new BusinessReportError('BUSINESS_REPORT_FORBIDDEN');
}
const scopedIds = this.hasTenantScope(actor) ? [] : actor.access.storeIds;
if (!storeId && scopedIds.length === 0 && !this.hasTenantScope(actor)) {
throw new BusinessReportError('BUSINESS_REPORT_FORBIDDEN');
}
const filters = ['tenant_id = ?', 'deleted_at IS NULL'];
const params: string[] = [actor.tenantId];
if (storeId) { filters.push('id = ?'); params.push(storeId); }
else if (scopedIds.length) {
filters.push(`id IN (${scopedIds.map(() => '?').join(', ')})`); params.push(...scopedIds);
}
const [rows] = await this.pool.execute<StoreRow[]>(
`SELECT id, name, timezone FROM qipai_stores WHERE ${filters.join(' AND ')} ORDER BY id`, params
);
if (storeId && !rows[0]) throw new BusinessReportError('BUSINESS_REPORT_STORE_NOT_FOUND');
return rows.map((row) => ({ ...row, id: String(row.id) }));
}
private async calculateStore(store: StoreRow, tenantId: string, from: string, to: string) {
const days = dateKeys(from, to);
const boundaries = new Map(days.map((date) => [date, {
start: zonedDateStart(date, store.timezone),
end: zonedDateStart(shiftDate(date, 1), store.timezone)
}]));
const utcFrom = boundaries.get(days[0])!.start;
const utcTo = boundaries.get(days.at(-1)!)!.end;
const params = [tenantId, store.id, utcFrom, utcTo];
const [paymentsResult, refundsResult, productPaymentsResult, productRefundsResult,
ordersResult, roomResult, paidCostsResult, reversedCostsResult] = await Promise.all([
this.pool.execute<MoneyRow[]>(
`SELECT channel AS provider, amount_cents AS amountCents, paid_at AS occurredAt
FROM qipai_payments WHERE tenant_id = ? AND store_id = ?
AND status IN ('SUCCEEDED', 'PARTIALLY_REFUNDED', 'REFUNDED')
AND paid_at >= ? AND paid_at < ? AND deleted_at IS NULL`, params),
this.pool.execute<MoneyRow[]>(
`SELECT p.channel AS provider, r.amount_cents AS amountCents, r.completed_at AS occurredAt
FROM qipai_refunds r INNER JOIN qipai_payments p
ON p.tenant_id = r.tenant_id AND p.id = r.payment_id
WHERE r.tenant_id = ? AND p.store_id = ? AND r.status = 'SUCCEEDED'
AND r.completed_at >= ? AND r.completed_at < ?`, params),
this.pool.execute<MoneyRow[]>(
`SELECT channel AS provider, amount_cents AS amountCents, paid_at AS occurredAt
FROM qipai_product_payments WHERE tenant_id = ? AND store_id = ?
AND status IN ('SUCCEEDED', 'REFUNDED')
AND paid_at >= ? AND paid_at < ?`, params),
this.pool.execute<MoneyRow[]>(
`SELECT p.channel AS provider, r.amount_cents AS amountCents, r.completed_at AS occurredAt
FROM qipai_product_refunds r INNER JOIN qipai_product_payments p
ON p.tenant_id = r.tenant_id AND p.id = r.payment_id
WHERE r.tenant_id = ? AND r.store_id = ? AND r.status = 'SUCCEEDED'
AND r.completed_at >= ? AND r.completed_at < ?`, params),
this.pool.execute<OrderUsageRow[]>(
`SELECT o.id, owner.user_id AS userId, o.status, o.created_at AS createdAt,
o.start_at AS bookedStart, o.end_at AS bookedEnd,
MIN(CASE WHEN h.to_status = 'IN_PROGRESS' THEN h.created_at END) AS actualStart,
MIN(CASE WHEN h.to_status = 'FINISHED' THEN h.created_at END) AS actualEnd
FROM qipai_orders o
LEFT JOIN qipai_order_user_access owner
ON owner.tenant_id = o.tenant_id AND owner.order_id = o.id AND owner.access_type = 'OWNER'
LEFT JOIN qipai_order_status_history h
ON h.tenant_id = o.tenant_id AND h.order_id = o.id
WHERE o.tenant_id = ? AND o.store_id = ? AND o.deleted_at IS NULL
AND ((o.created_at >= ? AND o.created_at < ?)
OR (o.start_at < ? AND o.end_at > ?)
OR (EXISTS (
SELECT 1 FROM qipai_order_status_history started
WHERE started.tenant_id = o.tenant_id AND started.order_id = o.id
AND started.to_status = 'IN_PROGRESS' AND started.created_at < ?
) AND (
NOT EXISTS (
SELECT 1 FROM qipai_order_status_history finished
WHERE finished.tenant_id = o.tenant_id AND finished.order_id = o.id
AND finished.to_status = 'FINISHED'
) OR EXISTS (
SELECT 1 FROM qipai_order_status_history finished
WHERE finished.tenant_id = o.tenant_id AND finished.order_id = o.id
AND finished.to_status = 'FINISHED' AND finished.created_at > ?
)
)))
GROUP BY o.id, owner.user_id, o.status, o.created_at, o.start_at, o.end_at`,
[tenantId, store.id, utcFrom, utcTo, utcTo, utcFrom, utcTo, utcFrom]),
this.pool.execute<Array<RowDataPacket & { total: number }>>(
`SELECT COUNT(*) AS total FROM qipai_rooms
WHERE tenant_id = ? AND store_id = ? AND deleted_at IS NULL`, [tenantId, store.id]),
this.pool.execute<CostRow[]>(
`SELECT i.reward_cents AS amountCents, s.paid_at AS occurredAt
FROM qipai_cleaning_settlement_items i
INNER JOIN qipai_cleaning_settlements s
ON s.tenant_id = i.tenant_id AND s.id = i.settlement_id
INNER JOIN qipai_cleaning_tasks t
ON t.tenant_id = i.tenant_id AND t.id = i.task_id
WHERE i.tenant_id = ? AND t.store_id = ? AND s.paid_at >= ? AND s.paid_at < ?`, params),
this.pool.execute<CostRow[]>(
`SELECT -CAST(i.reward_cents AS SIGNED) AS amountCents, i.reversed_at AS occurredAt
FROM qipai_cleaning_settlement_items i
INNER JOIN qipai_cleaning_tasks t
ON t.tenant_id = i.tenant_id AND t.id = i.task_id
WHERE i.tenant_id = ? AND t.store_id = ?
AND i.reversed_at >= ? AND i.reversed_at < ?`, params)
]);
const roomCount = Number(roomResult[0][0]?.total ?? 0);
const metrics = new Map(days.map((date) => [date, emptyMetric(store, date, roomCount,
Math.round((boundaries.get(date)!.end.getTime() - boundaries.get(date)!.start.getTime()) / 60000))]));
applyMoney(metrics, paymentsResult[0], store.timezone, 'roomGrossCents', 1);
applyMoney(metrics, refundsResult[0], store.timezone, 'roomRefundCents', -1);
applyMoney(metrics, productPaymentsResult[0], store.timezone, 'productGrossCents', 1);
applyMoney(metrics, productRefundsResult[0], store.timezone, 'productRefundCents', -1);
for (const row of [...paidCostsResult[0], ...reversedCostsResult[0]]) {
const metric = metrics.get(localDateKey(new Date(row.occurredAt), store.timezone));
if (metric) metric.cleaningCostCents += Number(row.amountCents);
}
const customerSets = new Map(days.map((date) => [date, new Set<string>()]));
for (const order of ordersResult[0]) {
const createdDate = localDateKey(new Date(order.createdAt), store.timezone);
const createdMetric = metrics.get(createdDate);
if (createdMetric) {
createdMetric.orderCount += 1;
if (order.userId) customerSets.get(createdDate)!.add(String(order.userId));
}
const actualStart = order.actualStart ? new Date(order.actualStart)
: ['IN_PROGRESS', 'FINISHED', 'CLOSED'].includes(order.status) ? new Date(order.bookedStart) : null;
const actualEnd = order.actualEnd ? new Date(order.actualEnd)
: order.status === 'IN_PROGRESS' ? this.now()
: ['FINISHED', 'CLOSED'].includes(order.status) ? new Date(order.bookedEnd) : null;
if (!actualStart || !actualEnd || actualEnd <= actualStart) continue;
for (const date of days) {
const boundary = boundaries.get(date)!;
const overlapMs = Math.max(0, Math.min(actualEnd.getTime(), boundary.end.getTime())
- Math.max(actualStart.getTime(), boundary.start.getTime()));
metrics.get(date)!.usedMinutes += Math.round(overlapMs / 60000);
}
}
const daily = days.map((date) => finalizeMetric(metrics.get(date)!, customerSets.get(date)!.size));
return {
daily,
customerIds: new Set([...customerSets.values()].flatMap((set) => [...set])),
counts: {
payments: paymentsResult[0].length, refunds: refundsResult[0].length,
productPayments: productPaymentsResult[0].length, productRefunds: productRefundsResult[0].length,
orders: ordersResult[0].length,
cleaningEntries: paidCostsResult[0].length + reversedCostsResult[0].length
}
};
}
private async loadPersisted(tenantId: string, storeIds: string[], from: string, to: string) {
if (!storeIds.length) return [];
const [rows] = await this.pool.execute<AggregateRow[]>(
`SELECT store_id AS storeId, business_date AS businessDate,
source_checksum AS sourceChecksum, calculated_at AS calculatedAt
FROM qipai_business_daily_summaries
WHERE tenant_id = ? AND store_id IN (${storeIds.map(() => '?').join(', ')})
AND business_date >= ? AND business_date <= ?`, [tenantId, ...storeIds, from, to]
);
return rows.map((row) => ({ ...row, storeId: String(row.storeId) }));
}
private async persist(tenantId: string, metric: BusinessDailyMetric) {
await this.pool.execute(
`INSERT INTO qipai_business_daily_summaries
(tenant_id, store_id, business_date, timezone, order_count, customer_count,
used_minutes, capacity_minutes, room_gross_cents, room_refund_cents,
product_gross_cents, product_refund_cents, cleaning_cost_cents,
wechat_net_cents, balance_net_cents, package_net_cents, group_buy_net_cents,
other_net_cents, source_checksum, calculated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, UTC_TIMESTAMP(3))
ON DUPLICATE KEY UPDATE timezone = VALUES(timezone), order_count = VALUES(order_count),
customer_count = VALUES(customer_count), used_minutes = VALUES(used_minutes),
capacity_minutes = VALUES(capacity_minutes), room_gross_cents = VALUES(room_gross_cents),
room_refund_cents = VALUES(room_refund_cents), product_gross_cents = VALUES(product_gross_cents),
product_refund_cents = VALUES(product_refund_cents), cleaning_cost_cents = VALUES(cleaning_cost_cents),
wechat_net_cents = VALUES(wechat_net_cents), balance_net_cents = VALUES(balance_net_cents),
package_net_cents = VALUES(package_net_cents), group_buy_net_cents = VALUES(group_buy_net_cents),
other_net_cents = VALUES(other_net_cents), source_checksum = VALUES(source_checksum),
calculated_at = VALUES(calculated_at)`,
[tenantId, metric.storeId, metric.date, metric.timezone, metric.orderCount, metric.customerCount,
metric.usedMinutes, metric.capacityMinutes, metric.roomGrossCents, metric.roomRefundCents,
metric.productGrossCents, metric.productRefundCents, metric.cleaningCostCents,
metric.channels.WECHAT, metric.channels.BALANCE, metric.channels.PACKAGE,
metric.channels.GROUP_BUY, metric.channels.OTHER, metric.sourceChecksum]
);
}
private assertCapability(actor: ManagementActor, capability: string) {
if (this.hasTenantScope(actor) || actor.access.capabilities.includes(capability)) return;
throw new BusinessReportError('BUSINESS_REPORT_FORBIDDEN');
}
private hasTenantScope(actor: ManagementActor) {
return actor.access.roles.includes('PLATFORM_ADMIN')
|| actor.access.capabilities.includes('tenant.manage');
}
private canAccessStore(actor: ManagementActor, storeId: string) {
return this.hasTenantScope(actor) || actor.access.storeIds.includes(storeId);
}
}
type MutableMetric = Omit<BusinessDailyMetric, 'customerCount' | 'utilizationBasisPoints' |
'roomNetCents' | 'productNetCents' | 'totalNetCents' | 'contributionCents' | 'sourceChecksum'>;
function emptyMetric(store: StoreRow, date: string, roomCount: number, dayMinutes: number): MutableMetric {
return {
storeId: String(store.id), storeName: store.name, date, timezone: store.timezone,
orderCount: 0, usedMinutes: 0, capacityMinutes: roomCount * dayMinutes,
roomGrossCents: 0, roomRefundCents: 0, productGrossCents: 0, productRefundCents: 0,
cleaningCostCents: 0,
channels: { WECHAT: 0, BALANCE: 0, PACKAGE: 0, GROUP_BUY: 0, OTHER: 0 }
};
}
function applyMoney(
metrics: Map<string, MutableMetric>, rows: MoneyRow[], timezone: string,
field: 'roomGrossCents' | 'roomRefundCents' | 'productGrossCents' | 'productRefundCents',
channelSign: 1 | -1
) {
for (const row of rows) {
const metric = metrics.get(localDateKey(new Date(row.occurredAt), timezone));
if (!metric) continue;
const amount = Number(row.amountCents);
metric[field] += amount;
metric.channels[normalizeChannel(row.provider)] += amount * channelSign;
}
}
function finalizeMetric(metric: MutableMetric, customerCount: number): BusinessDailyMetric {
metric.usedMinutes = Math.min(metric.usedMinutes, metric.capacityMinutes);
const roomNetCents = metric.roomGrossCents - metric.roomRefundCents;
const productNetCents = metric.productGrossCents - metric.productRefundCents;
const totalNetCents = roomNetCents + productNetCents;
const value = {
...metric,
customerCount,
utilizationBasisPoints: metric.capacityMinutes
? Math.round(metric.usedMinutes * 10000 / metric.capacityMinutes) : 0,
roomNetCents,
productNetCents,
totalNetCents,
contributionCents: totalNetCents - metric.cleaningCostCents
};
return { ...value, sourceChecksum: checksum(value) };
}
function summarize(rows: BusinessDailyMetric[]) {
const result = {
orderCount: 0, customerCount: 0, usedMinutes: 0, capacityMinutes: 0,
utilizationBasisPoints: 0, roomGrossCents: 0, roomRefundCents: 0, roomNetCents: 0,
productGrossCents: 0, productRefundCents: 0, productNetCents: 0,
totalNetCents: 0, cleaningCostCents: 0, contributionCents: 0,
channels: { WECHAT: 0, BALANCE: 0, PACKAGE: 0, GROUP_BUY: 0, OTHER: 0 } as Record<ReportChannel, number>
};
for (const row of rows) {
for (const key of ['orderCount', 'customerCount', 'usedMinutes', 'capacityMinutes',
'roomGrossCents', 'roomRefundCents', 'roomNetCents', 'productGrossCents',
'productRefundCents', 'productNetCents', 'totalNetCents', 'cleaningCostCents',
'contributionCents'] as const) result[key] += row[key];
for (const channel of Object.keys(result.channels) as ReportChannel[]) result.channels[channel] += row.channels[channel];
}
result.utilizationBasisPoints = result.capacityMinutes
? Math.round(result.usedMinutes * 10000 / result.capacityMinutes) : 0;
return result;
}
function normalizeChannel(value: string): ReportChannel {
return (['WECHAT', 'BALANCE', 'PACKAGE', 'GROUP_BUY'] as const).includes(value as never)
? value as ReportChannel : 'OTHER';
}
function checksum(value: unknown) {
return createHash('sha256').update(JSON.stringify(value)).digest('hex');
}
export function assertDateRange(from: string, to: string) {
if (!isDateKey(from) || !isDateKey(to) || from > to || dateKeys(from, to).length > 93) {
throw new BusinessReportError('BUSINESS_REPORT_RANGE_INVALID');
}
}
function isDateKey(value: string) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
return new Date(`${value}T00:00:00.000Z`).toISOString().slice(0, 10) === value;
}
export function dateKeys(from: string, to: string) {
const result: string[] = [];
for (let value = from; value <= to; value = shiftDate(value, 1)) result.push(value);
return result;
}
function shiftDate(value: string, days: number) {
const date = new Date(`${value}T00:00:00.000Z`);
date.setUTCDate(date.getUTCDate() + days);
return date.toISOString().slice(0, 10);
}
export function localDateKey(date: Date, timezone: string) {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit'
}).formatToParts(date);
const get = (type: Intl.DateTimeFormatPartTypes) => parts.find((part) => part.type === type)?.value ?? '';
return `${get('year')}-${get('month')}-${get('day')}`;
}
export function zonedDateStart(value: string, timezone: string) {
const [year, month, day] = value.split('-').map(Number);
const desired = Date.UTC(year, month - 1, day);
let timestamp = desired;
for (let attempt = 0; attempt < 4; attempt += 1) {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23'
}).formatToParts(new Date(timestamp));
const get = (type: Intl.DateTimeFormatPartTypes) => Number(parts.find((part) => part.type === type)?.value ?? 0);
const displayed = Date.UTC(get('year'), get('month') - 1, get('day'), get('hour'), get('minute'), get('second'));
timestamp += desired - displayed;
}
return new Date(timestamp);
}
function formatDate(value: string | Date) {
return typeof value === 'string' ? value.slice(0, 10) : value.toISOString().slice(0, 10);
}
+1
View File
@@ -113,6 +113,7 @@ function adminAccess(access: AccessProfile) {
const storeRead = tenant || access.capabilities.includes('store.operation.read'); const storeRead = tenant || access.capabilities.includes('store.operation.read');
const menus = [ const menus = [
...(storeRead ? ['overview', 'stores', 'orders', 'thirdParty'] : []), ...(storeRead ? ['overview', 'stores', 'orders', 'thirdParty'] : []),
...(tenant || access.capabilities.includes('report.read') ? ['reports'] : []),
...(tenant || access.capabilities.some((capability) => [ ...(tenant || access.capabilities.some((capability) => [
'product.catalog.read', 'product.catalog.write', 'inventory.read', 'inventory.adjust', 'product.catalog.read', 'product.catalog.write', 'inventory.read', 'inventory.adjust',
'goods.order.read', 'goods.order.manage', 'goods.storage.read', 'goods.storage.manage' 'goods.order.read', 'goods.order.manage', 'goods.storage.read', 'goods.storage.manage'
+91
View File
@@ -8,6 +8,10 @@ import {
BusinessStatisticsError, BusinessStatisticsError,
type BusinessStatisticsRepository type BusinessStatisticsRepository
} from '../operations/business-statistics-repository.js'; } from '../operations/business-statistics-repository.js';
import {
BusinessReportError,
type BusinessReportService
} from '../operations/business-report-service.js';
const querySchema = z.object({ const querySchema = z.object({
storeId: z.string().regex(/^[1-9]\d{0,19}$/), storeId: z.string().regex(/^[1-9]\d{0,19}$/),
@@ -15,8 +19,15 @@ const querySchema = z.object({
to: z.coerce.date().optional() to: z.coerce.date().optional()
}).strict(); }).strict();
const reportQuerySchema = z.object({
storeId: z.string().regex(/^[1-9]\d{0,19}$/).optional(),
from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/)
}).strict();
export interface BusinessStatisticsRouteOptions { export interface BusinessStatisticsRouteOptions {
repository: Pick<BusinessStatisticsRepository, 'overview'>; repository: Pick<BusinessStatisticsRepository, 'overview'>;
reportService?: Pick<BusinessReportService, 'report' | 'enqueueRange'>;
authRepository: Pick<AuthRepository, 'validateSession'>; authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> }; accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
jwtSecret: string; jwtSecret: string;
@@ -56,6 +67,66 @@ export async function registerBusinessStatisticsRoutes(
} }
}); });
} }
if (!options.reportService) return;
for (const path of ['/app-api/management/reports/business', '/admin-api/reports/business']) {
app.get(path, async (request, reply) => {
const actor = await requireActor(request, reply, options);
if (!actor) return;
const query = reportQuerySchema.safeParse(request.query);
if (!query.success) return invalidReport(reply, request.traceId);
try {
return { code: 0, data: await options.reportService!.report(actor, query.data), traceId: request.traceId };
} catch (error) {
return handleReportError(error, reply, request.traceId);
}
});
}
app.get('/admin-api/reports/business/export', async (request, reply) => {
const actor = await requireActor(request, reply, options);
if (!actor) return;
if (!actor.access.capabilities.includes('report.export')
&& !actor.access.capabilities.includes('tenant.manage')
&& !actor.access.roles.includes('PLATFORM_ADMIN')) {
return reply.status(403).send({ code: 'BUSINESS_REPORT_FORBIDDEN', message: 'Report export permission is required.', traceId: request.traceId });
}
const query = reportQuerySchema.safeParse(request.query);
if (!query.success) return invalidReport(reply, request.traceId);
try {
const report = await options.reportService!.report(actor, query.data);
const rows: Array<Array<string | number>> = [[
'日期', '门店', '时区', '订单数', '下单人数', '实际使用分钟', '可用分钟', '利用率(%)',
'房费实收(分)', '房费退款(分)', '房费净收入(分)', '商品实收(分)', '商品退款(分)',
'商品净收入(分)', '总净收入(分)', '保洁成本(分)', '贡献额(分)',
'微信净收入(分)', '余额净收入(分)', '套餐净收入(分)', '团购净收入(分)', '其他净收入(分)'
]];
for (const item of report.daily) rows.push([
item.date, item.storeName, item.timezone, item.orderCount, item.customerCount,
item.usedMinutes, item.capacityMinutes, (item.utilizationBasisPoints / 100).toFixed(2),
item.roomGrossCents, item.roomRefundCents, item.roomNetCents,
item.productGrossCents, item.productRefundCents, item.productNetCents,
item.totalNetCents, item.cleaningCostCents, item.contributionCents,
item.channels.WECHAT, item.channels.BALANCE, item.channels.PACKAGE,
item.channels.GROUP_BUY, item.channels.OTHER
]);
const csv = `\uFEFF${rows.map((row) => row.map(csvCell).join(',')).join('\r\n')}`;
return reply.header('content-type', 'text/csv; charset=utf-8')
.header('content-disposition', `attachment; filename="business-report-${query.data.from}-${query.data.to}.csv"`)
.send(csv);
} catch (error) {
return handleReportError(error, reply, request.traceId);
}
});
app.post('/admin-api/reports/business/rebuild', async (request, reply) => {
const actor = await requireActor(request, reply, options);
if (!actor) return;
const body = reportQuerySchema.safeParse(request.body);
if (!body.success) return invalidReport(reply, request.traceId);
try {
return { code: 0, data: await options.reportService!.enqueueRange(actor, body.data), traceId: request.traceId };
} catch (error) {
return handleReportError(error, reply, request.traceId);
}
});
} }
async function requireActor( async function requireActor(
@@ -94,3 +165,23 @@ function invalid(reply: FastifyReply, traceId: string) {
traceId traceId
}); });
} }
function invalidReport(reply: FastifyReply, traceId: string) {
return reply.status(400).send({
code: 'INVALID_BUSINESS_REPORT_REQUEST',
message: 'The business report request is invalid.',
traceId
});
}
function handleReportError(error: unknown, reply: FastifyReply, traceId: string) {
if (!(error instanceof BusinessReportError)) throw error;
const status = error.code.endsWith('_FORBIDDEN') ? 403
: error.code.endsWith('_NOT_FOUND') ? 404 : 400;
return reply.status(status).send({ code: error.code, message: 'Business report request failed.', traceId });
}
function csvCell(value: string | number) {
const text = String(value);
return /[",\r\n]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
}
+2
View File
@@ -41,6 +41,7 @@ import { MarketingBenefitService } from './wallets/marketing-benefit-service.js'
import { CleaningTaskRepository } from './cleaning/cleaning-task-repository.js'; import { CleaningTaskRepository } from './cleaning/cleaning-task-repository.js';
import { CleaningPayoutService } from './cleaning/cleaning-payout-service.js'; import { CleaningPayoutService } from './cleaning/cleaning-payout-service.js';
import { BusinessStatisticsRepository } from './operations/business-statistics-repository.js'; import { BusinessStatisticsRepository } from './operations/business-statistics-repository.js';
import { BusinessReportService } from './operations/business-report-service.js';
import { FranchiseRepository } from './franchise/franchise-repository.js'; import { FranchiseRepository } from './franchise/franchise-repository.js';
import { SystemOperationsRepository } from './operations/system-operations-repository.js'; import { SystemOperationsRepository } from './operations/system-operations-repository.js';
import { AdminAuthRepository } from './auth/admin-auth-repository.js'; import { AdminAuthRepository } from './auth/admin-auth-repository.js';
@@ -229,6 +230,7 @@ const app = await buildApp({
}, },
businessStatistics: { businessStatistics: {
repository: new BusinessStatisticsRepository(pool), repository: new BusinessStatisticsRepository(pool),
reportService: new BusinessReportService(pool),
authRepository, authRepository,
accessControl, accessControl,
jwtSecret: config.auth.jwtSecret jwtSecret: config.auth.jwtSecret
+7 -1
View File
@@ -15,6 +15,7 @@ import {
createNotificationAdaptersFromEnvironment, NotificationService createNotificationAdaptersFromEnvironment, NotificationService
} from '../notifications/notification-service.js'; } from '../notifications/notification-service.js';
import { OutboxRepository } from './outbox-repository.js'; import { OutboxRepository } from './outbox-repository.js';
import { BusinessReportService } from '../operations/business-report-service.js';
type TaskHandler = (task: AsyncTask) => Promise<void>; type TaskHandler = (task: AsyncTask) => Promise<void>;
@@ -84,11 +85,13 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1
const orderDevices = new OrderDeviceAutomationService(pool, deviceControl); const orderDevices = new OrderDeviceAutomationService(pool, deviceControl);
const notifications = new NotificationService(pool, createNotificationAdaptersFromEnvironment()); const notifications = new NotificationService(pool, createNotificationAdaptersFromEnvironment());
const outbox = new OutboxRepository(pool); const outbox = new OutboxRepository(pool);
const reports = new BusinessReportService(pool);
const worker = new TaskWorker({ const worker = new TaskWorker({
repository: new TaskRepository(pool), repository: new TaskRepository(pool),
handlers: new Map([ handlers: new Map([
['device.command', async (task) => { await orderDevices.handleTask(task); }], ['device.command', async (task) => { await orderDevices.handleTask(task); }],
['notification.dispatch', async (task) => { await notifications.handleTask(task); }], ['notification.dispatch', async (task) => { await notifications.handleTask(task); }],
['statistics.aggregate', async (task) => { await reports.handleTask(task); }],
['outbox.publish', async (task) => { ['outbox.publish', async (task) => {
const payload = task.payload as { eventId?: unknown }; const payload = task.payload as { eventId?: unknown };
const eventId = typeof payload?.eventId === 'string' ? payload.eventId : ''; const eventId = typeof payload?.eventId === 'string' ? payload.eventId : '';
@@ -98,7 +101,10 @@ if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1
}] }]
]), ]),
workerId: `${hostname()}:${process.pid}:${randomUUID()}`, workerId: `${hostname()}:${process.pid}:${randomUUID()}`,
onIdle: async () => { await notifications.enqueuePendingOutbox(); } onIdle: async () => {
await notifications.enqueuePendingOutbox();
await reports.enqueueDueAggregations();
}
}); });
const shutdown = () => worker.stop(); const shutdown = () => worker.stop();
process.on('SIGINT', shutdown); process.on('SIGINT', shutdown);
+113
View File
@@ -0,0 +1,113 @@
import assert from 'node:assert/strict';
import { buildApp } from '../dist/app.js';
import { signAccessToken } from '../dist/auth/jwt.js';
import {
BusinessReportError,
BusinessReportService,
assertDateRange,
localDateKey,
zonedDateStart
} from '../dist/operations/business-report-service.js';
assert.equal(zonedDateStart('2026-08-10', 'Asia/Shanghai').toISOString(), '2026-08-09T16:00:00.000Z');
assert.equal(zonedDateStart('2026-03-08', 'America/New_York').toISOString(), '2026-03-08T05:00:00.000Z');
assert.equal(zonedDateStart('2026-03-09', 'America/New_York').toISOString(), '2026-03-09T04:00:00.000Z');
assert.equal(localDateKey(new Date('2026-08-09T16:00:00.000Z'), 'Asia/Shanghai'), '2026-08-10');
assert.throws(() => assertDateRange('2026-01-01', '2026-04-30'),
(error) => error instanceof BusinessReportError && error.code === 'BUSINESS_REPORT_RANGE_INVALID');
const actor = {
tenantId: '7', userId: '22', traceId: 'report-test', ip: '127.0.0.1', userAgent: 'test',
access: { roles: ['STORE_ADMIN'], capabilities: ['report.read', 'report.export'], storeIds: ['11'] }
};
const calls = [];
const service = new BusinessReportService({
async execute(sql, params = []) {
calls.push({ sql, params });
if (sql.includes('SELECT id, name, timezone FROM qipai_stores WHERE')) {
return [[{ id: '11', name: '浦东店', timezone: 'Asia/Shanghai' }], []];
}
if (sql.includes('FROM qipai_payments WHERE')) {
return [[{ provider: 'WECHAT', amountCents: 10000, occurredAt: '2026-08-10T15:00:00.000Z' }], []];
}
if (sql.includes('FROM qipai_refunds r')) {
return [[{ provider: 'WECHAT', amountCents: 1000, occurredAt: '2026-08-10T15:30:00.000Z' }], []];
}
if (sql.includes('FROM qipai_product_payments WHERE')) {
return [[{ provider: 'WECHAT', amountCents: 2500, occurredAt: '2026-08-10T10:00:00.000Z' }], []];
}
if (sql.includes('FROM qipai_product_refunds r')) {
return [[{ provider: 'WECHAT', amountCents: 500, occurredAt: '2026-08-10T11:00:00.000Z' }], []];
}
if (sql.includes('FROM qipai_orders o') && sql.includes('actualStart')) {
return [[{
id: '91', userId: '31', status: 'FINISHED', createdAt: '2026-08-10T09:00:00.000Z',
bookedStart: '2026-08-10T15:00:00.000Z', bookedEnd: '2026-08-10T17:00:00.000Z',
actualStart: '2026-08-10T15:30:00.000Z', actualEnd: '2026-08-10T16:30:00.000Z'
}], []];
}
if (sql.includes('COUNT(*) AS total FROM qipai_rooms')) return [[{ total: 2 }], []];
if (sql.includes('FROM qipai_cleaning_settlement_items i') && sql.includes('s.paid_at AS occurredAt')) {
return [[{ amountCents: 600, occurredAt: '2026-08-10T12:00:00.000Z' }], []];
}
if (sql.includes('FROM qipai_cleaning_settlement_items i') && sql.includes('i.reversed_at AS occurredAt')) return [[], []];
if (sql.includes('FROM qipai_business_daily_summaries')) return [[], []];
throw new Error(`Unexpected report SQL: ${sql}`);
}
}, () => new Date('2026-08-11T00:00:00.000Z'));
const report = await service.report(actor, { storeId: '11', from: '2026-08-10', to: '2026-08-10' });
assert.equal(report.summary.roomNetCents, 9000);
assert.equal(report.summary.productNetCents, 2000);
assert.equal(report.summary.totalNetCents, 11000);
assert.equal(report.summary.channels.WECHAT, 11000);
assert.equal(report.summary.cleaningCostCents, 600);
assert.equal(report.summary.contributionCents, 10400);
assert.equal(report.summary.orderCount, 1);
assert.equal(report.summary.customerCount, 1);
assert.equal(report.summary.usedMinutes, 30);
assert.equal(report.summary.capacityMinutes, 2880);
assert.equal(report.reconciliation.status, 'NOT_AGGREGATED');
const paymentCall = calls.find(({ sql }) => sql.includes('FROM qipai_payments WHERE'));
assert.equal(paymentCall.params[2].toISOString(), '2026-08-09T16:00:00.000Z');
assert.equal(paymentCall.params[3].toISOString(), '2026-08-10T16:00:00.000Z');
await assert.rejects(
() => service.report({ ...actor, access: { ...actor.access, storeIds: ['12'] } },
{ storeId: '11', from: '2026-08-10', to: '2026-08-10' }),
(error) => error instanceof BusinessReportError && error.code === 'BUSINESS_REPORT_FORBIDDEN'
);
const secret = 'business-report-secret-with-32-characters';
const token = signAccessToken({ sub: '22', sid: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e', tid: '7', aid: '9', rv: 1 }, secret, 900);
let rebuildInput;
const app = await buildApp({
businessStatistics: {
jwtSecret: secret,
authRepository: { async validateSession() { return {
id: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e', tenantId: '7', platformAppId: '9',
expiresAt: new Date(Date.now() + 60000),
user: { id: '22', tenantId: '7', userType: 'STAFF', status: 'ACTIVE', roleVersion: 1, nickname: '', avatarUrl: '', phone: '' }
}; } },
accessControl: { async getAccessProfile() { return actor.access; } },
repository: { async overview() { return {}; } },
reportService: {
async report() { return report; },
async enqueueRange(_actor, input) { rebuildInput = input; return { queued: 1, storeCount: 1, dayCount: 1 }; }
}
}
});
const headers = { authorization: `Bearer ${token}` };
const response = await app.inject({ method: 'GET', url: '/admin-api/reports/business?storeId=11&from=2026-08-10&to=2026-08-10', headers });
assert.equal(response.statusCode, 200);
assert.equal(response.json().data.summary.totalNetCents, 11000);
const exported = await app.inject({ method: 'GET', url: '/admin-api/reports/business/export?storeId=11&from=2026-08-10&to=2026-08-10', headers });
assert.equal(exported.statusCode, 200);
assert.match(exported.headers['content-type'], /text\/csv/);
assert.match(exported.body, /浦东店/);
const rebuilt = await app.inject({ method: 'POST', url: '/admin-api/reports/business/rebuild', headers,
payload: { storeId: '11', from: '2026-08-10', to: '2026-08-10' } });
assert.equal(rebuilt.statusCode, 200);
assert.equal(rebuildInput.storeId, '11');
await app.close();
console.log('PASS: M10-B report metrics, store timezone boundaries, detail reconciliation, CSV and rebuild routes are stable.');
+27 -1
View File
@@ -156,6 +156,15 @@ const notificationDownSql = read(
const notificationVerifySql = read( const notificationVerifySql = read(
'database/migrations/2026081110_m10a_notification_center.verify.sql' 'database/migrations/2026081110_m10a_notification_center.verify.sql'
); );
const businessReportUpSql = read(
'database/migrations/2026081111_m10b_business_reports.up.sql'
);
const businessReportDownSql = read(
'database/migrations/2026081111_m10b_business_reports.down.sql'
);
const businessReportVerifySql = read(
'database/migrations/2026081111_m10b_business_reports.verify.sql'
);
const coreTables = [ const coreTables = [
'qipai_schema_migrations', 'qipai_schema_migrations',
@@ -751,4 +760,21 @@ assert.match(notificationUpSql, /'notification\.read'/);
assert.match(notificationUpSql, /'notification\.manage'/); assert.match(notificationUpSql, /'notification\.manage'/);
assert.match(notificationVerifySql, /'2026081110'/); assert.match(notificationVerifySql, /'2026081110'/);
console.log('PASS: M01-B through M10-A migration contracts are present.'); assert.match(businessReportUpSql, /CREATE TABLE IF NOT EXISTS qipai_business_daily_summaries\b/);
for (const column of ['room_gross_cents', 'room_refund_cents', 'product_gross_cents',
'product_refund_cents', 'used_minutes', 'capacity_minutes', 'cleaning_cost_cents',
'wechat_net_cents', 'balance_net_cents', 'package_net_cents', 'group_buy_net_cents',
'source_checksum']) {
assert.match(businessReportUpSql, new RegExp(`\\b${column}\\b`));
}
for (const permission of ['report.read', 'report.export', 'report.manage']) {
const pattern = new RegExp(permission.replace('.', '\\.'));
assert.match(businessReportUpSql, pattern);
assert.match(businessReportDownSql, pattern);
assert.match(businessReportVerifySql, pattern);
}
assert.match(businessReportUpSql, /uq_qipai_business_daily_summary_date/);
assert.match(businessReportDownSql, /DROP TABLE IF EXISTS qipai_business_daily_summaries/);
assert.match(businessReportVerifySql, /'2026081111'/);
console.log('PASS: M01-B through M10-B migration contracts are present.');
+10 -3
View File
@@ -49,7 +49,8 @@ assert.match(plan.file, /2026081006_m09c_cleaning_settlement_integrity\.up\.sql/
assert.match(plan.file, /2026081107_m09d1_product_inventory_foundation\.up\.sql/); assert.match(plan.file, /2026081107_m09d1_product_inventory_foundation\.up\.sql/);
assert.match(plan.file, /2026081108_m09d2_product_order_payment_inventory\.up\.sql/); assert.match(plan.file, /2026081108_m09d2_product_order_payment_inventory\.up\.sql/);
assert.match(plan.file, /2026081109_m09d3_product_storage\.up\.sql/); assert.match(plan.file, /2026081109_m09d3_product_storage\.up\.sql/);
assert.match(plan.file, /2026081110_m10a_notification_center\.up\.sql$/); assert.match(plan.file, /2026081110_m10a_notification_center\.up\.sql/);
assert.match(plan.file, /2026081111_m10b_business_reports\.up\.sql$/);
assert.match(plan.checksum, /^[a-f0-9]{64}$/); assert.match(plan.checksum, /^[a-f0-9]{64}$/);
assert.ok(plan.statements.length >= 11); assert.ok(plan.statements.length >= 11);
@@ -72,13 +73,19 @@ assert.match(
verifyPlan.file, verifyPlan.file,
/2026081109_m09d3_product_storage\.verify\.sql/ /2026081109_m09d3_product_storage\.verify\.sql/
); );
assert.match(verifyPlan.file, /2026081110_m10a_notification_center\.verify\.sql$/); assert.match(verifyPlan.file, /2026081110_m10a_notification_center\.verify\.sql/);
assert.match(verifyPlan.file, /2026081111_m10b_business_reports\.verify\.sql$/);
const downPlan = await loadMigrationPlan('down'); const downPlan = await loadMigrationPlan('down');
assert.match(downPlan.file, /^database\/migrations\/2026081110_m10a_notification_center\.down\.sql/); assert.match(downPlan.file, /^database\/migrations\/2026081111_m10b_business_reports\.down\.sql/);
assert.match(downPlan.file, /2026081110_m10a_notification_center\.down\.sql/);
assert.match(downPlan.file, /2026081109_m09d3_product_storage\.down\.sql/); assert.match(downPlan.file, /2026081109_m09d3_product_storage\.down\.sql/);
assert.match(downPlan.file, /2026081108_m09d2_product_order_payment_inventory\.down\.sql/); assert.match(downPlan.file, /2026081108_m09d2_product_order_payment_inventory\.down\.sql/);
assert.match(downPlan.file, /2026081107_m09d1_product_inventory_foundation\.down\.sql/); assert.match(downPlan.file, /2026081107_m09d1_product_inventory_foundation\.down\.sql/);
assert.ok(
downPlan.file.indexOf('2026081111_m10b_business_reports.down.sql')
< downPlan.file.indexOf('2026081110_m10a_notification_center.down.sql')
);
assert.ok( assert.ok(
downPlan.file.indexOf('2026081110_m10a_notification_center.down.sql') downPlan.file.indexOf('2026081110_m10a_notification_center.down.sql')
< downPlan.file.indexOf('2026081109_m09d3_product_storage.down.sql') < downPlan.file.indexOf('2026081109_m09d3_product_storage.down.sql')
@@ -62,6 +62,9 @@ import {
ProductStorageError, ProductStorageService, productStorageCredentialDigest ProductStorageError, ProductStorageService, productStorageCredentialDigest
} from '../dist/products/product-storage-service.js'; } from '../dist/products/product-storage-service.js';
import { NotificationService } from '../dist/notifications/notification-service.js'; import { NotificationService } from '../dist/notifications/notification-service.js';
import {
BusinessReportService, zonedDateStart
} from '../dist/operations/business-report-service.js';
import { import {
executeMigrationPlan, executeMigrationPlan,
loadMigrationPlan, loadMigrationPlan,
@@ -74,6 +77,7 @@ const expectedTables = [
'qipai_async_tasks', 'qipai_async_tasks',
'qipai_audit_logs', 'qipai_audit_logs',
'qipai_auth_sessions', 'qipai_auth_sessions',
'qipai_business_daily_summaries',
'qipai_cleaning_settlement_events', 'qipai_cleaning_settlement_events',
'qipai_cleaning_settlement_reversals', 'qipai_cleaning_settlement_reversals',
'qipai_cleaning_task_photos', 'qipai_cleaning_task_photos',
@@ -196,18 +200,29 @@ async function assertProductInventoryMigrationRetry(pool, fullUpPlan) {
repoRoot, repoRoot,
'database/migrations/2026081110_m10a_notification_center' 'database/migrations/2026081110_m10a_notification_center'
); );
const reportMigrationBase = resolve(
repoRoot,
'database/migrations/2026081111_m10b_business_reports'
);
const [upSql, downSql, productOrderDownSql, productStorageDownSql, const [upSql, downSql, productOrderDownSql, productStorageDownSql,
notificationDownSql] = await Promise.all([ notificationDownSql, reportDownSql] = await Promise.all([
readFile(`${migrationBase}.up.sql`, 'utf8'), readFile(`${migrationBase}.up.sql`, 'utf8'),
readFile(`${migrationBase}.down.sql`, 'utf8'), readFile(`${migrationBase}.down.sql`, 'utf8'),
readFile(`${productOrderMigrationBase}.down.sql`, 'utf8'), readFile(`${productOrderMigrationBase}.down.sql`, 'utf8'),
readFile(`${productStorageMigrationBase}.down.sql`, 'utf8'), readFile(`${productStorageMigrationBase}.down.sql`, 'utf8'),
readFile(`${notificationMigrationBase}.down.sql`, 'utf8') readFile(`${notificationMigrationBase}.down.sql`, 'utf8'),
readFile(`${reportMigrationBase}.down.sql`, 'utf8')
]); ]);
const upStatements = splitSqlStatements(upSql); const upStatements = splitSqlStatements(upSql);
let productOrderDownAttempt = 0; let productOrderDownAttempt = 0;
const removeProductOrderDependents = async () => { const removeProductOrderDependents = async () => {
productOrderDownAttempt += 1; productOrderDownAttempt += 1;
await executeMigrationPlan(pool, {
direction: 'down',
file: `${reportMigrationBase}.retry-${productOrderDownAttempt}.down.sql`,
checksum: `m10b-before-m09d1-retry-${productOrderDownAttempt}`,
statements: splitSqlStatements(reportDownSql)
});
await executeMigrationPlan(pool, { await executeMigrationPlan(pool, {
direction: 'down', direction: 'down',
file: `${notificationMigrationBase}.retry-${productOrderDownAttempt}.down.sql`, file: `${notificationMigrationBase}.retry-${productOrderDownAttempt}.down.sql`,
@@ -301,14 +316,15 @@ async function readMigrationVersions(pool) {
const [rows] = await pool.query( const [rows] = await pool.query(
`SELECT version, name `SELECT version, name
FROM qipai_schema_migrations FROM qipai_schema_migrations
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ORDER BY version`, ORDER BY version`,
['2026061601', '2026061802', '2026061803', '2026061804', ['2026061601', '2026061802', '2026061803', '2026061804',
'2026061805', '2026061806', '2026061807', '2026061808', '2026061809', '2026061805', '2026061806', '2026061807', '2026061808', '2026061809',
'2026061810', '2026061811', '2026062012', '2026062013', '2026062014', '2026061810', '2026061811', '2026062012', '2026062013', '2026062014',
'2026062015', '2026062216', '2026062217', '2026062218', '2026062219', '2026062015', '2026062216', '2026062217', '2026062218', '2026062219',
'2026062220', '2026081002', '2026081003', '2026081004', '2026081005', '2026062220', '2026081002', '2026081003', '2026081004', '2026081005',
'2026081006', '2026081107', '2026081108', '2026081109', '2026081110'] '2026081006', '2026081107', '2026081108', '2026081109', '2026081110',
'2026081111']
); );
return rows; return rows;
} }
@@ -1996,7 +2012,7 @@ async function assertSystemOperations(pool, context) {
assert.equal(logs.items[0].metadata.nested.safe, 'visible'); assert.equal(logs.items[0].metadata.nested.safe, 'visible');
const overview = await repository.getSystemOverview(context.tenantId); const overview = await repository.getSystemOverview(context.tenantId);
assert.equal(overview.tenant.id, context.tenantId); assert.equal(overview.tenant.id, context.tenantId);
assert.equal(overview.latestMigration.version, '2026081110'); assert.equal(overview.latestMigration.version, '2026081111');
assert.ok(overview.counts.userCount > 0); assert.ok(overview.counts.userCount > 0);
await repository.updateTenant(actor, context.tenantId, { await repository.updateTenant(actor, context.tenantId, {
name: overview.tenant.name, timezone: overview.tenant.timezone name: overview.tenant.name, timezone: overview.tenant.timezone
@@ -4812,6 +4828,73 @@ async function assertNotificationCenter(pool, context) {
console.log('PASS: M10-A outbox fan-out, redaction, consent suppression, delivery, retry and immutable attempts are consistent.'); console.log('PASS: M10-A outbox fan-out, redaction, consent suppression, delivery, retry and immutable attempts are consistent.');
} }
async function assertBusinessReports(pool, context) {
const [adminRows] = await pool.query(
`SELECT u.id FROM qipai_users u
INNER JOIN qipai_user_roles ur ON ur.tenant_id = u.tenant_id AND ur.user_id = u.id
INNER JOIN qipai_roles r ON r.tenant_id = ur.tenant_id AND r.id = ur.role_id
WHERE u.tenant_id = ? AND r.code = 'TENANT_ADMIN' AND u.deleted_at IS NULL LIMIT 1`,
[context.tenantId]
);
const [storeRows] = await pool.query(
`SELECT id, timezone FROM qipai_stores
WHERE tenant_id = ? AND deleted_at IS NULL ORDER BY id LIMIT 1`, [context.tenantId]
);
assert.ok(adminRows[0] && storeRows[0]);
const adminId = String(adminRows[0].id);
const storeId = String(storeRows[0].id);
const access = await new RbacRepository(pool).getAccessProfile(context.tenantId, adminId);
for (const permission of ['report.read', 'report.export', 'report.manage']) {
assert.ok(access.capabilities.includes(permission));
}
const actor = { tenantId: context.tenantId, userId: adminId, access,
traceId: 'm10b-live-report', ip: '127.0.0.1', userAgent: 'M10-B live test' };
const dateParts = new Intl.DateTimeFormat('en-CA', {
timeZone: storeRows[0].timezone, year: 'numeric', month: '2-digit', day: '2-digit'
}).formatToParts(new Date());
const value = (type) => dateParts.find((part) => part.type === type)?.value;
const date = `${value('year')}-${value('month')}-${value('day')}`;
const service = new BusinessReportService(pool);
const live = await service.report(actor, { storeId, from: date, to: date });
const utcFrom = zonedDateStart(date, storeRows[0].timezone);
const nextDate = new Date(`${date}T00:00:00.000Z`);
nextDate.setUTCDate(nextDate.getUTCDate() + 1);
const utcTo = zonedDateStart(nextDate.toISOString().slice(0, 10), storeRows[0].timezone);
const [roomMoneyRows] = await pool.query(
`SELECT
(SELECT COALESCE(SUM(amount_cents), 0) FROM qipai_payments
WHERE tenant_id = ? AND store_id = ?
AND status IN ('SUCCEEDED', 'PARTIALLY_REFUNDED', 'REFUNDED')
AND paid_at >= ? AND paid_at < ? AND deleted_at IS NULL) AS grossCents,
(SELECT COALESCE(SUM(r.amount_cents), 0) FROM qipai_refunds r
INNER JOIN qipai_payments p ON p.tenant_id = r.tenant_id AND p.id = r.payment_id
WHERE r.tenant_id = ? AND p.store_id = ? AND r.status = 'SUCCEEDED'
AND r.completed_at >= ? AND r.completed_at < ?) AS refundCents`,
[context.tenantId, storeId, utcFrom, utcTo,
context.tenantId, storeId, utcFrom, utcTo]
);
assert.equal(live.daily.length, 1);
assert.equal(live.summary.roomGrossCents, Number(roomMoneyRows[0].grossCents));
assert.equal(live.summary.roomRefundCents, Number(roomMoneyRows[0].refundCents));
assert.equal(live.summary.totalNetCents,
live.summary.roomNetCents + live.summary.productNetCents);
assert.equal(Object.values(live.summary.channels).reduce((sum, amount) => sum + amount, 0),
live.summary.totalNetCents);
assert.ok(live.summary.usedMinutes <= live.summary.capacityMinutes);
assert.equal(live.reconciliation.status, 'NOT_AGGREGATED');
await service.handleTask({ id: 'm10b-task', tenantId: context.tenantId,
taskType: 'statistics.aggregate', idempotencyKey: `report:${storeId}:${date}`,
payload: { storeId, date }, status: 'RUNNING', attempts: 1, maxAttempts: 5 });
const reconciled = await service.report(actor, { storeId, from: date, to: date });
assert.equal(reconciled.reconciliation.status, 'MATCHED');
const queued = await service.enqueueRange(actor, { storeId, from: date, to: date });
assert.equal(queued.storeCount, 1); assert.equal(queued.dayCount, 1); assert.equal(queued.queued, 1);
await assert.rejects(() => service.report({ ...actor,
access: { roles: ['STORE_ADMIN'], capabilities: ['report.read'], storeIds: [] }
}, { storeId, from: date, to: date }), (error) => error?.code === 'BUSINESS_REPORT_FORBIDDEN');
console.log('PASS: M10-B timezone boundaries, net revenue, usage, daily aggregation and detail reconciliation are consistent.');
}
const config = loadConfig(); const config = loadConfig();
assert.equal(config.mysql.passwordConfigured, true, 'Live migration test requires a temporary password.'); assert.equal(config.mysql.passwordConfigured, true, 'Live migration test requires a temporary password.');
assert.match( assert.match(
@@ -4868,7 +4951,8 @@ try {
{ version: '2026081107', name: 'm09d1_product_inventory_foundation' }, { version: '2026081107', name: 'm09d1_product_inventory_foundation' },
{ version: '2026081108', name: 'm09d2_product_order_payment_inventory' }, { version: '2026081108', name: 'm09d2_product_order_payment_inventory' },
{ version: '2026081109', name: 'm09d3_product_storage' }, { version: '2026081109', name: 'm09d3_product_storage' },
{ version: '2026081110', name: 'm10a_notification_center' } { version: '2026081110', name: 'm10a_notification_center' },
{ version: '2026081111', name: 'm10b_business_reports' }
]); ]);
await assertTaskDurability(pool); await assertTaskDurability(pool);
const loginContext = await assertPlatformTenantIsolation(pool); const loginContext = await assertPlatformTenantIsolation(pool);
@@ -4895,13 +4979,14 @@ try {
await assertProductOrderPaymentInventory(pool, loginContext); await assertProductOrderPaymentInventory(pool, loginContext);
await assertProductStorageLifecycle(pool, loginContext); await assertProductStorageLifecycle(pool, loginContext);
await assertNotificationCenter(pool, loginContext); await assertNotificationCenter(pool, loginContext);
await assertBusinessReports(pool, loginContext);
await assertLegacyCompatibility(pool); await assertLegacyCompatibility(pool);
console.log('PASS: first up, verify, tenant isolation and revocable auth checks completed.'); console.log('PASS: first up, verify, tenant isolation and revocable auth checks completed.');
await executeMigrationPlan(pool, plans.down); await executeMigrationPlan(pool, plans.down);
assert.deepEqual(await readCoreTables(pool), []); assert.deepEqual(await readCoreTables(pool), []);
await assertLegacyCompatibility(pool); await assertLegacyCompatibility(pool);
console.log('PASS: down removed all M01-B through M10-A migration tables.'); console.log('PASS: down removed all M01-B through M10-B migration tables.');
await executeMigrationPlan(pool, plans.up); await executeMigrationPlan(pool, plans.up);
await executeMigrationPlan(pool, plans.verify); await executeMigrationPlan(pool, plans.verify);
@@ -4935,7 +5020,8 @@ try {
{ version: '2026081107', name: 'm09d1_product_inventory_foundation' }, { version: '2026081107', name: 'm09d1_product_inventory_foundation' },
{ version: '2026081108', name: 'm09d2_product_order_payment_inventory' }, { version: '2026081108', name: 'm09d2_product_order_payment_inventory' },
{ version: '2026081109', name: 'm09d3_product_storage' }, { version: '2026081109', name: 'm09d3_product_storage' },
{ version: '2026081110', name: 'm10a_notification_center' } { version: '2026081110', name: 'm10a_notification_center' },
{ version: '2026081111', name: 'm10b_business_reports' }
]); ]);
await assertLegacyCompatibility(pool); await assertLegacyCompatibility(pool);
console.log('PASS: second up and verify restored the schema.'); console.log('PASS: second up and verify restored the schema.');
@@ -0,0 +1,10 @@
DELETE rp FROM qipai_role_permissions rp
INNER JOIN qipai_permissions p ON p.id = rp.permission_id
WHERE p.code IN ('report.read', 'report.export', 'report.manage');
DELETE FROM qipai_permissions
WHERE code IN ('report.read', 'report.export', 'report.manage');
DROP TABLE IF EXISTS qipai_business_daily_summaries;
DELETE FROM qipai_schema_migrations WHERE version = '2026081111';
@@ -0,0 +1,54 @@
CREATE TABLE IF NOT EXISTS qipai_business_daily_summaries (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
store_id BIGINT UNSIGNED NOT NULL,
business_date DATE NOT NULL,
timezone VARCHAR(64) NOT NULL,
order_count BIGINT UNSIGNED NOT NULL DEFAULT 0,
customer_count BIGINT UNSIGNED NOT NULL DEFAULT 0,
used_minutes BIGINT UNSIGNED NOT NULL DEFAULT 0,
capacity_minutes BIGINT UNSIGNED NOT NULL DEFAULT 0,
room_gross_cents BIGINT UNSIGNED NOT NULL DEFAULT 0,
room_refund_cents BIGINT UNSIGNED NOT NULL DEFAULT 0,
product_gross_cents BIGINT UNSIGNED NOT NULL DEFAULT 0,
product_refund_cents BIGINT UNSIGNED NOT NULL DEFAULT 0,
cleaning_cost_cents BIGINT NOT NULL DEFAULT 0,
wechat_net_cents BIGINT NOT NULL DEFAULT 0,
balance_net_cents BIGINT NOT NULL DEFAULT 0,
package_net_cents BIGINT NOT NULL DEFAULT 0,
group_buy_net_cents BIGINT NOT NULL DEFAULT 0,
other_net_cents BIGINT NOT NULL DEFAULT 0,
source_checksum CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
calculated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
ON UPDATE CURRENT_TIMESTAMP(3),
CONSTRAINT fk_qipai_business_daily_summary_tenant
FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
CONSTRAINT fk_qipai_business_daily_summary_store
FOREIGN KEY (tenant_id, store_id) REFERENCES qipai_stores(tenant_id, id),
CONSTRAINT chk_qipai_business_daily_summary_usage CHECK (
used_minutes <= capacity_minutes
),
UNIQUE KEY uq_qipai_business_daily_summary_date
(tenant_id, store_id, business_date),
KEY idx_qipai_business_daily_summary_tenant_date
(tenant_id, business_date, store_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT IGNORE INTO qipai_permissions (code, name, category) VALUES
('report.read', '查看经营报表', 'report'),
('report.export', '导出经营报表', 'report'),
('report.manage', '重算经营日汇总', 'report');
INSERT IGNORE INTO qipai_role_permissions (tenant_id, role_id, permission_id)
SELECT r.tenant_id, r.id, p.id
FROM qipai_roles r
INNER JOIN qipai_permissions p
ON (r.code = 'STORE_ADMIN' AND p.code IN ('report.read', 'report.export'))
OR (r.code IN ('TENANT_ADMIN', 'PLATFORM_ADMIN')
AND p.code IN ('report.read', 'report.export', 'report.manage'))
WHERE r.status = 'ACTIVE' AND r.deleted_at IS NULL;
INSERT IGNORE INTO qipai_schema_migrations (version, name)
VALUES ('2026081111', 'm10b_business_reports');
@@ -0,0 +1,41 @@
SELECT table_name
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name = 'qipai_business_daily_summaries';
SELECT column_name
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'qipai_business_daily_summaries'
AND column_name IN (
'business_date', 'timezone', 'order_count', 'customer_count',
'used_minutes', 'capacity_minutes', 'room_gross_cents',
'room_refund_cents', 'product_gross_cents', 'product_refund_cents',
'cleaning_cost_cents', 'wechat_net_cents', 'balance_net_cents',
'package_net_cents', 'group_buy_net_cents', 'other_net_cents',
'source_checksum', 'calculated_at'
);
SELECT index_name
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'qipai_business_daily_summaries'
AND index_name IN (
'uq_qipai_business_daily_summary_date',
'idx_qipai_business_daily_summary_tenant_date'
);
SELECT code
FROM qipai_permissions
WHERE code IN ('report.read', 'report.export', 'report.manage');
SELECT COUNT(*) AS configured_role_permissions
FROM qipai_role_permissions rp
INNER JOIN qipai_roles r ON r.id = rp.role_id AND r.tenant_id = rp.tenant_id
INNER JOIN qipai_permissions p ON p.id = rp.permission_id
WHERE r.code IN ('STORE_ADMIN', 'TENANT_ADMIN', 'PLATFORM_ADMIN')
AND p.code IN ('report.read', 'report.export', 'report.manage');
SELECT version, name
FROM qipai_schema_migrations
WHERE version = '2026081111' AND name = 'm10b_business_reports';
+53
View File
@@ -0,0 +1,53 @@
# M10-B 经营报表指标口径
本文件固定 `M10-B-v1` 的统计口径。页面、CSV 导出、日汇总任务和明细复算均使用 `BusinessReportService`,不得分别实现不同算法。
## 时间与范围
- 请求使用闭区间本地日期 `from``to`,最长 93 天;“今日”和“近 7 日”只是该范围的快捷入口。
- 每家门店按 `qipai_stores.timezone` 的 IANA 时区独立换算本地零点,数据库时间仍为 UTC `DATETIME(3)`
- 全门店报表只包含调用人获授权的门店;租户管理员和平台管理员可读取当前租户全部门店。
- 夏令时自然日可能是 23 或 25 小时,因此利用率分母按相邻两个门店本地零点的真实分钟差计算,不固定写死 1440 分钟。
## 金额指标
所有金额单位均为整数分。
| 指标 | 明细来源与公式 |
|---|---|
| 房费实收 | `qipai_payments` 中成功、部分退款或全额退款状态的原支付,按 `paid_at` 归属日期;退款后的状态变化不能抹掉原始实收 |
| 房费退款 | `qipai_refunds` 中成功退款,按 `completed_at` 归属日期 |
| 房费净收入 | 房费实收减房费退款 |
| 商品实收 | `qipai_product_payments` 中成功或已全额退款的原支付,按 `paid_at` 归属日期 |
| 商品退款 | `qipai_product_refunds` 中成功退款,按 `completed_at` 归属日期 |
| 商品净收入 | 商品实收减商品退款 |
| 总净收入 | 房费净收入加商品净收入 |
| 保洁成本 | 已付款结算明细按结算 `paid_at` 计正数;冲正明细按 `reversed_at` 计负数 |
| 贡献额 | 总净收入减保洁成本;不是会计利润,不包含租金、水电、渠道手续费和税费 |
支付渠道读取支付单的 `channel`,统一映射为微信、余额、套餐、团购平台和其他;退款按原支付渠道反向扣减。各渠道净额之和必须等于总净收入。
## 订单、人数和房间利用率
- 订单数按房间订单 `created_at` 归属日期统计,不包含商品订单。
- 下单人数按请求范围内房间订单 OWNER 用户去重;逐日行按当日去重,范围汇总不会把同一用户跨日重复计算。
- 实际使用开始时间优先取订单状态历史首次进入 `IN_PROGRESS` 的时间,结束时间优先取首次进入 `FINISHED` 的时间。
- 历史迁移订单缺状态时间时,只有 `IN_PROGRESS/FINISHED/CLOSED` 才回退到预订起止时间;其他状态不计实际使用。
- 跨日使用按每个门店自然日边界切分;进行中订单最多计算到查询时刻。
- 可用分钟 = 当日未删除房间数 × 当日真实分钟数;利用率基点 = `实际使用分钟 × 10000 ÷ 可用分钟`,页面用百分比展示。
- 为避免异常重叠历史造成超过 100%,逐店逐日实际分钟最多取可用分钟;原始异常仍应通过订单/房间对账修复。
## 日汇总与复算
- `qipai_business_daily_summaries``(tenant_id, store_id, business_date)` 唯一保存逐店逐日结果和 `source_checksum`
- Worker 每 5 分钟检查一次任务,每小时幂等安排最近 7 个门店自然日;管理员可对最多 93 天范围人工重算。
- 查询和 CSV 始终从业务明细实时复算,不把可能陈旧的日汇总当作资金事实源。
- 实时结果与日汇总 checksum 全部相同时返回 `MATCHED`;没有汇总返回 `NOT_AGGREGATED`;缺行或内容不同返回 `MISMATCH`
- 退款、冲正或订单完成时间变化后,汇总允许被同一明细结果覆盖更新;不累计旧汇总,避免重复计账。
## 权限与导出
- `report.read`:读取授权门店报表。
- `report.export`:导出与页面同源的 UTF-8 CSV。
- `report.manage`:加入日汇总重算任务,仅租户管理员和平台管理员默认拥有。
- 所有接口仍执行租户、门店范围和服务端日期边界校验;前端筛选不构成权限边界。
+10 -10
View File
@@ -1,10 +1,10 @@
# 当前开发成果基线 # 当前开发成果基线
> V5.0 首次核验日期:2026-06-16 > V5.0 首次核验日期:2026-06-16
> audited_commit: `41b9cf7` > audited_commit: `5d70114`
> audited_worktree: M10-A 已完成工程(随本次中文工程提交固化) > audited_worktree: M10-B 已完成工程(随本次中文工程提交固化)
> audited_at: `2026-08-11T09:08:00+08:00` > audited_at: `2026-08-11T09:41:58+08:00`
> next_engineering_target: 按 V5.7 固定队列执行 M10-B,完成可由明细复算的统计报表 > next_engineering_target: 按 V5.7 固定队列执行 M10-C,完成多应用、加盟开通与扩展边界
> 事实源:当前工作区、Git 历史、状态文档、Windows/WSL 检查脚本。 > 事实源:当前工作区、Git 历史、状态文档、Windows/WSL 检查脚本。
## 总体结论 ## 总体结论
@@ -13,10 +13,10 @@
|---|---|---|---| |---|---|---|---|
| 总纲版本 | V5.7 已成为当前权威总纲,V5.6 及更早版本仅作历史基线 | 根目录 `V5.7.md` 的增量控制层与第 30 章固定队列 | 必须按 V5.7 游标开发,不得回退到历史队列 | | 总纲版本 | V5.7 已成为当前权威总纲,V5.6 及更早版本仅作历史基线 | 根目录 `V5.7.md` 的增量控制层与第 30 章固定队列 | 必须按 V5.7 游标开发,不得回退到历史队列 |
| Git 远端 | `origin=ssh://git@git.txyundm.cn:2222/panda/qipai.git`,分支 `main` | `git rev-list main...origin/main``0 0` | 本地与远端同步 | | Git 远端 | `origin=ssh://git@git.txyundm.cn:2222/panda/qipai.git`,分支 `main` | `git rev-list main...origin/main``0 0` | 本地与远端同步 |
| 正式后端 | 已完成 Fastify、迁移兼容、outbox/worker、多应用租户、身份/RBAC/门店范围及 M03-M09 业务 APIM10-A 新增统一通知模板、路由、接收人解析、顾客订阅授权/站内信、HTTPS 渠道、幂等投递、有限重试、失败人工补发、门店时区静默与递归脱敏,并将房间订单、支付退款、保洁、设备、商品订单和寄存事件接入 outbox | `backend/src/notifications/notification-service.ts``backend/src/routes/notifications.ts``backend/src/tasks/worker.ts``database/migrations/2026081110_m10a_notification_center.*.sql` | Windows 全量 76.7 秒通过;MySQL 8.4.10 完成 229 up、165 verify、209 down 往返和清理探针,进入 M10-B | | 正式后端 | 已完成 Fastify、迁移兼容、outbox/worker、多应用租户、身份/RBAC/门店范围及 M03-M09 业务 APIM10-A 完成统一通知中心,M10-B 新增经营报表服务,按门店 IANA 时区从房费/商品支付退款、订单状态历史和保洁结算冲正明细复算,提供日汇总 checksum、Worker 重算、全店/单店查询与服务端 CSV | `backend/src/operations/business-report-service.ts``backend/src/routes/business-statistics.ts``backend/src/tasks/worker.ts``docs/business-report-metrics.md` | Windows 全量 77.6 秒通过;MySQL 8.4.10 完成 233 up、171 verify、213 down 往返和清理探针,进入 M10-C |
| 后台管理端 | 已建立 Vue3 + Vite + TypeScript + Element Plus 权限路由工程;14 个业务入口按路由懒加载;通知中心支持按门店查看模板、事件路由和投递状态,筛选渠道/失败原因并对终态失败记录人工补发 | `admin/src/router.ts``admin/src/components/NotificationCenterPanel.vue``admin/src/api.ts``backend/src/routes/admin-auth.ts` | 生产构建和导航测试通过;通知中心分块约 6.4 KiB gzip,桌面/平板/手机响应式样式纳入静态门禁 | | 后台管理端 | 已建立 Vue3 + Vite + TypeScript + Element Plus 权限路由工程;15 个业务入口按路由懒加载;通知中心可追踪投递,经营报表支持全部授权门店/单店、今日/近 7 日/自定义范围、渠道与成本拆分、逐日明细、汇总复核、CSV 和授权重算 | `admin/src/router.ts``admin/src/components/BusinessReportsPanel.vue``admin/src/api.ts``backend/src/routes/admin-auth.ts` | 生产构建和导航测试通过;经营报表独立分块约 3.5 KiB gzip最大 JS 分块 223,111 B桌面/平板/手机响应式样式纳入门禁 |
| 微信小程序 | 已纳管微信原生模板,并完成顾客端房间订单、充值、权益、保洁等主体链路;M09-D4 新增门店商品选购、购物车、商品订单取消、整单寄存、部分取出、换码与取消寄存页面 | `miniapp/pages/goods/*``miniapp/pages/store/detail``miniapp/pages/profile/*``miniapp/utils/api.js``scripts/check-m09-d4-product-experience.mjs` | 商品领取码只保留页面内存并可复制后隐藏,不调用本地持久化;生产商品支付入口仍沿用服务端后续真实微信支付接线边界 | | 微信小程序 | 已纳管微信原生模板,并完成顾客端房间订单、充值、权益、保洁等主体链路;M09-D4 新增门店商品选购、购物车、商品订单取消、整单寄存、部分取出、换码与取消寄存页面 | `miniapp/pages/goods/*``miniapp/pages/store/detail``miniapp/pages/profile/*``miniapp/utils/api.js``scripts/check-m09-d4-product-experience.mjs` | 商品领取码只保留页面内存并可复制后隐藏,不调用本地持久化;生产商品支付入口仍沿用服务端后续真实微信支付接线边界 |
| 数据库迁移 | 已形成 M01-B 至 M10-A 连续 up/down/verify 计划;M10-A 新增模板、路由、订阅、投递、不可变尝试 5 张表,使用生成作用域键保证 `NULL` 门店下的全局配置唯一,并在 M09-D1 崩溃重试前按依赖撤销 | `database/migrations/2026081110_m10a_notification_center.{up,down,verify}.sql``backend/src/db/migration-runner.ts``backend/tests/mysql-migration-roundtrip.test.mjs` | WSL MySQL 8.4.10 已完成 229 up、165 verify、209 down 的 up/verify/down/up/verify订阅拦截、脱敏、幂等扇出、失败重试、人工审计和不可变尝试通过 | | 数据库迁移 | 已形成 M01-B 至 M10-B 连续 up/down/verify 计划;M10-B 新增逐店逐日日汇总表和 `report.read/export/manage` 权限,保存可由明细重建的指标与 source checksum,并在 M09-D1 崩溃重试前按依赖撤销 | `database/migrations/2026081111_m10b_business_reports.{up,down,verify}.sql``backend/src/db/migration-runner.ts``backend/tests/mysql-migration-roundtrip.test.mjs` | WSL MySQL 8.4.10 已完成 233 up、171 verify、213 down 的 up/verify/down/up/verify时区边界、退款净额、实际时长、汇总重算和 checksum 对账通过 |
| M00 部署脚本 | 已有菜单、状态、HTTPS、Certbot、EMQX、备份检查模板 | `setup.sh``scripts/setup/``deploy/` | PARTIAL,生产未执行 | | M00 部署脚本 | 已有菜单、状态、HTTPS、Certbot、EMQX、备份检查模板 | `setup.sh``scripts/setup/``deploy/` | PARTIAL,生产未执行 |
| 发布清单 dry-run | 可基于当前 HEAD 输出 `deployed=false` 的 release manifest,记录后端/后台/迁移尚未生成而跳过 | `scripts/setup/deploy-business.sh --dry-run .``scripts/dev/windows/check-release-manifest.ps1` | 可在 M00 验证结构;真实构建与生产发布待 M01/M09 后接入 | | 发布清单 dry-run | 可基于当前 HEAD 输出 `deployed=false` 的 release manifest,记录后端/后台/迁移尚未生成而跳过 | `scripts/setup/deploy-business.sh --dry-run .``scripts/dev/windows/check-release-manifest.ps1` | 可在 M00 验证结构;真实构建与生产发布待 M01/M09 后接入 |
| 参考资料 | 已有清单、脱敏日志、页面/接口/表结构摘要 | `docs/reference-*``docs/db-schema-inventory.md` | PARTIAL,仍需按模块迁移正式实现 | | 参考资料 | 已有清单、脱敏日志、页面/接口/表结构摘要 | `docs/reference-*``docs/db-schema-inventory.md` | PARTIAL,仍需按模块迁移正式实现 |
@@ -26,7 +26,7 @@
## 状态校准 ## 状态校准
- `docs/module-status.md` 中 M00 仍为 `PARTIAL` 是正确的:生产 Ubuntu 初始化、部署与运维验收尚未执行;后端、后台和小程序源码已在后续模块建立。 - `docs/module-status.md` 中 M00 仍为 `PARTIAL` 是正确的:生产 Ubuntu 初始化、部署与运维验收尚未执行;后端、后台和小程序源码已在后续模块建立。
- M01-M04、M07、M09 已具备模块工程验收;M05、M06、M08 因真实厂商/硬件/生产条件保持 `PARTIAL`;M10-A 内部工程已完成,真实渠道凭据保持外部阻塞,当前进入 M10-B - M01-M04、M07、M09 已具备模块工程验收;M05、M06、M08 因真实厂商/硬件/生产条件保持 `PARTIAL`M10-A/M10-B 内部工程已完成,真实通知渠道凭据保持外部阻塞,当前进入 M10-C
- 任何“目录存在”只能证明骨架存在,不能证明模块完成。 - 任何“目录存在”只能证明骨架存在,不能证明模块完成。
## Git 历史敏感信息核验 ## Git 历史敏感信息核验
@@ -37,6 +37,6 @@
## 下一步 ## 下一步
1. 完成 M10-A 中文提交、push、远端标题与 SHA 校验。 1. 完成 M10-B 中文提交、push、远端标题与 SHA 校验。
2. 执行 M10-B,统一报表口径、门店时区边界、明细复算、筛选和导出 2. 执行 M10-C,多 AppID/租户隔离回归、加盟开通工作流、抽奖合规边界和 KTV 白名单适配器骨架
3. 真实通知网关、M06-G 硬件、微信与团购联调继续等待外部条件,不阻断内部主队列。 3. 真实通知网关、M06-G 硬件、微信与团购联调继续等待外部条件,不阻断内部主队列。
+6 -4
View File
@@ -60,6 +60,8 @@
> 2026-08-11 M10-A:当前伴随工程完成统一通知中心。5 张新表覆盖模板、事件路由、微信订阅授权、投递快照和不可变尝试;房间订单/支付退款、保洁任务、设备告警、商品订单和寄存事件统一写 outbox。worker 执行幂等扇出、门店时区静默、递归脱敏、有限指数退避和终态失败人工补发,人工尝试保留操作人;顾客可管理订阅授权、读取/已读站内信。站内信、门店 HTTPS Webhook 与按环境启用的微信订阅/企微/云喇叭网关适配器已接线,未配置渠道明确失败。Vue 后台第 14 个懒加载入口可维护模板/路由并追踪投递。Windows 全量 76.7 秒与 MySQL 8.4.10 的 229 up、165 verify、209 down 往返及清理探针通过,游标进入 M10-B。 > 2026-08-11 M10-A:当前伴随工程完成统一通知中心。5 张新表覆盖模板、事件路由、微信订阅授权、投递快照和不可变尝试;房间订单/支付退款、保洁任务、设备告警、商品订单和寄存事件统一写 outbox。worker 执行幂等扇出、门店时区静默、递归脱敏、有限指数退避和终态失败人工补发,人工尝试保留操作人;顾客可管理订阅授权、读取/已读站内信。站内信、门店 HTTPS Webhook 与按环境启用的微信订阅/企微/云喇叭网关适配器已接线,未配置渠道明确失败。Vue 后台第 14 个懒加载入口可维护模板/路由并追踪投递。Windows 全量 76.7 秒与 MySQL 8.4.10 的 229 up、165 verify、209 down 往返及清理探针通过,游标进入 M10-B。
> 2026-08-11 M10-B:当前伴随工程完成统一经营报表。服务端按每家门店 IANA 时区切分自然日,从房费与商品支付/退款、订单状态历史、保洁结算和冲正明细实时复算;覆盖全授权门店/单店、今日/近 7 日/自定义范围、订单/下单人数、实际使用分钟/利用率、渠道净额和贡献额。逐店逐日日汇总保存 source checksumWorker 每小时幂等回算最近 7 日,管理员可重算最多 93 天;页面、UTF-8 CSV 和 checksum 复核共用同一服务。指标口径已固定在 `docs/business-report-metrics.md`。Windows 全量 77.6 秒与 MySQL 8.4.10 的 233 up、171 verify、213 down 往返及清理探针通过,游标进入 M10-C。
| ID | 功能 | 模块子阶段 | 状态 | 最近提交 | 测试/验收证据 | 阻塞原因 | 下一步 | | ID | 功能 | 模块子阶段 | 状态 | 最近提交 | 测试/验收证据 | 阻塞原因 | 下一步 |
|---|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|---|
| ENV-001 | 固定 Windows 工作区 | M00-A/M00-B | DONE | 远端 HEAD | 已确认 `D:\qipai`Git 根、main 分支、固定 origin 和 push 校验通过。 | - | 后续每模块继续复检。 | | ENV-001 | 固定 Windows 工作区 | M00-A/M00-B | DONE | 远端 HEAD | 已确认 `D:\qipai`Git 根、main 分支、固定 origin 和 push 校验通过。 | - | 后续每模块继续复检。 |
@@ -68,7 +70,7 @@
| WSL-001 | WSL 隔离辅助验证 | M00-C | PARTIAL | `a690e85` | WSL 基础脚本已执行通过;本地 MQTT 服务级核验通过;认证/ACL 冒烟脚本入口、配置自检、可选 TLS/遗嘱/重复消息探测入口已生成;V5.0 点名的 WSL EMQX 检查/启动/停止入口已补齐。 | 正式后端/后台尚未生成,无法执行完整 Linux 构建;MQTT 本地账号未配置,认证/ACL/TLS/遗嘱/幂等未验收;生产 EMQX 不由 WSL 脚本管理。 | M01/M09 生成项目后在 WSL 原生临时副本执行完整构建;配置本地 MQTT 最小权限账号后执行冒烟和可选探测。 | | WSL-001 | WSL 隔离辅助验证 | M00-C | PARTIAL | `a690e85` | WSL 基础脚本已执行通过;本地 MQTT 服务级核验通过;认证/ACL 冒烟脚本入口、配置自检、可选 TLS/遗嘱/重复消息探测入口已生成;V5.0 点名的 WSL EMQX 检查/启动/停止入口已补齐。 | 正式后端/后台尚未生成,无法执行完整 Linux 构建;MQTT 本地账号未配置,认证/ACL/TLS/遗嘱/幂等未验收;生产 EMQX 不由 WSL 脚本管理。 | M01/M09 生成项目后在 WSL 原生临时副本执行完整构建;配置本地 MQTT 最小权限账号后执行冒烟和可选探测。 |
| API-001 | 固定 HTTPS API 域名 | M00-E/M01/M08/M10 | PARTIAL | `1192dcb` | 已新增 Windows 检查脚本、Ubuntu 菜单检查和固定域名 Nginx 模板;M01-A 健康接口、编译和本地 HTTP 检查通过;M01-B 的连接池、迁移 CLI、旧表兼容读取、整数分转换已通过契约测试,并在 WSL MySQL 8.4.9 的空库和脱敏旧库副本完成 `up/verify/down/up/verify`,异常退出清理后临时库与账号均为 0。 | DNS/HTTPS 生产验证、Ubuntu 24.04 生产运行时、业务接口和鉴权未验收。 | 进入 M02 身份与权限;后续在 M10 接入生产域名、证书和 Nginx 真实验收。 | | API-001 | 固定 HTTPS API 域名 | M00-E/M01/M08/M10 | PARTIAL | `1192dcb` | 已新增 Windows 检查脚本、Ubuntu 菜单检查和固定域名 Nginx 模板;M01-A 健康接口、编译和本地 HTTP 检查通过;M01-B 的连接池、迁移 CLI、旧表兼容读取、整数分转换已通过契约测试,并在 WSL MySQL 8.4.9 的空库和脱敏旧库副本完成 `up/verify/down/up/verify`,异常退出清理后临时库与账号均为 0。 | DNS/HTTPS 生产验证、Ubuntu 24.04 生产运行时、业务接口和鉴权未验收。 | 进入 M02 身份与权限;后续在 M10 接入生产域名、证书和 Nginx 真实验收。 |
| ASYNC-001 | MySQL outbox 与轻量任务 worker | M01-C | DONE | `2b90d1f` | outbox/任务迁移、幂等键、`FOR UPDATE SKIP LOCKED` 领取、租约恢复、指数退避、人工补偿状态和 PM2 worker 已实现;Windows 契约测试与 WSL MySQL 8.4.9 往返迁移、重复入队、重启后领取验证通过。 | 具体通知、订单、设备、退款和统计 handler 在对应业务模块接入。 | M02 起按业务事务写入 outbox/任务并注册真实 handler。 | | ASYNC-001 | MySQL outbox 与轻量任务 worker | M01-C | DONE | `2b90d1f` | outbox/任务迁移、幂等键、`FOR UPDATE SKIP LOCKED` 领取、租约恢复、指数退避、人工补偿状态和 PM2 worker 已实现;Windows 契约测试与 WSL MySQL 8.4.9 往返迁移、重复入队、重启后领取验证通过。 | 具体通知、订单、设备、退款和统计 handler 在对应业务模块接入。 | M02 起按业务事务写入 outbox/任务并注册真实 handler。 |
| NTF-001 | 全场景通知提醒 | M10-A | PARTIAL | M10-A 当前工程 | 模板/路由、角色/用户/顾客/Webhook 接收人、订阅授权、站内信、HTTPS 渠道、投递快照、幂等、有限重试、失败人工补发、门店时区静默、递归脱敏和不可变尝试已完成;订单、支付退款、保洁、设备、商品与寄存事件接入统一 outbox,后台可维护和追踪。 | 真实微信订阅、企微和云喇叭网关凭据及生产回执为 `BLOCKED_EXTERNAL`。 | 取得渠道条件后执行小流量真实投递与回执对账;内部队列继续 M10-B。 | | NTF-001 | 全场景通知提醒 | M10-A | PARTIAL | `5d70114` | 模板/路由、角色/用户/顾客/Webhook 接收人、订阅授权、站内信、HTTPS 渠道、投递快照、幂等、有限重试、失败人工补发、门店时区静默、递归脱敏和不可变尝试已完成;订单、支付退款、保洁、设备、商品与寄存事件接入统一 outbox,后台可维护和追踪。 | 真实微信订阅、企微和云喇叭网关凭据及生产回执为 `BLOCKED_EXTERNAL`。 | 取得渠道条件后执行小流量真实投递与回执对账。 |
| TLS-001 | Nginx 与证书自动化 | M00-E/M10 | PARTIAL | `4cb3ab6` | 已生成 Nginx 模板、Certbot 命令说明和菜单第 4 项检查,可检查模板、站点启用、TLS、健康端点、证书文件、续期配置和 `certbot.timer`。 | 证书申请/续期 dry-run、80/443 生产验证未执行。 | 在生产 Ubuntu 执行证书签发、续期 dry-run 和 Nginx 安装记录。 | | TLS-001 | Nginx 与证书自动化 | M00-E/M10 | PARTIAL | `4cb3ab6` | 已生成 Nginx 模板、Certbot 命令说明和菜单第 4 项检查,可检查模板、站点启用、TLS、健康端点、证书文件、续期配置和 `certbot.timer`。 | 证书申请/续期 dry-run、80/443 生产验证未执行。 | 在生产 Ubuntu 执行证书签发、续期 dry-run 和 Nginx 安装记录。 |
| WXNET-001 | 微信合法域名与真机验证 | M00-E/M08/M10 | PARTIAL | `4ac2c96` | 小程序配置继续固定 `https://api.txyundm.cn/app-api``scripts/check-miniapp-m08-a.mjs` 已检查不存在 localhost、127.0.0.1 和 HTTP API。 | 微信后台合法域名和真机体验版未验证。 | M08-A 后续补真机登录、上传/下载、下单和设备接口冒烟;M10 做生产验收。 | | WXNET-001 | 微信合法域名与真机验证 | M00-E/M08/M10 | PARTIAL | `4ac2c96` | 小程序配置继续固定 `https://api.txyundm.cn/app-api``scripts/check-miniapp-m08-a.mjs` 已检查不存在 localhost、127.0.0.1 和 HTTP API。 | 微信后台合法域名和真机体验版未验证。 | M08-A 后续补真机登录、上传/下载、下单和设备接口冒烟;M10 做生产验收。 |
| OPS-001 | 固定 `/opt/apps` 目录 | M00-D/M10 | PARTIAL | `292ab7f` | `scripts/setup/init-layout.sh` 已生成目录布局、uploads 目录和 manifestWSL 检查脚本通过,未在生产 Ubuntu 执行。 | 生产操作未执行。 | 由管理员在 Ubuntu 菜单执行并记录结果。 | | OPS-001 | 固定 `/opt/apps` 目录 | M00-D/M10 | PARTIAL | `292ab7f` | `scripts/setup/init-layout.sh` 已生成目录布局、uploads 目录和 manifestWSL 检查脚本通过,未在生产 Ubuntu 执行。 | 生产操作未执行。 | 由管理员在 Ubuntu 菜单执行并记录结果。 |
@@ -88,8 +90,8 @@
| CLN-005 | 保洁驳回/免清洁 | M08-B/M09 | PARTIAL | `eb838f5` | 已支持管理侧驳回、批量驳回和保洁员驳回补做;已新增 `POST /admin-api/cleaning/tasks/:taskId/exempt`,后台任务列表提供免清洁按钮、批量免清洁和 `EXEMPT` 筛选,仓储将可处理任务置为 `EXEMPT`、奖励归零并移除未结算协作者;待结算候选继续只取 `COMPLETED` 且未结算任务,免清洁任务不进入结算;管理统计 summary、门店明细、每日趋势和统计导出均新增免清洁数量,便于核对不结算口径。 | 真实商户现场执行仍待继续。 | 继续 M08-B/M09,补现场验收记录和更完整运营管理界面。 | | CLN-005 | 保洁驳回/免清洁 | M08-B/M09 | PARTIAL | `eb838f5` | 已支持管理侧驳回、批量驳回和保洁员驳回补做;已新增 `POST /admin-api/cleaning/tasks/:taskId/exempt`,后台任务列表提供免清洁按钮、批量免清洁和 `EXEMPT` 筛选,仓储将可处理任务置为 `EXEMPT`、奖励归零并移除未结算协作者;待结算候选继续只取 `COMPLETED` 且未结算任务,免清洁任务不进入结算;管理统计 summary、门店明细、每日趋势和统计导出均新增免清洁数量,便于核对不结算口径。 | 真实商户现场执行仍待继续。 | 继续 M08-B/M09,补现场验收记录和更完整运营管理界面。 |
| PRD-001 | 商品目录与门店售卖 | M09-D1/M09-D4 | DONE | `93af128``e91b979` | 分类、商品、SKU、门店上架、履约方式、销售开关/人工暂停及最多 8 段每周营业时段已实现;M09-D4 新增登录态顾客门店目录,小程序与运营后台复用同一商品主数据。 | - | M10 按通知和统计需求复用商品主数据。 | | PRD-001 | 商品目录与门店售卖 | M09-D1/M09-D4 | DONE | `93af128``e91b979` | 分类、商品、SKU、门店上架、履约方式、销售开关/人工暂停及最多 8 段每周营业时段已实现;M09-D4 新增登录态顾客门店目录,小程序与运营后台复用同一商品主数据。 | - | M10 按通知和统计需求复用商品主数据。 |
| INV-001 | 库存策略与不可变流水 | M09-D1/M09-D2 | DONE | `93af128``1a3ea7b` | TRACKED/UNLIMITED 策略、入库、调整、盘点、损耗、批量锁定/释放/扣减已实现;M09-D2 已把订单创建、支付、取消、超时和退款接入稳定业务号与同事务库存变更。请求指纹、版本 CAS、数量边界和不可变流水保证并发回调只扣一次、重复释放/返库幂等且不出现负库存。 | - | M09-D3 寄存数量已使用独立账本,不回写已完成订单项快照。 | | INV-001 | 库存策略与不可变流水 | M09-D1/M09-D2 | DONE | `93af128``1a3ea7b` | TRACKED/UNLIMITED 策略、入库、调整、盘点、损耗、批量锁定/释放/扣减已实现;M09-D2 已把订单创建、支付、取消、超时和退款接入稳定业务号与同事务库存变更。请求指纹、版本 CAS、数量边界和不可变流水保证并发回调只扣一次、重复释放/返库幂等且不出现负库存。 | - | M09-D3 寄存数量已使用独立账本,不回写已完成订单项快照。 |
| GOODS-102 | 商品订单与支付库存闭环 | M09-D2/M09-D4 | DONE | `1a3ea7b``e91b979``41b9cf7` + M10-A 当前工程 | 商品订单与支付单分离,订单项与状态事件不可变;小程序与 Vue 运营端已接入完整状态处置,逐单对账覆盖金额、成功支付/退款、库存占用/销售余额并显示原始流水;状态、支付和退款事件进入统一通知 outbox。 | 真实微信商品支付入口仍缺生产商户条件。 | M10-B 复用明细完成商品收入与退款报表;真实支付纳入 BLOCKED_EXTERNAL。 | | GOODS-102 | 商品订单与支付库存闭环 | M09-D2/M09-D4 | DONE | `1a3ea7b``e91b979``41b9cf7``5d70114` + M10-B 当前工程 | 商品订单与支付单分离,订单项与状态事件不可变;小程序与 Vue 运营端已接入完整状态处置,逐单对账覆盖金额、成功支付/退款、库存占用/销售余额并显示原始流水;状态、支付和退款事件进入通知 outbox,M10-B 已从支付/退款明细复算商品净收入与渠道。 | 真实微信商品支付入口仍缺生产商户条件。 | 真实支付纳入 BLOCKED_EXTERNALM10-C 不改变既有商品账本。 |
| GOODS-104 | 商品寄存与安全取出 | M09-D3/M09-D4 | DONE | `60264cb``e91b979``41b9cf7` + M10-A 当前工程 | 寄存状态、商品快照、一次性哈希领取码、部分取出自动轮换、并发防超领、审计与 outbox 已完成;逐单对账验证订单来源寄存总量、剩余量与明细聚合一致,M10-A worker 已消费寄存事件并按路由投递。 | - | M10-B 保持寄存数量与商品订单收入分离。 | | GOODS-104 | 商品寄存与安全取出 | M09-D3/M09-D4 | DONE | `60264cb``e91b979``41b9cf7``5d70114` | 寄存状态、商品快照、一次性哈希领取码、部分取出自动轮换、并发防超领、审计与 outbox 已完成;逐单对账验证订单来源寄存总量、剩余量与明细聚合一致,M10-A worker 已消费寄存事件并按路由投递M10-B 保持寄存数量不计入商品收入。 | - | M10-C 不改变既有寄存账本。 |
| STAFF-001 | 员工账号 | M02-D/M08 | PARTIAL | `93f06d2` | 已实现创建员工、分配角色/门店、禁用、备注、重置会话和审计;管理员小程序已展示员工概况,Vue 后台可按角色/状态搜索并完成完整生命周期管理,权限变化即时撤销活动会话。 | 真实员工微信绑定和生产账号生命周期验收待补。 | M10 以真实员工账号验证绑定、门店切换、禁用和重新登录。 | | STAFF-001 | 员工账号 | M02-D/M08 | PARTIAL | `93f06d2` | 已实现创建员工、分配角色/门店、禁用、备注、重置会话和审计;管理员小程序已展示员工概况,Vue 后台可按角色/状态搜索并完成完整生命周期管理,权限变化即时撤销活动会话。 | 真实员工微信绑定和生产账号生命周期验收待补。 | M10 以真实员工账号验证绑定、门店切换、禁用和重新登录。 |
| BKG-002 | 后台用户管理 | M02-D/M08 | PARTIAL | `0511008` | 已实现分页筛选、注册/登录时间、状态、角色、门店范围、手机号/IP 脱敏及会员/STAFF 分栏;租户管理员可为同租户有效 STAFF 设置唯一登录名和强密码,更新凭据会撤销其活动会话且不返回密码哈希。 | 尚缺平台级跨租户用户视图和真实生产数据验收。 | M10 以生产平台/租户账号复验跨租户用户视图、凭据重置和审计。 | | BKG-002 | 后台用户管理 | M02-D/M08 | PARTIAL | `0511008` | 已实现分页筛选、注册/登录时间、状态、角色、门店范围、手机号/IP 脱敏及会员/STAFF 分栏;租户管理员可为同租户有效 STAFF 设置唯一登录名和强密码,更新凭据会撤销其活动会话且不返回密码哈希。 | 尚缺平台级跨租户用户视图和真实生产数据验收。 | M10 以生产平台/租户账号复验跨租户用户视图、凭据重置和审计。 |
| TEN-001 | 多门店支持 | M02/M03 | PARTIAL | `15139b3` | 门店 CRUD 与门店管理员数据范围已实现;跨门店写操作在 Windows 和 WSL MySQL 实测被拒绝。 | 地图选店、前端切换与完整订单数据范围待后续阶段。 | M03-C 接入地图选店,M04 起业务资源继承门店范围。 | | TEN-001 | 多门店支持 | M02/M03 | PARTIAL | `15139b3` | 门店 CRUD 与门店管理员数据范围已实现;跨门店写操作在 Windows 和 WSL MySQL 实测被拒绝。 | 地图选店、前端切换与完整订单数据范围待后续阶段。 | M03-C 接入地图选店,M04 起业务资源继承门店范围。 |
@@ -111,7 +113,7 @@
| GRP-002 | 美团直订 | M05-C | PARTIAL | `7b97808` | 已实现 HMAC 回调、事件幂等、外部门店/房间映射、未映射人工队列、顾客认领和团购支付订单生成;映射成功订单可进入 PAID。Vue 后台可按平台/门店/状态查看直订队列、维护同租户资源映射,并以安全读取接口查看过滤后的配置。 | 缺美团开放平台授权和真实回调协议确认。 | 取得授权后按厂商协议实现专用签名适配并联调。 | | GRP-002 | 美团直订 | M05-C | PARTIAL | `7b97808` | 已实现 HMAC 回调、事件幂等、外部门店/房间映射、未映射人工队列、顾客认领和团购支付订单生成;映射成功订单可进入 PAID。Vue 后台可按平台/门店/状态查看直订队列、维护同租户资源映射,并以安全读取接口查看过滤后的配置。 | 缺美团开放平台授权和真实回调协议确认。 | 取得授权后按厂商协议实现专用签名适配并联调。 |
| GRP-003 | 管理员验券 | M05-C/M08 | PARTIAL | `7b97808` | 已提供管理员小程序与 Vue 后台扫码/手输券码、在线核销、显式人工确认、待支付订单选择和脱敏核销记录;金额由服务端订单未付金额推导,写权限叠加授权门店,记录保留操作人/门店/平台结果,券码仅存 SHA-256 和掩码;后台凭据只接受环境引用,敏感设置键不从读取接口返回。 | 缺美团/抖音/快手真实商家 API 授权,未执行厂商真实核销。 | 取得授权后配置 API 模式并执行真实验券。 | | GRP-003 | 管理员验券 | M05-C/M08 | PARTIAL | `7b97808` | 已提供管理员小程序与 Vue 后台扫码/手输券码、在线核销、显式人工确认、待支付订单选择和脱敏核销记录;金额由服务端订单未付金额推导,写权限叠加授权门店,记录保留操作人/门店/平台结果,券码仅存 SHA-256 和掩码;后台凭据只接受环境引用,敏感设置键不从读取接口返回。 | 缺美团/抖音/快手真实商家 API 授权,未执行厂商真实核销。 | 取得授权后配置 API 模式并执行真实验券。 |
| ADM-001 | 管理员小程序模式 | M08/M06 | PARTIAL | `ce52f07` | 已完成角色入口、授权门店概况/房态、订单处置与代下单、会员/员工概况、保洁验收、设备状态、临时开门/电控、管理员验券和门店经营统计;按钮按 capability 展示,服务端重复校验租户、门店与写权限,关键写操作保留实际操作人审计。 | 真实团购商家、微信和硬件现场验收条件未提供。 | M08-D 继续平台后台;具备外部条件后补真机与现场验收。 | | ADM-001 | 管理员小程序模式 | M08/M06 | PARTIAL | `ce52f07` | 已完成角色入口、授权门店概况/房态、订单处置与代下单、会员/员工概况、保洁验收、设备状态、临时开门/电控、管理员验券和门店经营统计;按钮按 capability 展示,服务端重复校验租户、门店与写权限,关键写操作保留实际操作人审计。 | 真实团购商家、微信和硬件现场验收条件未提供。 | M08-D 继续平台后台;具备外部条件后补真机与现场验收。 |
| REP-001 | 数据统计报表 | M10/M08 | PARTIAL | `65f2e7c` | 门店 7/30/90 日经营统计已同时接入管理员小程序与 Vue 后台,返回订单总量/状态/预订额、OWNER 下单会员、成功支付实收与渠道、每日收入、当前房态、验券成功/失败;最长查询 93 天,租户与门店范围由服务端校验,后台支持授权门店切换、趋势条和明确口径提示,MySQL 实测 `GROUP_BUY` 渠道与成功支付口径。 | 尚缺全门店汇总、自定义导出、使用率/使用时长和生产数据复算验收。 | M08-D/M10 扩展平台统计、导出和指标口径文档。 | | REP-001 | 数据统计报表 | M10-B/M08 | DONE | `65f2e7c` + M10-B 当前工程 | 全授权门店/单店、今日/近 7 日/最长 93 天自定义范围已统一;按门店时区从房费和商品成功支付/退款、订单状态历史、保洁结算/冲正明细复算总净收入、渠道净额、订单、范围去重人数、实际使用时长、DST 感知利用率、保洁成本与贡献额。页面、服务端 UTF-8 CSV、逐店逐日日汇总和 source checksum 同源;自动/人工重算及 `MATCHED/NOT_AGGREGATED/MISMATCH` 可直接定位差异。Windows 全量和 MySQL 8.4.10 真库往返通过,指标口径见 `docs/business-report-metrics.md`。 | - | M10-C 继续使用租户/门店权限,不分叉统计口径。 |
| FRN-001 | 加盟商与自动分账 | M05-D/M10/M08 | PARTIAL | `aa4a6bd` | 已实现加盟意向幂等提交、租户隔离跟进、负责人分派、受控状态流转,以及收款账户、环境凭据引用、分账授权、脱敏接收方、比例校验、幂等分账和明细查询;Vue 后台覆盖线索与资金运营。 | 真实租户开通、门店创建、微信服务商分账权限和生产对账仍属于 M10-C/外部联调。 | M10-C 接入签约后的租户开通编排;取得权限后执行真实分账。 | | FRN-001 | 加盟商与自动分账 | M05-D/M10/M08 | PARTIAL | `aa4a6bd` | 已实现加盟意向幂等提交、租户隔离跟进、负责人分派、受控状态流转,以及收款账户、环境凭据引用、分账授权、脱敏接收方、比例校验、幂等分账和明细查询;Vue 后台覆盖线索与资金运营。 | 真实租户开通、门店创建、微信服务商分账权限和生产对账仍属于 M10-C/外部联调。 | M10-C 接入签约后的租户开通编排;取得权限后执行真实分账。 |
| BKG-008 | 后台加盟信息管理 | M08/M10 | PARTIAL | `aa4a6bd` | 小程序可提交城市、联系人、电话和留言;后台支持分页筛选、详情、同租户员工分派、电话/微信/面谈/备注跟进、下次跟进、状态机和完整操作历史。 | 尚缺签约后的租户开通编排、附件与生产隐私合规验收。 | M10-C 衔接租户/门店/支付配置开通流程。 | | BKG-008 | 后台加盟信息管理 | M08/M10 | PARTIAL | `aa4a6bd` | 小程序可提交城市、联系人、电话和留言;后台支持分页筛选、详情、同租户员工分派、电话/微信/面谈/备注跟进、下次跟进、状态机和完整操作历史。 | 尚缺签约后的租户开通编排、附件与生产隐私合规验收。 | M10-C 衔接租户/门店/支付配置开通流程。 |
| BKG-007 | 后台微信支付配置 | M05-D/M08 | PARTIAL | `4eac99d` | 已提供收款账户、分账接收方、门店/全局比例策略和授权状态 API;Vue 后台支持环境引用配置、授权状态、脱敏接收方、比例策略、分账记录、微信退款、对账单请求和分账执行;私钥/APIv3 Key 不读取或回显。M08-B 保洁转账预检仍提供商户资料与转账前阻断。 | 真实商户与服务商授权、微信后台场景报备和生产小额退款/分账仍需现场联调。 | 授权后完成生产退款、对账与分账验收。 | | BKG-007 | 后台微信支付配置 | M05-D/M08 | PARTIAL | `4eac99d` | 已提供收款账户、分账接收方、门店/全局比例策略和授权状态 API;Vue 后台支持环境引用配置、授权状态、脱敏接收方、比例策略、分账记录、微信退款、对账单请求和分账执行;私钥/APIv3 Key 不读取或回显。M08-B 保洁转账预检仍提供商户资料与转账前阻断。 | 真实商户与服务商授权、微信后台场景报备和生产小额退款/分账仍需现场联调。 | 授权后完成生产退款、对账与分账验收。 |
+7 -7
View File
@@ -3,16 +3,16 @@
```yaml ```yaml
execution_cursor: execution_cursor:
current_module: M10 current_module: M10
current_stage: M10-B current_stage: M10-C
stage_status: TODO stage_status: TODO
last_completed_stage: M10-A last_completed_stage: M10-B
next_stage: M10-C next_stage: M10-D
last_engineering_commit: `41b9cf7` last_engineering_commit: `5d70114`
pending_engineering_commit: "M10-A 已完成工程(随本次中文工程提交固化)" pending_engineering_commit: "M10-B 已完成工程(随本次中文工程提交固化)"
last_push_verified: true last_push_verified: true
base_branch: main base_branch: main
blocked_reason: "M08-A 真机合法域名、真实微信支付和实物开门验证缺微信后台/商户/硬件现场条件;M08-B 可独立工程内容已完成,真实商户凭据、微信后台配置、生产小额转账和实物硬件结果保持 BLOCKED_EXTERNALM08-C 可独立工程内容已完成,真实美团/抖音/快手商家授权与真机验券保持 BLOCKED_EXTERNAL,已按规则旁路进入 M08-D。M06-G 真实硬件联调仍缺生产 MQTT 账号/ACL、DeviceID、控制箱、门锁、插座和现场配线。" blocked_reason: "M08-A 真机合法域名、真实微信支付和实物开门验证缺微信后台/商户/硬件现场条件;M08-B 可独立工程内容已完成,真实商户凭据、微信后台配置、生产小额转账和实物硬件结果保持 BLOCKED_EXTERNALM08-C 可独立工程内容已完成,真实美团/抖音/快手商家授权与真机验券保持 BLOCKED_EXTERNAL,已按规则旁路进入 M08-D。M06-G 真实硬件联调仍缺生产 MQTT 账号/ACL、DeviceID、控制箱、门锁、插座和现场配线。"
updated_at: 2026-08-11T09:08:00+08:00 updated_at: 2026-08-11T09:41:58+08:00
``` ```
| 模块 | 状态 | 最近提交 | 最近开发日志 | 备注 | | 模块 | 状态 | 最近提交 | 最近开发日志 | 备注 |
@@ -27,5 +27,5 @@ execution_cursor:
| M07 会员、余额、充值、优惠券和套餐营销 | DONE | `ed0d455` | docs/devlogs/2026-06-24-M07-D-会员管理.md | M07-A 已完成现金/赠送双余额账本;M07-B 已完成充值优惠;M07-C 已完成优惠券/套餐权益底表和冻结、确认、退回的可靠补偿核销服务;M07-D 已完成会员画像、订单、消费、余额、充值、优惠券和套餐聚合查询。 | | M07 会员、余额、充值、优惠券和套餐营销 | DONE | `ed0d455` | docs/devlogs/2026-06-24-M07-D-会员管理.md | M07-A 已完成现金/赠送双余额账本;M07-B 已完成充值优惠;M07-C 已完成优惠券/套餐权益底表和冻结、确认、退回的可靠补偿核销服务;M07-D 已完成会员画像、订单、消费、余额、充值、优惠券和套餐聚合查询。 |
| M08 微信原生小程序完整业务 | PARTIAL | `3faf868` | docs/devlogs/2026-08-10-M08-D-Vue平台后台.md | M08-A/B/C/D 功能切片与 M08-D-R1 架构返工已完成;12 个权限路由按领域懒加载,最大 JS 分块 217,195 B,三档响应式门禁通过。真实微信、团购和硬件现场验收按 BLOCKED_EXTERNAL 保持 PARTIAL。 | | M08 微信原生小程序完整业务 | PARTIAL | `3faf868` | docs/devlogs/2026-08-10-M08-D-Vue平台后台.md | M08-A/B/C/D 功能切片与 M08-D-R1 架构返工已完成;12 个权限路由按领域懒加载,最大 JS 分块 217,195 B,三档响应式门禁通过。真实微信、团购和硬件现场验收按 BLOCKED_EXTERNAL 保持 PARTIAL。 |
| M09 保洁任务、结算、商品和库存 | DONE | `41b9cf7` | docs/devlogs/2026-08-11-M09-REGRESSION-商品全链路对账.md | M09-A/B/C 与 M09-D1/D2/D3/D4 及模块回归均已完成并推送;逐单金额、支付退款、库存、寄存和权限闭环通过。 | | M09 保洁任务、结算、商品和库存 | DONE | `41b9cf7` | docs/devlogs/2026-08-11-M09-REGRESSION-商品全链路对账.md | M09-A/B/C 与 M09-D1/D2/D3/D4 及模块回归均已完成并推送;逐单金额、支付退款、库存、寄存和权限闭环通过。 |
| M10 部署、域名、验收和运维闭环 | PARTIAL | M10-A 当前工程 | V5.7.md | M10-A 已完成统一通知事件、模板/路由、接收人解析、订阅授权、站内信、HTTPS 渠道适配器、幂等投递、有限重试、失败人工补发、静默时段、脱敏和后台追踪;Windows 全量 76.7 秒与 MySQL 8.4.10 的 229 up、165 verify、209 down 往返通过。下一步执行 M10-B 统计报表。 | | M10 部署、域名、验收和运维闭环 | PARTIAL | M10-B 当前工程 | V5.7.md | M10-A 已完成统一通知中心;M10-B 完成全店/单店、今日/7 日/自定义经营报表,按门店时区复算房费与商品实收减退款、实际使用时长/利用率、保洁成本和渠道净额,日汇总 checksum、重算任务与 UTF-8 CSV 同源。Windows 全量 77.6 秒与 MySQL 8.4.10 的 233 up、171 verify、213 down 往返通过。下一步执行 M10-C。 |
+2 -2
View File
@@ -24,11 +24,11 @@ if (!distOnly) {
assert.doesNotMatch(app, /import\s+(?!AdminLoginPanel\b)\w+Panel\s+from/); assert.doesNotMatch(app, /import\s+(?!AdminLoginPanel\b)\w+Panel\s+from/);
const router = read('admin/src/router.ts'); const router = read('admin/src/router.ts');
for (const path of ['/overview', '/stores', '/orders', '/products', '/notifications', '/payments', '/third-party', for (const path of ['/overview', '/stores', '/orders', '/products', '/notifications', '/reports', '/payments', '/third-party',
'/people', '/cleaning', '/devices', '/apps', '/content', '/franchise', '/system']) { '/people', '/cleaning', '/devices', '/apps', '/content', '/franchise', '/system']) {
assert.match(read('admin/src/navigation.mjs'), new RegExp(`path: '${path.replace('/', '\\/')}'`)); assert.match(read('admin/src/navigation.mjs'), new RegExp(`path: '${path.replace('/', '\\/')}'`));
} }
assert.equal((router.match(/\(\) => import\('/g) || []).length, 14); assert.equal((router.match(/\(\) => import\('/g) || []).length, 15);
assert.match(router, /router\.beforeEach/); assert.match(router, /router\.beforeEach/);
assert.match(router, /isNavigationAllowed/); assert.match(router, /isNavigationAllowed/);
+2 -2
View File
@@ -24,11 +24,11 @@ for (const pattern of [
assert.doesNotMatch(app, /import\s+(?!AdminLoginPanel\b)\w+Panel\s+from/); assert.doesNotMatch(app, /import\s+(?!AdminLoginPanel\b)\w+Panel\s+from/);
const router = read('admin/src/router.ts'); const router = read('admin/src/router.ts');
assert.equal((router.match(/\(\) => import\('/g) || []).length, 14); assert.equal((router.match(/\(\) => import\('/g) || []).length, 15);
assert.match(router, /router\.beforeEach/); assert.match(router, /router\.beforeEach/);
const navigation = read('admin/src/navigation.mjs'); const navigation = read('admin/src/navigation.mjs');
for (const path of ['/overview', '/stores', '/orders', '/products', '/payments', '/third-party', '/people', for (const path of ['/overview', '/stores', '/orders', '/products', '/payments', '/third-party', '/people',
'/cleaning', '/notifications', '/devices', '/apps', '/content', '/franchise', '/system']) assert.ok(navigation.includes(`path: '${path}'`)); '/cleaning', '/notifications', '/reports', '/devices', '/apps', '/content', '/franchise', '/system']) assert.ok(navigation.includes(`path: '${path}'`));
const cleaningWorkspace = read('admin/src/components/CleaningWorkspace.vue'); const cleaningWorkspace = read('admin/src/components/CleaningWorkspace.vue');
for (const pattern of ['CleaningTasksPanel', 'CleaningSettlementsPanel', 'CleaningStatisticsPanel', for (const pattern of ['CleaningTasksPanel', 'CleaningSettlementsPanel', 'CleaningStatisticsPanel',
+36
View File
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = dirname(dirname(fileURLToPath(import.meta.url)));
const read = (path) => readFileSync(join(root, path), 'utf8');
const service = read('backend/src/operations/business-report-service.ts');
const routes = read('backend/src/routes/business-statistics.ts');
const worker = read('backend/src/tasks/worker.ts');
const migration = read('database/migrations/2026081111_m10b_business_reports.up.sql');
const panel = read('admin/src/components/BusinessReportsPanel.vue');
const navigation = read('admin/src/navigation.mjs');
for (const token of [
'roomGrossCents', 'roomRefundCents', 'productGrossCents', 'productRefundCents',
'cleaningCostCents', 'usedMinutes', 'capacityMinutes', 'utilizationBasisPoints',
'zonedDateStart', 'sourceChecksum', 'reconciliation', 'enqueueDueAggregations'
]) assert.ok(service.includes(token), `Missing report service contract: ${token}`);
assert.ok(service.includes("'PARTIALLY_REFUNDED', 'REFUNDED'"));
for (const channel of ['WECHAT', 'BALANCE', 'PACKAGE', 'GROUP_BUY', 'OTHER']) {
assert.ok(service.includes(`'${channel}'`), `Missing payment channel: ${channel}`);
}
for (const path of [
'/admin-api/reports/business', '/admin-api/reports/business/export',
'/admin-api/reports/business/rebuild'
]) assert.ok(routes.includes(path), `Missing report route: ${path}`);
assert.ok(routes.includes('text/csv; charset=utf-8'));
assert.ok(worker.includes("['statistics.aggregate'"));
assert.ok(worker.includes('reports.enqueueDueAggregations()'));
assert.ok(migration.includes('qipai_business_daily_summaries'));
for (const permission of ['report.read', 'report.export', 'report.manage']) assert.ok(migration.includes(permission));
for (const label of ['今日', '近 7 日', '全部授权门店', '重算日汇总', '经营明细']) assert.ok(panel.includes(label));
assert.ok(navigation.includes("menuKey: 'reports'"));
console.log('PASS: M10-B timezone reporting, refunds, usage, channels, daily reconciliation and export gates are present.');
+2
View File
@@ -50,6 +50,8 @@ node scripts/check-m09-regression.mjs
Assert-NativeSuccess "check-m09-regression" Assert-NativeSuccess "check-m09-regression"
node scripts/check-m10-a-notifications.mjs node scripts/check-m10-a-notifications.mjs
Assert-NativeSuccess "check-m10-a-notifications" Assert-NativeSuccess "check-m10-a-notifications"
node scripts/check-m10-b-business-reports.mjs
Assert-NativeSuccess "check-m10-b-business-reports"
if (Test-Path "admin/package.json") { if (Test-Path "admin/package.json") {
Push-Location admin Push-Location admin
+2 -2
View File
@@ -107,6 +107,6 @@ export QIPAI_MYSQL_USER="${username}"
export QIPAI_MYSQL_PASSWORD="${password}" export QIPAI_MYSQL_PASSWORD="${password}"
export QIPAI_MYSQL_CONNECTION_LIMIT=2 export QIPAI_MYSQL_CONNECTION_LIMIT=2
echo "INFO: MySQL ${mysql_version}; running M01-B through M10-A integration roundtrip in a temporary database." echo "INFO: MySQL ${mysql_version}; running M01-B through M10-B integration roundtrip in a temporary database."
npm --prefix backend run test:mysql:migration npm --prefix backend run test:mysql:migration
echo "PASS: M01-B through M10-A live MySQL integration roundtrip completed." echo "PASS: M01-B through M10-B live MySQL integration roundtrip completed."