feat(M08-B): 补后台协作与结算详情

This commit is contained in:
Codex
2026-06-30 10:02:17 +08:00
parent 9506021b29
commit f59beb0e75
15 changed files with 529 additions and 23 deletions
+3 -3
View File
@@ -19,7 +19,7 @@
## 目录 ## 目录
- `backend/`Fastify + TypeScript 后端 API;若实际后端位于仓库根目录,以 `docs/repository-map.md` 为准 - `backend/`Fastify + TypeScript 后端 API;若实际后端位于仓库根目录,以 `docs/repository-map.md` 为准
- `admin/`:Vue3 后台管理端,已接入 M08-B 保洁运营工作台,必须适配桌面、平板和手机 - `admin/`:Vue3 后台管理端,已接入 M08-B 保洁运营工作台、协作者管理和结算详情,必须适配桌面、平板和手机
- `miniapp/`:微信原生小程序 - `miniapp/`:微信原生小程序
- `database/`:迁移、种子和兼容 SQL - `database/`:迁移、种子和兼容 SQL
- `deploy/`Nginx、PM2、EMQX 模板与部署版本 - `deploy/`Nginx、PM2、EMQX 模板与部署版本
@@ -121,8 +121,8 @@ WSL 已验证:EMQX `5.8.9`、MQTTX CLI `1.13.0`、EMQX 服务 `active (running
项目已开发部分模块。具体完成度不得从 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` 为准。
- 当前执行游标:`M08-B`PARTIAL - 当前执行游标:`M08-B`PARTIAL
- 最近工程提交:`1fada09`,上一轮 `M08-B` 保洁管理后台增量;本轮正在推进微信转账预检与真实商户场景报备联调准备 - 最近工程提交:`M08-B后台协作与结算详情增量`,本轮补齐保洁任务协作者管理、结算单详情接口和后台详情界面
- 下一工程目标:继续 M08-B,补结算单详情、协作者管理和更完整保洁运营管理界面。 - 下一工程目标:继续 M08-B,补统计明细、结算口径校验和更完整保洁运营管理界面。
## 版本递进 ## 版本递进
+47
View File
@@ -1,6 +1,8 @@
import type { import type {
CleaningSettlement, CleaningSettlement,
CleaningSettlementDetail,
CleaningTask, CleaningTask,
CleaningTaskMember,
PageResult, PageResult,
SettlementStatus, SettlementStatus,
TaskStatus, TaskStatus,
@@ -86,6 +88,44 @@ export function rejectCleaningTask(session: ApiSession, taskId: string, reason:
}); });
} }
export function listCleaningTaskMembers(session: ApiSession, taskId: string) {
return request<CleaningTaskMember[]>(
session,
`/cleaning/tasks/${encodeURIComponent(taskId)}/members`
);
}
export function addCleaningTaskMember(
session: ApiSession,
taskId: string,
input: { cleanerUserId: string; rewardCents: number; note?: string }
) {
return request<CleaningTaskMember[]>(
session,
`/cleaning/tasks/${encodeURIComponent(taskId)}/members`,
{
method: 'POST',
body: JSON.stringify(input)
}
);
}
export function removeCleaningTaskMember(
session: ApiSession,
taskId: string,
cleanerUserId: string,
note?: string
) {
return request<CleaningTaskMember[]>(
session,
`/cleaning/tasks/${encodeURIComponent(taskId)}/members/${encodeURIComponent(cleanerUserId)}/remove`,
{
method: 'POST',
body: JSON.stringify({ note })
}
);
}
export function reclaimCleaningTimeouts( export function reclaimCleaningTimeouts(
session: ApiSession, session: ApiSession,
input: { olderThanMinutes: number; limit: number } input: { olderThanMinutes: number; limit: number }
@@ -118,6 +158,13 @@ export function generateCleaningSettlement(
}); });
} }
export function getCleaningSettlementDetail(session: ApiSession, settlementId: string) {
return request<CleaningSettlementDetail>(
session,
`/cleaning/settlements/${encodeURIComponent(settlementId)}`
);
}
export function confirmCleaningSettlement(session: ApiSession, settlementId: string, note?: string) { export function confirmCleaningSettlement(session: ApiSession, settlementId: string, note?: string) {
return request<CleaningSettlement>( return request<CleaningSettlement>(
session, session,
@@ -49,6 +49,7 @@
<el-table-column label="操作" width="320" fixed="right"> <el-table-column label="操作" width="320" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<div class="row-actions"> <div class="row-actions">
<el-button size="small" :icon="ListChecks" @click="openDetail(row)">详情</el-button>
<el-button <el-button
size="small" size="small"
:icon="BadgeCheck" :icon="BadgeCheck"
@@ -188,6 +189,56 @@
<el-button type="danger" @click="submitFailure">记录</el-button> <el-button type="danger" @click="submitFailure">记录</el-button>
</template> </template>
</el-dialog> </el-dialog>
<el-dialog v-model="detailDialog.open" title="结算单详情" width="720px">
<div v-if="detailDialog.detail" class="dialog-stack">
<div class="detail-grid">
<span>单号<strong>{{ detailDialog.detail.settlement.settlementNo }}</strong></span>
<span>保洁员<strong>{{ detailDialog.detail.settlement.cleanerName }}</strong></span>
<span>金额<strong>{{ money(detailDialog.detail.settlement.totalRewardCents) }}</strong></span>
<span>状态<strong>{{ detailDialog.detail.settlement.status }}</strong></span>
</div>
<el-table :data="detailDialog.detail.items" size="small" v-loading="detailDialog.loading">
<el-table-column prop="taskNo" label="任务" min-width="170">
<template #default="{ row }">
<div class="stack">
<strong>{{ row.taskNo }}</strong>
<span>{{ compactText(row.orderNo) }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="房间" min-width="150">
<template #default="{ row }">
{{ row.storeName }} · {{ row.roomName || row.roomNo }}
</template>
</el-table-column>
<el-table-column prop="cleanerName" label="成员" width="130" />
<el-table-column prop="rewardCents" label="金额" width="110">
<template #default="{ row }">{{ money(row.rewardCents) }}</template>
</el-table-column>
<el-table-column prop="completedAt" label="完成" width="130">
<template #default="{ row }">{{ shortDate(row.completedAt) }}</template>
</el-table-column>
</el-table>
<el-descriptions :column="2" size="small" border>
<el-descriptions-item label="转账状态">
{{ compactText(detailDialog.detail.settlement.payoutState) }}
</el-descriptions-item>
<el-descriptions-item label="外部流水">
{{ compactText(detailDialog.detail.settlement.payoutReference) }}
</el-descriptions-item>
<el-descriptions-item label="失败原因" :span="2">
{{ compactText(detailDialog.detail.settlement.payoutError) }}
</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">
{{ compactText(detailDialog.detail.settlement.note) }}
</el-descriptions-item>
</el-descriptions>
</div>
<template #footer>
<el-button @click="detailDialog.open = false">关闭</el-button>
</template>
</el-dialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -197,14 +248,16 @@ import {
CircleAlert, CircleAlert,
ClipboardCheck, ClipboardCheck,
FilePlus2, FilePlus2,
ListChecks,
RefreshCw, RefreshCw,
Send Send
} from '@lucide/vue'; } from '@lucide/vue';
import { ElMessage } from 'element-plus'; import { ElMessage } from 'element-plus';
import { preflightWechatTransfer } from '../api'; import { getCleaningSettlementDetail, preflightWechatTransfer } from '../api';
import { compactText, money, shortDate } from '../format'; import { compactText, money, shortDate } from '../format';
import type { import type {
CleaningSettlement, CleaningSettlement,
CleaningSettlementDetail,
PageResult, PageResult,
SettlementStatus, SettlementStatus,
TransferMode, TransferMode,
@@ -260,6 +313,15 @@ const failureDialog = reactive({
payoutReference: '', payoutReference: '',
note: '' note: ''
}); });
const detailDialog = reactive<{
open: boolean;
loading: boolean;
detail: CleaningSettlementDetail | null;
}>({
open: false,
loading: false,
detail: null
});
function settlementTag(status: SettlementStatus) { function settlementTag(status: SettlementStatus) {
if (status === 'PAID') return 'success'; if (status === 'PAID') return 'success';
@@ -325,4 +387,16 @@ function submitFailure() {
}); });
failureDialog.open = false; failureDialog.open = false;
} }
async function openDetail(row: CleaningSettlement) {
detailDialog.open = true;
detailDialog.loading = true;
try {
detailDialog.detail = await getCleaningSettlementDetail(props.session, row.id);
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '结算详情加载失败');
} finally {
detailDialog.loading = false;
}
}
</script> </script>
+137 -3
View File
@@ -64,6 +64,7 @@
<template #default="{ row }"> <template #default="{ row }">
<div class="row-actions"> <div class="row-actions">
<el-button size="small" :icon="UserPlus" @click="openAssign(row)">指派</el-button> <el-button size="small" :icon="UserPlus" @click="openAssign(row)">指派</el-button>
<el-button size="small" :icon="Users" @click="openMembers(row)">成员</el-button>
<el-button <el-button
size="small" size="small"
type="success" type="success"
@@ -120,6 +121,66 @@
<el-button type="danger" @click="submitReject">驳回</el-button> <el-button type="danger" @click="submitReject">驳回</el-button>
</template> </template>
</el-dialog> </el-dialog>
<el-dialog v-model="memberDialog.open" title="协作者管理" width="640px">
<div class="dialog-stack">
<div class="detail-header">
<strong>{{ memberDialog.taskNo }}</strong>
<span>任务奖励 {{ money(memberDialog.taskRewardCents) }}</span>
</div>
<el-table :data="memberDialog.members" size="small" v-loading="memberDialog.loading">
<el-table-column prop="nickname" label="成员" min-width="140">
<template #default="{ row }">
<div class="stack">
<strong>{{ row.nickname || compactText(row.userId) }}</strong>
<span>ID {{ row.userId }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="memberRole" label="角色" width="90">
<template #default="{ row }">
<el-tag :type="row.memberRole === 'LEAD' ? 'success' : 'info'" effect="light">
{{ row.memberRole }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="rewardCents" label="分账" width="110">
<template #default="{ row }">{{ money(row.rewardCents) }}</template>
</el-table-column>
<el-table-column prop="settledAt" label="结算" width="130">
<template #default="{ row }">{{ shortDate(row.settledAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="90">
<template #default="{ row }">
<el-button
size="small"
type="danger"
:icon="UserMinus"
:disabled="row.memberRole === 'LEAD' || !!row.settledAt"
@click="removeMember(row.userId)"
>
移除
</el-button>
</template>
</el-table-column>
</el-table>
<el-form label-position="top" class="member-form">
<el-form-item label="协作者用户 ID">
<el-input v-model="memberDialog.cleanerUserId" inputmode="numeric" />
</el-form-item>
<el-form-item label="分账金额">
<el-input-number v-model="memberDialog.rewardCents" :min="0" :max="1000000" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="memberDialog.note" />
</el-form-item>
<el-button type="primary" :icon="UserPlus" @click="submitMember">添加/更新</el-button>
</el-form>
</div>
<template #footer>
<el-button @click="memberDialog.open = false">关闭</el-button>
</template>
</el-dialog>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -131,12 +192,20 @@ import {
RefreshCw, RefreshCw,
RotateCcw, RotateCcw,
Undo2, Undo2,
UserPlus UserMinus,
UserPlus,
Users
} from '@lucide/vue'; } from '@lucide/vue';
import { ElMessage } from 'element-plus';
import {
addCleaningTaskMember,
listCleaningTaskMembers,
removeCleaningTaskMember
} from '../api';
import { compactText, money, shortDate } from '../format'; import { compactText, money, shortDate } from '../format';
import type { CleaningTask, PageResult, TaskStatus } from '../types'; import type { CleaningTask, CleaningTaskMember, PageResult, TaskStatus } from '../types';
defineProps<{ const props = defineProps<{
session: { token: string }; session: { token: string };
loading: boolean; loading: boolean;
items: PageResult<CleaningTask>['items']; items: PageResult<CleaningTask>['items'];
@@ -168,6 +237,17 @@ const statusOptions = [
const reclaimForm = reactive({ olderThanMinutes: 60, limit: 20 }); const reclaimForm = reactive({ olderThanMinutes: 60, limit: 20 });
const assignDialog = reactive({ open: false, taskId: '', cleanerUserId: '', note: '' }); const assignDialog = reactive({ open: false, taskId: '', cleanerUserId: '', note: '' });
const rejectDialog = reactive({ open: false, taskId: '', reason: '' }); const rejectDialog = reactive({ open: false, taskId: '', reason: '' });
const memberDialog = reactive({
open: false,
loading: false,
taskId: '',
taskNo: '',
taskRewardCents: 0,
cleanerUserId: '',
rewardCents: 0,
note: '',
members: [] as CleaningTaskMember[]
});
function taskTag(status: TaskStatus) { function taskTag(status: TaskStatus) {
if (status === 'SUBMITTED') return 'warning'; if (status === 'SUBMITTED') return 'warning';
@@ -202,4 +282,58 @@ function submitReject() {
emit('reject', { taskId: rejectDialog.taskId, reason: rejectDialog.reason }); emit('reject', { taskId: rejectDialog.taskId, reason: rejectDialog.reason });
rejectDialog.open = false; rejectDialog.open = false;
} }
async function loadMembers() {
memberDialog.loading = true;
try {
memberDialog.members = await listCleaningTaskMembers(props.session, memberDialog.taskId);
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '成员加载失败');
} finally {
memberDialog.loading = false;
}
}
async function openMembers(row: CleaningTask) {
memberDialog.open = true;
memberDialog.taskId = row.id;
memberDialog.taskNo = row.taskNo;
memberDialog.taskRewardCents = row.rewardCents;
memberDialog.cleanerUserId = '';
memberDialog.rewardCents = 0;
memberDialog.note = '';
await loadMembers();
}
async function submitMember() {
try {
memberDialog.members = await addCleaningTaskMember(props.session, memberDialog.taskId, {
cleanerUserId: memberDialog.cleanerUserId,
rewardCents: memberDialog.rewardCents,
note: memberDialog.note
});
memberDialog.cleanerUserId = '';
memberDialog.rewardCents = 0;
memberDialog.note = '';
ElMessage.success('协作者已更新');
emit('refresh');
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '协作者更新失败');
}
}
async function removeMember(cleanerUserId: string) {
try {
memberDialog.members = await removeCleaningTaskMember(
props.session,
memberDialog.taskId,
cleanerUserId,
'后台移除协作者'
);
ElMessage.success('协作者已移除');
emit('refresh');
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '协作者移除失败');
}
}
</script> </script>
+64
View File
@@ -255,6 +255,65 @@ textarea {
width: 100%; width: 100%;
} }
.dialog-stack {
display: grid;
gap: 14px;
}
.detail-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border: 1px solid #e1e8f0;
border-radius: 8px;
background: #f5f8fb;
}
.detail-header span {
color: #60708a;
font-size: 13px;
}
.member-form {
display: grid;
grid-template-columns: minmax(0, 1fr) 140px minmax(0, 1fr) auto;
gap: 10px;
align-items: end;
padding-top: 2px;
}
.member-form .el-form-item {
margin-bottom: 0;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.detail-grid span {
display: grid;
gap: 4px;
min-width: 0;
padding: 9px 10px;
color: #60708a;
font-size: 12px;
border: 1px solid #e1e8f0;
border-radius: 8px;
background: #f5f8fb;
}
.detail-grid strong {
overflow: hidden;
color: #18212f;
font-size: 14px;
text-overflow: ellipsis;
white-space: nowrap;
}
.el-button { .el-button {
border-radius: 8px; border-radius: 8px;
} }
@@ -327,4 +386,9 @@ textarea {
.preflight-meta { .preflight-meta {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.member-form,
.detail-grid {
grid-template-columns: 1fr;
}
} }
+33
View File
@@ -45,6 +45,18 @@ export interface CleaningTask {
updatedAt?: string; updatedAt?: string;
} }
export interface CleaningTaskMember {
id: string;
taskId: string;
userId: string;
nickname: string;
memberRole: 'LEAD' | 'ASSIST';
rewardCents: number;
joinedAt?: string;
removedAt?: string | null;
settledAt?: string | null;
}
export interface CleaningSettlement { export interface CleaningSettlement {
id: string; id: string;
settlementNo: string; settlementNo: string;
@@ -69,6 +81,27 @@ export interface CleaningSettlement {
createdAt: string; createdAt: string;
} }
export interface CleaningSettlementItem {
id: string;
settlementId: string;
taskId: string;
taskNo: string;
orderNo: string | null;
storeName: string;
roomName: string;
roomNo: string;
cleanerUserId: string;
cleanerName: string;
rewardCents: number;
completedAt: string | null;
createdAt: string;
}
export interface CleaningSettlementDetail {
settlement: CleaningSettlement;
items: CleaningSettlementItem[];
}
export type WechatTransferPreflightStatus = 'PASS' | 'WARN' | 'FAIL'; export type WechatTransferPreflightStatus = 'PASS' | 'WARN' | 'FAIL';
export interface WechatTransferPreflightCheck { export interface WechatTransferPreflightCheck {
@@ -96,6 +96,21 @@ interface SettlementRow extends RowDataPacket {
note: string; note: string;
createdAt: Date; createdAt: Date;
} }
interface SettlementItemRow extends RowDataPacket {
id: string;
settlementId: string;
taskId: string;
taskNo: string;
orderNo: string | null;
storeName: string;
roomName: string;
roomNo: string;
cleanerUserId: string;
cleanerName: string;
rewardCents: number;
completedAt: Date | null;
createdAt: Date;
}
export class CleaningTaskRepository { export class CleaningTaskRepository {
constructor(private readonly pool: MySqlPool) {} constructor(private readonly pool: MySqlPool) {}
@@ -370,6 +385,31 @@ export class CleaningTaskRepository {
}; };
} }
async getSettlementDetail(input: CleaningActor & { settlementId: string }) {
this.assertSettlement(input.access, 'read');
const settlement = await this.getSettlementScoped(input);
const [items] = await this.pool.execute<SettlementItemRow[]>(
`SELECT i.id, i.settlement_id AS settlementId, i.task_id AS taskId,
t.task_no AS taskNo, o.order_no AS orderNo,
st.name AS storeName, r.name AS roomName, r.room_no AS roomNo,
i.cleaner_user_id AS cleanerUserId, u.nickname AS cleanerName,
i.reward_cents AS rewardCents, t.completed_at AS completedAt,
i.created_at AS createdAt
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 AND s.deleted_at IS NULL
INNER JOIN qipai_cleaning_tasks t ON t.tenant_id = i.tenant_id AND t.id = i.task_id
INNER JOIN qipai_stores st ON st.tenant_id = t.tenant_id AND st.id = t.store_id
INNER JOIN qipai_rooms r ON r.tenant_id = t.tenant_id AND r.id = t.room_id
INNER JOIN qipai_users u ON u.tenant_id = i.tenant_id AND u.id = i.cleaner_user_id
LEFT JOIN qipai_orders o ON o.tenant_id = t.tenant_id AND o.id = t.order_id
WHERE i.tenant_id = ? AND i.settlement_id = ?
ORDER BY t.completed_at ASC, i.id ASC`,
[input.tenantId, input.settlementId]
);
return { settlement, items: items.map(publicSettlementItem) };
}
async generateSettlement(input: CleaningActor & { async generateSettlement(input: CleaningActor & {
cleanerUserId: string; storeId?: string; note?: string; cleanerUserId: string; storeId?: string; note?: string;
}) { }) {
@@ -963,6 +1003,29 @@ export class CleaningTaskRepository {
return publicSettlement(rows[0]); return publicSettlement(rows[0]);
} }
private async getSettlementScoped(input: CleaningActor & { settlementId: string }) {
const [rows] = await this.pool.execute<SettlementRow[]>(
`SELECT s.id, s.settlement_no AS settlementNo,
s.cleaner_user_id AS cleanerUserId, u.nickname AS cleanerName,
s.store_id AS storeId, st.name AS storeName, s.status,
s.task_count AS taskCount, s.total_reward_cents AS totalRewardCents,
s.period_start AS periodStart, s.period_end AS periodEnd,
s.paid_by AS paidBy, s.confirmed_at AS confirmedAt, s.paid_at AS paidAt,
s.payout_channel AS payoutChannel, s.payout_reference AS payoutReference,
s.payout_state AS payoutState, s.payout_package_info AS payoutPackageInfo,
s.payout_error AS payoutError,
s.note, s.created_at AS createdAt
FROM qipai_cleaning_settlements s
INNER JOIN qipai_users u ON u.tenant_id = s.tenant_id AND u.id = s.cleaner_user_id
LEFT JOIN qipai_stores st ON st.tenant_id = s.tenant_id AND st.id = s.store_id
WHERE s.tenant_id = ? AND s.id = ? AND s.deleted_at IS NULL
AND ${storeScopeSql(input.access, 'COALESCE(s.store_id, 0)')}`,
[input.tenantId, input.settlementId]
);
if (!rows[0]) throw new CleaningTaskError('CLEANING_SETTLEMENT_NOT_FOUND');
return publicSettlement(rows[0]);
}
private async transaction<T>(work: (connection: PoolConnection) => Promise<T>) { private async transaction<T>(work: (connection: PoolConnection) => Promise<T>) {
const connection = await this.pool.getConnection(); const connection = await this.pool.getConnection();
try { try {
@@ -1055,6 +1118,24 @@ function publicSettlement(row: SettlementRow) {
}; };
} }
function publicSettlementItem(row: SettlementItemRow) {
return {
id: String(row.id),
settlementId: String(row.settlementId),
taskId: String(row.taskId),
taskNo: row.taskNo,
orderNo: row.orderNo,
storeName: row.storeName,
roomName: row.roomName,
roomNo: row.roomNo,
cleanerUserId: String(row.cleanerUserId),
cleanerName: row.cleanerName,
rewardCents: Number(row.rewardCents),
completedAt: row.completedAt,
createdAt: row.createdAt
};
}
function minDate(values: Array<Date | null>) { function minDate(values: Array<Date | null>) {
const dates = values.filter((value): value is Date => value instanceof Date); const dates = values.filter((value): value is Date => value instanceof Date);
if (dates.length === 0) return null; if (dates.length === 0) return null;
+16 -1
View File
@@ -89,7 +89,7 @@ export interface CleaningRouteOptions {
repository: Pick<CleaningTaskRepository, repository: Pick<CleaningTaskRepository,
'listHall' | 'listMine' | 'listManage' | 'claim' | 'start' | 'rework' | 'submit' 'listHall' | 'listMine' | 'listManage' | 'claim' | 'start' | 'rework' | 'submit'
| 'assign' | 'complete' | 'reject' | 'listMembers' | 'addMember' | 'removeMember' | 'settlementCandidates' | 'assign' | 'complete' | 'reject' | 'listMembers' | 'addMember' | 'removeMember' | 'settlementCandidates'
| 'listSettlements' | 'generateSettlement' | 'confirmSettlement' | 'markSettlementPaid' | 'listSettlements' | 'getSettlementDetail' | 'generateSettlement' | 'confirmSettlement' | 'markSettlementPaid'
| 'recordSettlementPayoutFailure' | 'reclaimTimeouts' | 'recordSettlementPayoutFailure' | 'reclaimTimeouts'
| 'assertCanUploadPhoto' | 'stats'>; | 'assertCanUploadPhoto' | 'stats'>;
mediaStorage?: MediaStorage; mediaStorage?: MediaStorage;
@@ -183,6 +183,21 @@ export async function registerCleaningRoutes(
})); }));
}); });
app.get('/admin-api/cleaning/settlements/:settlementId', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
if (!actor) return;
const params = settlementParamsSchema.safeParse(request.params);
if (!params.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.getSettlementDetail({
...actor,
settlementId: params.data.settlementId
}),
traceId: request.traceId
}));
});
app.post('/app-api/cleaning/tasks/:taskId/claim', async (request, reply) => { app.post('/app-api/cleaning/tasks/:taskId/claim', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write'); const actor = await requireActor(request, reply, options, 'write');
if (!actor) return; if (!actor) return;
+34
View File
@@ -114,6 +114,13 @@ const app = await buildApp({
calls.push(['listSettlements', input]); calls.push(['listSettlements', input]);
return { items: [settlementResponse(input.status || 'DRAFT')], total: 1, page: input.page, pageSize: input.pageSize }; return { items: [settlementResponse(input.status || 'DRAFT')], total: 1, page: input.page, pageSize: input.pageSize };
}, },
async getSettlementDetail(input) {
calls.push(['getSettlementDetail', input]);
return {
settlement: settlementResponse('DRAFT'),
items: [settlementItemResponse()]
};
},
async generateSettlement(input) { async generateSettlement(input) {
calls.push(['generateSettlement', input]); calls.push(['generateSettlement', input]);
return settlementResponse('DRAFT'); return settlementResponse('DRAFT');
@@ -367,6 +374,15 @@ assert.equal(settlementList.statusCode, 200);
assert.equal(calls.at(-1)[0], 'listSettlements'); assert.equal(calls.at(-1)[0], 'listSettlements');
assert.equal(calls.at(-1)[1].status, 'DRAFT'); assert.equal(calls.at(-1)[1].status, 'DRAFT');
const settlementDetail = await app.inject({
method: 'GET',
url: '/admin-api/cleaning/settlements/501',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(settlementDetail.statusCode, 200);
assert.equal(calls.at(-1)[0], 'getSettlementDetail');
assert.equal(settlementDetail.json().data.items[0].taskNo, 'CLN-20260625-0001');
const generatedSettlement = await app.inject({ const generatedSettlement = await app.inject({
method: 'POST', method: 'POST',
url: '/admin-api/cleaning/settlements', url: '/admin-api/cleaning/settlements',
@@ -542,6 +558,24 @@ function settlementResponse(status) {
}; };
} }
function settlementItemResponse() {
return {
id: '601',
settlementId: '501',
taskId: '101',
taskNo: 'CLN-20260625-0001',
orderNo: 'O301',
storeName: 'Test Store',
roomName: 'A Room',
roomNo: 'A01',
cleanerUserId: '31',
cleanerName: 'Cleaner',
rewardCents: 600,
completedAt: new Date(),
createdAt: new Date()
};
}
function member(memberRole, userId, rewardCents) { function member(memberRole, userId, rewardCents) {
return { return {
id: `${userId}01`, id: `${userId}01`,
@@ -154,3 +154,11 @@
- Headers`wechatpay-timestamp``wechatpay-nonce``wechatpay-serial``wechatpay-signature` - Headers`wechatpay-timestamp``wechatpay-nonce``wechatpay-serial``wechatpay-signature`
- Body:微信支付 v3 加密通知原文。 - Body:微信支付 v3 加密通知原文。
- 说明:服务端用原始 body 验签解密,校验商户号后按 `out_bill_no``transfer_bill_no` 定位保洁结算单,并写回成功、失败或中间态。 - 说明:服务端用原始 body 验签解密,校验商户号后按 `out_bill_no``transfer_bill_no` 定位保洁结算单,并写回成功、失败或中间态。
## 2026-06-30 后台协作与结算详情增量
- `GET /admin-api/cleaning/settlements/:settlementId`
- 登录:必须。
- 权限:`cleaning.settlement.read``tenant.manage``PLATFORM_ADMIN`
- 说明:按租户和门店数据范围读取结算单详情,返回结算单主信息与固化结算明细。
- 返回:`settlement``items`;明细包含任务号、订单号、门店、房间、保洁员、成员分账金额和完成时间。
+4 -4
View File
@@ -2,7 +2,7 @@
> V5.0 首次核验日期:2026-06-16 > V5.0 首次核验日期:2026-06-16
> audited_commit: `4ae9296` > audited_commit: `4ae9296`
> next_engineering_target: 继续 M08-B 结算单详情、协作者管理和更完整保洁运营管理界面 > next_engineering_target: 继续 M08-B 统计明细、结算口径校验和更完整保洁运营管理界面
> 事实源:当前工作区、Git 历史、状态文档、Windows/WSL 检查脚本。 > 事实源:当前工作区、Git 历史、状态文档、Windows/WSL 检查脚本。
## 总体结论 ## 总体结论
@@ -12,8 +12,8 @@
| 总纲版本 | V5.4 已成为当前权威总纲,旧版已保留为历史备份 | 根目录存在 `V5.4.md` 及旧版总纲 | 可继续按 V5.4 的执行游标开发 | | 总纲版本 | V5.4 已成为当前权威总纲,旧版已保留为历史备份 | 根目录存在 `V5.4.md` 及旧版总纲 | 可继续按 V5.4 的执行游标开发 |
| 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 和门店范围 | `backend/src/auth/**``database/migrations/2026061805_m02c_rbac.*.sql``backend/tests/**` | M01、M02-A/B/C DONEMySQL 已验证 capability 和跨租户门店授权拒绝;下一阶段 M02-D | | 正式后端 | 已完成 Fastify、迁移兼容、outbox/worker、多应用租户、微信身份/会话,以及六角色 RBAC 和门店范围 | `backend/src/auth/**``database/migrations/2026061805_m02c_rbac.*.sql``backend/tests/**` | M01、M02-A/B/C DONEMySQL 已验证 capability 和跨租户门店授权拒绝;下一阶段 M02-D |
| 后台管理端 | 已建立 Vue3 + Vite + TypeScript + Element Plus 工程,并接入 M08-B 保洁任务、结算管理和微信转账预检起步界面 | `admin/src/App.vue``admin/src/components/CleaningTasksPanel.vue``admin/src/components/CleaningSettlementsPanel.vue``scripts/check-admin-m08-b.mjs` | M08-B 后台起点已具备,可查看结算微信转账前置条件;M09 完整后台仍未开始,不能标记 DONE | | 后台管理端 | 已建立 Vue3 + Vite + TypeScript + Element Plus 工程,并接入 M08-B 保洁任务、协作者管理、结算详情和微信转账预检界面 | `admin/src/App.vue``admin/src/components/CleaningTasksPanel.vue``admin/src/components/CleaningSettlementsPanel.vue``scripts/check-admin-m08-b.mjs` | M08-B 后台起点已具备,可维护任务成员、查看结算详情和结算微信转账前置条件;M09 完整后台仍未开始,不能标记 DONE |
| 微信小程序 | 已纳管微信原生模板,并完成顾客端订单、充值、权益明细、下单抵扣主体链路和保洁任务端照片上传/补做主体链路 | `miniapp/pages/index``miniapp/pages/store/detail``miniapp/pages/room/detail``miniapp/pages/orders/*``miniapp/pages/recharge/*``miniapp/pages/profile/*``miniapp/pages/cleaner/*``miniapp/utils/api.js``scripts/check-miniapp-m08-a.mjs` | M08 PARTIALM08-A 顾客端工程闭环按外部阻塞旁路,M08-B 已支持保洁任务大厅、我的任务、接单、开始、照片上传、提交验收、驳回补做、订单结束自动建任务、统计、管理侧查询/指派/验收/驳回、超时回收、协作者查看/添加/移除、待结算候选、正式结算单生成/确认、发放记录、微信转账预检、主动同步、通知回调后台起步管理界面,仍不能标记 DONE | | 微信小程序 | 已纳管微信原生模板,并完成顾客端订单、充值、权益明细、下单抵扣主体链路和保洁任务端照片上传/补做主体链路 | `miniapp/pages/index``miniapp/pages/store/detail``miniapp/pages/room/detail``miniapp/pages/orders/*``miniapp/pages/recharge/*``miniapp/pages/profile/*``miniapp/pages/cleaner/*``miniapp/utils/api.js``scripts/check-miniapp-m08-a.mjs` | M08 PARTIALM08-A 顾客端工程闭环按外部阻塞旁路,M08-B 已支持保洁任务大厅、我的任务、接单、开始、照片上传、提交验收、驳回补做、订单结束自动建任务、统计、管理侧查询/指派/验收/驳回、超时回收、协作者查看/添加/移除、待结算候选、正式结算单生成/确认、发放记录、微信转账预检、主动同步、通知回调后台协作者管理和结算详情界面,仍不能标记 DONE |
| 数据库迁移 | 已新增 M01-B 核心 schema up/down/verify SQL、最小脱敏 seed、旧库结构 fixture、迁移计划/执行 CLI、验证结果数量门禁和 MySQL 8 集成测试 | `database/migrations/2026061601_m01b_core_schema.*.sql``database/seeds/2026061601_m01b_minimal_seed.sql``database/fixtures/2026061801_m01b_legacy_schema.sql``backend/src/db/migration-runner.ts``backend/tests/mysql-migration-roundtrip.test.mjs` | M01-B DONEWSL MySQL 8.4.9 已验证 8 条旧库 fixture、11 条 up、3 条 verify、10 条 down,并验证租户隔离和旧金额整数分转换;不导入真实旧数据 | | 数据库迁移 | 已新增 M01-B 核心 schema up/down/verify SQL、最小脱敏 seed、旧库结构 fixture、迁移计划/执行 CLI、验证结果数量门禁和 MySQL 8 集成测试 | `database/migrations/2026061601_m01b_core_schema.*.sql``database/seeds/2026061601_m01b_minimal_seed.sql``database/fixtures/2026061801_m01b_legacy_schema.sql``backend/src/db/migration-runner.ts``backend/tests/mysql-migration-roundtrip.test.mjs` | M01-B DONEWSL MySQL 8.4.9 已验证 8 条旧库 fixture、11 条 up、3 条 verify、10 条 down,并验证租户隔离和旧金额整数分转换;不导入真实旧数据 |
| 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 后接入 |
@@ -35,6 +35,6 @@
## 下一步 ## 下一步
1. 继续 M08-B:在已完成保洁任务底表、权限、任务大厅、我的任务、接单、开始、照片上传、提交验收、驳回补做、订单结束自动建任务、统计、管理侧查询/指派/验收/驳回、超时回收、协作者查看/添加/移除、待结算候选、正式结算单生成/确认、微信转账预检、主动同步、通知回调后台起步管理界面基础上,补结算单详情、协作者管理和更完整保洁运营管理界面。 1. 继续 M08-B:在已完成保洁任务底表、权限、任务大厅、我的任务、接单、开始、照片上传、提交验收、驳回补做、订单结束自动建任务、统计、管理侧查询/指派/验收/驳回、超时回收、协作者查看/添加/移除、待结算候选、正式结算单生成/确认、微信转账预检、主动同步、通知回调后台协作者管理和结算详情界面基础上,补统计明细、结算口径校验和更完整保洁运营管理界面。
2. 继续复用 M03 门店发现、M04 订单、M05 支付、M06 设备联动和 M07 会员营销后端能力,不重复初始化小程序。 2. 继续复用 M03 门店发现、M04 订单、M05 支付、M06 设备联动和 M07 会员营销后端能力,不重复初始化小程序。
3. M06-G 真实硬件联调仍等待生产 MQTT 账号/ACL、DeviceID 和现场实物配线。 3. M06-G 真实硬件联调仍等待生产 MQTT 账号/ACL、DeviceID 和现场实物配线。
+14 -2
View File
@@ -81,6 +81,13 @@
- 后台结算面板新增“预检”操作和结果弹窗,展示真实转账前置条件是否满足,帮助现场联调前明确缺失项。 - 后台结算面板新增“预检”操作和结果弹窗,展示真实转账前置条件是否满足,帮助现场联调前明确缺失项。
- `scripts/check-admin-m08-b.mjs` 扩展检查微信转账预检 API 封装和后台控件。 - `scripts/check-admin-m08-b.mjs` 扩展检查微信转账预检 API 封装和后台控件。
## 2026-06-30 后台协作与结算详情增量
- 新增 `GET /admin-api/cleaning/settlements/:settlementId`,按租户和门店数据范围返回结算单主信息与固化结算明细,包含任务号、订单号、门店、房间、保洁员、成员分账金额和完成时间。
- 后台任务面板新增“成员”入口,可查看负责人/协作者、分账金额和结算状态,并支持添加/更新协作者与移除未结算协作者。
- 后台结算面板新增“详情”入口,可查看结算单任务明细、转账状态、外部流水、失败原因和备注,避免管理员只能从列表猜测结算口径。
- `scripts/check-admin-m08-b.mjs` 扩展检查协作者管理、结算详情和对应 API 封装。
## 验收结果 ## 验收结果
- `npm run build``backend/`):PASS。 - `npm run build``backend/`):PASS。
@@ -125,14 +132,19 @@
- 微信转账预检增量后 `node tests/cleaning-route.test.mjs`PASS,覆盖后台预检路由。 - 微信转账预检增量后 `node tests/cleaning-route.test.mjs`PASS,覆盖后台预检路由。
- 微信转账预检增量后 `node scripts/check-admin-m08-b.mjs`PASS。 - 微信转账预检增量后 `node scripts/check-admin-m08-b.mjs`PASS。
- 微信转账预检增量后 `npm run build``admin/`):PASS。 - 微信转账预检增量后 `npm run build``admin/`):PASS。
- 后台协作与结算详情增量后 `npm run build``backend/`):PASS。
- 后台协作与结算详情增量后 `npm run build``admin/`):PASS。
- 后台协作与结算详情增量后 `node tests/cleaning-route.test.mjs`PASS,覆盖结算单详情路由。
- 后台协作与结算详情增量后 `node scripts/check-admin-m08-b.mjs`PASS。
- 后台协作与结算详情增量后 `npm test``backend/`):PASS。
## 状态 ## 状态
M08-B 保持 `PARTIAL`。当前已完成保洁端任务大厅、我的任务、接单、开始、照片上传、提交验收、驳回补做、订单结束自动建任务、统计、管理员任务查询、指派、验收、驳回、超时回收、多人协作分账、待结算候选、正式结算单生成、确认、发放成功标记、发放失败记录、微信商家转账 API 适配、转账中间态落库、主动轮询同步、微信转账通知回调、微信转账预检、非生产 Mock 门禁和保洁管理后台起步界面;真实商户现场联调和更完整的后台/小程序管理界面仍待继续。M08-A 的真机合法域名、真实微信支付和实物开门验证按外部条件阻塞保留,不阻断 M08-B 工程推进。 M08-B 保持 `PARTIAL`。当前已完成保洁端任务大厅、我的任务、接单、开始、照片上传、提交验收、驳回补做、订单结束自动建任务、统计、管理员任务查询、指派、验收、驳回、超时回收、多人协作分账、待结算候选、正式结算单生成、确认、发放成功标记、发放失败记录、微信商家转账 API 适配、转账中间态落库、主动轮询同步、微信转账通知回调、微信转账预检、非生产 Mock 门禁、后台任务成员协作管理和结算单详情界面;真实商户现场联调和更完整的后台/小程序管理界面仍待继续。M08-A 的真机合法域名、真实微信支付和实物开门验证按外部条件阻塞保留,不阻断 M08-B 工程推进。
## 提交 ## 提交
- 工程提交:`M08-B微信转账预检增量` - 工程提交:`M08-B后台协作与结算详情增量`
- 远端校验:待本轮 push 后执行 `HEAD == origin/main` - 远端校验:待本轮 push 后执行 `HEAD == origin/main`
+4 -4
View File
@@ -17,14 +17,14 @@
| OPS-003 | 整仓发布清单 | M00-E/M10 | PARTIAL | `59d92c0` | `deploy-business.sh --dry-run` 可在 Windows/WSL 验证当前 HEAD 的 release manifest 结构;`check-release-manifest.ps1` 已接入 `test-all.ps1`;正式构建待项目生成。 | 后端/后台尚未生成,生产菜单未执行。 | M01/M09 后接入真实构建结果,并在生产菜单 2 生成真实 release manifest。 | | OPS-003 | 整仓发布清单 | M00-E/M10 | PARTIAL | `59d92c0` | `deploy-business.sh --dry-run` 可在 Windows/WSL 验证当前 HEAD 的 release manifest 结构;`check-release-manifest.ps1` 已接入 `test-all.ps1`;正式构建待项目生成。 | 后端/后台尚未生成,生产菜单未执行。 | M01/M09 后接入真实构建结果,并在生产菜单 2 生成真实 release manifest。 |
| OPS-004 | 菜单式更新与环境监测 | M00-E/M10 | PARTIAL | `292ab7f` | `setup.sh` 已接入初始化、更新、MQTT、HTTPS、状态、备份、恢复、回滚和诊断菜单;`--backup-status` 已在 WSL 验证,可检查备份工具、目录和模板。 | 未在生产 Ubuntu 执行;真实备份配置未启用。 | 生产执行后补部署记录、真实备份和恢复演练记录。 | | OPS-004 | 菜单式更新与环境监测 | M00-E/M10 | PARTIAL | `292ab7f` | `setup.sh` 已接入初始化、更新、MQTT、HTTPS、状态、备份、恢复、回滚和诊断菜单;`--backup-status` 已在 WSL 验证,可检查备份工具、目录和模板。 | 未在生产 Ubuntu 执行;真实备份配置未启用。 | 生产执行后补部署记录、真实备份和恢复演练记录。 |
| IOT-001 | MQTT Broker 生产部署 | M00/M06 | PARTIAL | `d01f771` | 菜单 3 已支持 Ubuntu 24.04 amd64 原生安装/更新与复检;正式 Topic ACL 已固定;后端实现 MQTT.js 3.1、QoS 1、持久会话、自动重连、订阅恢复、健康检查、消息上限和真实硬件烟测运行器;Windows 全量测试通过,WSL EMQX 5.8.9 服务级检查通过。 | 缺生产 EMQX 安装、正式账号、ACL/端口/备份验收;WSL 本地账号未配置,真实硬件烟测按预期 SKIP。 | 由管理员在生产菜单 3 执行并登记结果;提供生产账号、DeviceID 和实物后执行 `node dist/devices/hardware-smoke-runner.js`。 | | IOT-001 | MQTT Broker 生产部署 | M00/M06 | PARTIAL | `d01f771` | 菜单 3 已支持 Ubuntu 24.04 amd64 原生安装/更新与复检;正式 Topic ACL 已固定;后端实现 MQTT.js 3.1、QoS 1、持久会话、自动重连、订阅恢复、健康检查、消息上限和真实硬件烟测运行器;Windows 全量测试通过,WSL EMQX 5.8.9 服务级检查通过。 | 缺生产 EMQX 安装、正式账号、ACL/端口/备份验收;WSL 本地账号未配置,真实硬件烟测按预期 SKIP。 | 由管理员在生产菜单 3 执行并登记结果;提供生产账号、DeviceID 和实物后执行 `node dist/devices/hardware-smoke-runner.js`。 |
| SYS-001 | 微信原生小程序 | M00-C/M08 | PARTIAL | `M08-B微信转账预检增量` | 微信原生模板已接入顾客端登录态、选店跳转、门店详情、房间列表、报价、预占、测试支付、订单生产微信预支付调起、充值微信支付调起、余额支付、优惠券/套餐下单抵扣参数与可视化选择器、受控 Wi-Fi、订单列表、订单详情、状态历史、取消入口、订单权限开门入口、续费、同门店换房、分享口令生成、个人中心余额/权益/账单汇总、充值计划/充值单入口、优惠券/套餐明细,以及保洁端任务大厅、我的任务、接单、开始、照片上传、提交验收、驳回补做入口、统计和管理侧验收/驳回/指派/结算/多人协作/发放 API;后端已接入微信商家转账适配、转账中间态落库、主动轮询同步、微信转账通知回调、微信转账预检和非生产 Mock 门禁;后台已建立保洁运营工作台起步界面并可查看微信转账真实商户前置条件;`npm test``admin npm run build`、相关会员/权益/充值/保洁测试通过。 | M08-A 真机联调缺微信后台/商户/硬件条件;M08-B 真实商户现场联调和更完整管理界面待继续。 | 继续 M08-B,补结算单详情、协作者管理和更完整保洁运营管理界面。 | | SYS-001 | 微信原生小程序 | M00-C/M08 | PARTIAL | `M08-B后台协作与结算详情增量` | 微信原生模板已接入顾客端登录态、选店跳转、门店详情、房间列表、报价、预占、测试支付、订单生产微信预支付调起、充值微信支付调起、余额支付、优惠券/套餐下单抵扣参数与可视化选择器、受控 Wi-Fi、订单列表、订单详情、状态历史、取消入口、订单权限开门入口、续费、同门店换房、分享口令生成、个人中心余额/权益/账单汇总、充值计划/充值单入口、优惠券/套餐明细,以及保洁端任务大厅、我的任务、接单、开始、照片上传、提交验收、驳回补做入口、统计和管理侧验收/驳回/指派/结算/多人协作/发放 API;后端已接入微信商家转账适配、转账中间态落库、主动轮询同步、微信转账通知回调、微信转账预检、结算详情和非生产 Mock 门禁;后台已建立保洁运营工作台,可维护任务协作者、查看结算单详情和微信转账真实商户前置条件;`npm test``admin npm run build`、相关会员/权益/充值/保洁测试通过。 | M08-A 真机联调缺微信后台/商户/硬件条件;M08-B 真实商户现场联调、统计明细和更完整管理界面待继续。 | 继续 M08-B,补统计明细、结算口径校验和更完整保洁运营管理界面。 |
| CFG-001 | 小程序品牌自定义 | M02-A/M03/M08 | PARTIAL | `8b8fb28` | 已建立 AppID/tenant 品牌配置表和 `/app-api/bootstrap`;同一 AppID 两租户隔离读取在 MySQL 8.4.9 实测通过。 | Logo/分享图片上传与小程序实际展示属于后续阶段。 | M03/M08 接入配置管理和小程序展示。 | | CFG-001 | 小程序品牌自定义 | M02-A/M03/M08 | PARTIAL | `8b8fb28` | 已建立 AppID/tenant 品牌配置表和 `/app-api/bootstrap`;同一 AppID 两租户隔离读取在 MySQL 8.4.9 实测通过。 | Logo/分享图片上传与小程序实际展示属于后续阶段。 | M03/M08 接入配置管理和小程序展示。 |
| BKG-010 | 后台多小程序管理 | M02-A/M08/M10 | PARTIAL | `8b8fb28` | 已建立逻辑应用、租户绑定和租户配置模型;多租户 AppID 未指定租户会被拒绝。 | 尚无后台管理页面,多 AppID 真机配置未验证。 | M02-B/C 完成身份权限,M08 增加管理页面。 | | BKG-010 | 后台多小程序管理 | M02-A/M08/M10 | PARTIAL | `8b8fb28` | 已建立逻辑应用、租户绑定和租户配置模型;多租户 AppID 未指定租户会被拒绝。 | 尚无后台管理页面,多 AppID 真机配置未验证。 | M02-B/C 完成身份权限,M08 增加管理页面。 |
| AUTH-001 | 多端统一小程序身份 | M02-B/M02-C/M08 | PARTIAL | `5a300a8` | 已实现微信 code 交换适配器、租户/AppID 隔离身份、JWT、服务端会话、当前用户和注销;禁用、角色/门店范围变化及管理员重置均使旧会话立即失效。 | 缺真实 AppSecret 真机联调和小程序动态角色界面。 | M08 完成小程序角色界面与真机登录。 | | AUTH-001 | 多端统一小程序身份 | M02-B/M02-C/M08 | PARTIAL | `5a300a8` | 已实现微信 code 交换适配器、租户/AppID 隔离身份、JWT、服务端会话、当前用户和注销;禁用、角色/门店范围变化及管理员重置均使旧会话立即失效。 | 缺真实 AppSecret 真机联调和小程序动态角色界面。 | M08 完成小程序角色界面与真机登录。 |
| AUTH-002 | 多级权限 | M02-B/M02-C/M08 | PARTIAL | `5a300a8` | 已建立六类角色、权限目录、用户角色和门店范围;后台员工 API 同时执行 capability、租户和门店范围校验,越权分配被拒绝并记录审计。 | 后续业务资源接口和前端按钮/菜单仍需逐模块接入。 | M03 起所有管理接口复用 capability 与数据范围;M08 完成前端菜单按钮控制。 | | AUTH-002 | 多级权限 | M02-B/M02-C/M08 | PARTIAL | `5a300a8` | 已建立六类角色、权限目录、用户角色和门店范围;后台员工 API 同时执行 capability、租户和门店范围校验,越权分配被拒绝并记录审计。 | 后续业务资源接口和前端按钮/菜单仍需逐模块接入。 | M03 起所有管理接口复用 capability 与数据范围;M08 完成前端菜单按钮控制。 |
| CLN-001 | 保洁人员管理 | M08-B/M09 | PARTIAL | `2a4dcd0` | `CLEANER` 角色已接入 `cleaning.task.read/write``cleaning.statistics.read`,保洁接口按登录态、capability 和门店范围裁剪任务大厅。 | 员工授权管理页面和保洁员资料管理仍待管理员端/后台接入。 | 继续 M08-B/M09,补管理员指派和保洁员管理界面。 | | CLN-001 | 保洁人员管理 | M08-B/M09 | PARTIAL | `2a4dcd0` | `CLEANER` 角色已接入 `cleaning.task.read/write``cleaning.statistics.read`,保洁接口按登录态、capability 和门店范围裁剪任务大厅。 | 员工授权管理页面和保洁员资料管理仍待管理员端/后台接入。 | 继续 M08-B/M09,补管理员指派和保洁员管理界面。 |
| CLN-002 | 保洁任务大厅 | M08-B/M09 | PARTIAL | `M08-B微信转账预检增量` | 新增 `qipai_cleaning_tasks``qipai_cleaning_task_events``GET /app-api/cleaning/tasks/hall``GET /app-api/cleaning/tasks/mine`、接单、开始、照片上传、提交验收、驳回补做接口;订单 `FINISHED` 时同事务幂等创建 `WAITING` 保洁任务;管理侧已支持任务查询、指派、验收、驳回、超时回收、协作者查看/添加/移除、待结算候选、正式结算单生成/确认、发放成功标记、发放失败记录、微信转账发放、转账预检、主动同步微信通知回调;小程序 `pages/cleaner/tasks` 已调用真实 app-api 并支持相机/相册上传;后台 `admin/` 已提供任务筛选、指派、验收、驳回、超时回收和结算微信转账预检界面。 | 真实商户现场联调和更完整管理界面仍待继续。 | 继续 M08-B,补结算单详情、协作者管理和更完整保洁运营管理界面。 | | CLN-002 | 保洁任务大厅 | M08-B/M09 | PARTIAL | `M08-B后台协作与结算详情增量` | 新增 `qipai_cleaning_tasks``qipai_cleaning_task_events``GET /app-api/cleaning/tasks/hall``GET /app-api/cleaning/tasks/mine`、接单、开始、照片上传、提交验收、驳回补做接口;订单 `FINISHED` 时同事务幂等创建 `WAITING` 保洁任务;管理侧已支持任务查询、指派、验收、驳回、超时回收、协作者查看/添加/移除、待结算候选、正式结算单生成/确认、发放成功标记、发放失败记录、微信转账发放、转账预检、主动同步微信通知回调和结算单详情;小程序 `pages/cleaner/tasks` 已调用真实 app-api 并支持相机/相册上传;后台 `admin/` 已提供任务筛选、指派、验收、驳回、超时回收、任务成员协作管理、结算详情和结算微信转账预检界面。 | 真实商户现场联调、统计明细和更完整管理界面仍待继续。 | 继续 M08-B,补统计明细、结算口径校验和更完整保洁运营管理界面。 |
| CLN-003 | 保洁统计 | M08-B/M09 | PARTIAL | `M08-B微信转账预检增量` | 新增 `GET /app-api/cleaning/stats`,返回当前保洁员负责人/协作者任务状态计数和按成员分账计算的待结算金额,小程序展示已抢、进行、待验、完成和待结算金额;管理侧新增结算单、结算明细、任务成员底表和发放字段,生成结算时按成员固化奖励金额,发放结果记录渠道、流水、失败原因、微信转账状态、package 信息、主动同步结果、微信回调结果和真实转账前置条件预检结果;后台首屏汇总展示待验收、已完成、待发放和本页金额。 | 统计明细、周期筛选、驳回数、结算单反查详情、真实商户现场联调和更完整管理界面待继续。 | M08-B/M09-C 补统计明细、结算口径校验和更完整管理界面。 | | CLN-003 | 保洁统计 | M08-B/M09 | PARTIAL | `M08-B后台协作与结算详情增量` | 新增 `GET /app-api/cleaning/stats`,返回当前保洁员负责人/协作者任务状态计数和按成员分账计算的待结算金额,小程序展示已抢、进行、待验、完成和待结算金额;管理侧新增结算单、结算明细、任务成员底表和发放字段,生成结算时按成员固化奖励金额,发放结果记录渠道、流水、失败原因、微信转账状态、package 信息、主动同步结果、微信回调结果和真实转账前置条件预检结果;后台首屏汇总展示待验收、已完成、待发放和本页金额,并可反查结算单固化任务明细。 | 统计明细、周期筛选、驳回数、真实商户现场联调和更完整管理界面待继续。 | M08-B/M09-C 补统计明细、结算口径校验和更完整管理界面。 |
| STAFF-001 | 员工账号 | M02-D/M08 | PARTIAL | `5a300a8` | 已实现创建员工、分配角色/门店、禁用、备注、重置会话和审计;WSL MySQL 实测权限变化撤销活动会话。 | 后台与管理员小程序页面尚未实现。 | M08 接入管理界面。 | | STAFF-001 | 员工账号 | M02-D/M08 | PARTIAL | `5a300a8` | 已实现创建员工、分配角色/门店、禁用、备注、重置会话和审计;WSL MySQL 实测权限变化撤销活动会话。 | 后台与管理员小程序页面尚未实现。 | M08 接入管理界面。 |
| BKG-002 | 后台用户管理 | M02-D/M08 | PARTIAL | `5a300a8` | 已实现分页筛选 API、注册/登录时间、状态、角色、门店范围以及手机号/IP 脱敏。 | Vue3 后台页面尚未实现。 | M08 实现响应式用户管理页面。 | | BKG-002 | 后台用户管理 | M02-D/M08 | PARTIAL | `5a300a8` | 已实现分页筛选 API、注册/登录时间、状态、角色、门店范围以及手机号/IP 脱敏。 | Vue3 后台页面尚未实现。 | M08 实现响应式用户管理页面。 |
| 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 起业务资源继承门店范围。 |
@@ -45,7 +45,7 @@
| GRP-002 | 美团直订 | M05-C | PARTIAL | `cda640b` | 已实现 HMAC 回调、事件幂等、外部门店/房间映射、未映射人工队列、顾客认领和团购支付订单生成;映射成功订单可进入 PAID。 | 缺美团开放平台授权和真实回调协议确认。 | 取得授权后按厂商协议实现专用签名适配并联调。 | | GRP-002 | 美团直订 | M05-C | PARTIAL | `cda640b` | 已实现 HMAC 回调、事件幂等、外部门店/房间映射、未映射人工队列、顾客认领和团购支付订单生成;映射成功订单可进入 PAID。 | 缺美团开放平台授权和真实回调协议确认。 | 取得授权后按厂商协议实现专用签名适配并联调。 |
| GRP-003 | 管理员验券 | M05-C/M08 | PARTIAL | `cda640b` | 已提供后台人工验券、配置、映射和记录查询 API,门店数据范围校验生效,券码列表只返回脱敏值。 | 管理员小程序扫码界面待 M08,真实平台核销待授权。 | M08 接入扫码界面;授权后切换 API 模式。 | | GRP-003 | 管理员验券 | M05-C/M08 | PARTIAL | `cda640b` | 已提供后台人工验券、配置、映射和记录查询 API,门店数据范围校验生效,券码列表只返回脱敏值。 | 管理员小程序扫码界面待 M08,真实平台核销待授权。 | M08 接入扫码界面;授权后切换 API 模式。 |
| FRN-001 | 加盟商与自动分账 | M05-D/M10/M08 | PARTIAL | `1680d73` | 已实现租户/门店收款账户、商户号、环境凭据引用、分账授权状态、接收方哈希脱敏、比例总和校验、支付后幂等分账和明细查询;未授权不能开启,生产环境 Mock 强制关闭。 | 缺微信支付服务商和分账权限,尚未执行真实分账。 | M10/M08 补加盟流程与界面;取得权限后执行真实分账与对账。 | | FRN-001 | 加盟商与自动分账 | M05-D/M10/M08 | PARTIAL | `1680d73` | 已实现租户/门店收款账户、商户号、环境凭据引用、分账授权状态、接收方哈希脱敏、比例总和校验、支付后幂等分账和明细查询;未授权不能开启,生产环境 Mock 强制关闭。 | 缺微信支付服务商和分账权限,尚未执行真实分账。 | M10/M08 补加盟流程与界面;取得权限后执行真实分账与对账。 |
| BKG-007 | 后台微信支付配置 | M05-D/M08 | PARTIAL | `M08-B微信转账预检增量` | 已提供收款账户、分账接收方、门店/全局比例策略和授权状态 API;私钥、APIv3 Key 和接收方真实标识只使用环境引用;M08-B 新增保洁结算微信转账预检,后台可查看脱敏商户号、凭据引用、场景 ID、报备项数量、通知地址、平台证书数量和保洁员 openid 是否满足真实转账前置条件。 | 真实商户与服务商授权、微信后台场景报备和生产小额转账仍需现场联调。 | M08/M09 接入更完整支付配置页面;授权后完成生产验收。 | | BKG-007 | 后台微信支付配置 | M05-D/M08 | PARTIAL | `M08-B后台协作与结算详情增量` | 已提供收款账户、分账接收方、门店/全局比例策略和授权状态 API;私钥、APIv3 Key 和接收方真实标识只使用环境引用;M08-B 新增保洁结算微信转账预检,后台可查看脱敏商户号、凭据引用、场景 ID、报备项数量、通知地址、平台证书数量和保洁员 openid 是否满足真实转账前置条件。 | 真实商户与服务商授权、微信后台场景报备和生产小额转账仍需现场联调。 | M08/M09 接入更完整支付配置页面;授权后完成生产验收。 |
| DEV-003 | 设备管理 | M06-B/M08 | PARTIAL | `9144fa8` | 已实现 DeviceID/IMEI/ICCID/型号/固件/信号/能力资产,控制箱通道、智慧插座负载、Sub-1G 父子拓扑、状态快照、开放告警和维护记录查询;插座过载、过温、过流、过压/欠压、功率和温度阈值告警已接入;门店数据范围生效。 | 尚无后台/手机页面,真实硬件在线/计量仍需 M06-G 实物更新。 | M06-F 接入订单自动联动;M08 实现响应式界面。 | | DEV-003 | 设备管理 | M06-B/M08 | PARTIAL | `9144fa8` | 已实现 DeviceID/IMEI/ICCID/型号/固件/信号/能力资产,控制箱通道、智慧插座负载、Sub-1G 父子拓扑、状态快照、开放告警和维护记录查询;插座过载、过温、过流、过压/欠压、功率和温度阈值告警已接入;门店数据范围生效。 | 尚无后台/手机页面,真实硬件在线/计量仍需 M06-G 实物更新。 | M06-F 接入订单自动联动;M08 实现响应式界面。 |
| BKG-004 | 后台设备管理 | M06-B/M08 | PARTIAL | `9144fa8` | 已提供设备入库、列表、拓扑、通道绑定、Sub-1G 绑定、状态、维护和插座控制 API;同一房间控制目标唯一,控制箱与智慧插座重复负载被数据库拒绝。 | Vue 后台页面和真实设备扫码入库待 M08/M06-G。 | M06-F 接入订单自动联动;M08 实现管理页面。 | | BKG-004 | 后台设备管理 | M06-B/M08 | PARTIAL | `9144fa8` | 已提供设备入库、列表、拓扑、通道绑定、Sub-1G 绑定、状态、维护和插座控制 API;同一房间控制目标唯一,控制箱与智慧插座重复负载被数据库拒绝。 | Vue 后台页面和真实设备扫码入库待 M08/M06-G。 | M06-F 接入订单自动联动;M08 实现管理页面。 |
| IOT-006 | MQTT 消息幂等 | M01/M06-C | DONE | `e795cdb` | 上行按租户、设备、Topic 和 payload SHA-256 唯一;QoS 1 重复消息只增加 `receive_count`,不重复更新命令或设备副作用;WSL MySQL 8.4.9 实测两次 ACK 仅一条事件。 | - | M06-D/E 继续复用同一消费链路。 | | IOT-006 | MQTT 消息幂等 | M01/M06-C | DONE | `e795cdb` | 上行按租户、设备、Topic 和 payload SHA-256 唯一;QoS 1 重复消息只增加 `receive_count`,不重复更新命令或设备副作用;WSL MySQL 8.4.9 实测两次 ACK 仅一条事件。 | - | M06-D/E 继续复用同一消费链路。 |
+4 -4
View File
@@ -7,11 +7,11 @@ execution_cursor:
stage_status: PARTIAL stage_status: PARTIAL
last_completed_stage: M07-D last_completed_stage: M07-D
next_stage: M08-B next_stage: M08-B
last_engineering_commit: `M08-B微信转账预检增量` last_engineering_commit: `M08-B后台协作与结算详情增量`
last_push_verified: true last_push_verified: true
base_branch: main base_branch: main
blocked_reason: "M08-A 真机合法域名、真实微信支付和实物开门验证缺微信后台/商户/硬件现场条件;M08-B 已完成照片上传、驳回补做、订单结束自动建保洁任务、管理员查询/指派/验收/驳回、超时回收、待结算候选、正式结算单生成/确认、多保洁员协作分账策略、发放成功标记、发放失败记录、微信商家转账 API 适配、转账中间态落库、主动轮询同步、微信转账通知回调、微信转账预检、非生产 Mock 门禁和保洁管理后台起步界面,仍需继续补真实商户现场联调和更完整的管理界面。M06-G 真实硬件联调仍缺生产 MQTT 账号/ACL、DeviceID、控制箱、门锁、插座和现场配线。" blocked_reason: "M08-A 真机合法域名、真实微信支付和实物开门验证缺微信后台/商户/硬件现场条件;M08-B 已完成照片上传、驳回补做、订单结束自动建保洁任务、管理员查询/指派/验收/驳回、超时回收、待结算候选、正式结算单生成/确认、多保洁员协作分账策略、发放成功标记、发放失败记录、微信商家转账 API 适配、转账中间态落库、主动轮询同步、微信转账通知回调、微信转账预检、非生产 Mock 门禁、后台任务成员协作管理和结算单详情界面,仍需继续补真实商户现场联调、统计明细和更完整的管理界面。M06-G 真实硬件联调仍缺生产 MQTT 账号/ACL、DeviceID、控制箱、门锁、插座和现场配线。"
updated_at: 2026-06-29T00:00:00+08:00 updated_at: 2026-06-30T00:00:00+08:00
``` ```
| 模块 | 状态 | 最近提交 | 最近开发日志 | 备注 | | 模块 | 状态 | 最近提交 | 最近开发日志 | 备注 |
@@ -24,7 +24,7 @@ execution_cursor:
| M05 会员、余额、套餐、优惠券 | PARTIAL | `1680d73` | docs/devlogs/2026-06-22-M05-D-分账与收款配置.md | M05-A/B/C/D 工程闭环已完成:统一支付、微信支付退款、团购直订、门店收款账户、分账接收方、比例策略、授权门禁、幂等分账和对账查询均通过 Windows 与 WSL MySQL 回归;真实微信/团购/分账权限未提供,模块保持 PARTIAL/BLOCKED_EXTERNAL。 | | M05 会员、余额、套餐、优惠券 | PARTIAL | `1680d73` | docs/devlogs/2026-06-22-M05-D-分账与收款配置.md | M05-A/B/C/D 工程闭环已完成:统一支付、微信支付退款、团购直订、门店收款账户、分账接收方、比例策略、授权门禁、幂等分账和对账查询均通过 Windows 与 WSL MySQL 回归;真实微信/团购/分账权限未提供,模块保持 PARTIAL/BLOCKED_EXTERNAL。 |
| M06 设备、MQTT 与真实硬件联动 | PARTIAL | `d01f771` | docs/devlogs/2026-06-24-M06-G-真实硬件联调.md | M06-A 至 F 已完成 MQTT、拓扑、协议幂等、控制箱、Sub-1G 门锁、智慧插座和订单自动联动任务;M06-G 已补真实硬件烟测运行器,缺生产 MQTT 账号/ACL、DeviceID 和实物配线,保持 BLOCKED_EXTERNAL。 | | M06 设备、MQTT 与真实硬件联动 | PARTIAL | `d01f771` | docs/devlogs/2026-06-24-M06-G-真实硬件联调.md | M06-A 至 F 已完成 MQTT、拓扑、协议幂等、控制箱、Sub-1G 门锁、智慧插座和订单自动联动任务;M06-G 已补真实硬件烟测运行器,缺生产 MQTT 账号/ACL、DeviceID 和实物配线,保持 BLOCKED_EXTERNAL。 |
| 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 | `M08-B微信转账预检增量` | docs/devlogs/2026-06-25-M08-B-保洁端.md | M08-A 已完成顾客端选店、门店/房间浏览、报价、预占、测试支付、订单生产微信预支付调起、充值微信支付调起与通知入账、受控 Wi-Fi、订单列表、订单详情、状态历史、取消测算、取消订单、订单权限开门、顾客续费、同门店换房、分享口令生成、个人中心余额/权益/账单汇总、充值计划/充值单入口、优惠券/套餐明细、优惠券/套餐/余额下单抵扣工程闭环和权益可视化选择器;真机合法域名、真实微信支付和实物开门验证按 BLOCKED_EXTERNAL 旁路。M08-B 已完成保洁任务底表、权限、任务大厅、我的任务、接单、开始、照片上传、提交验收、驳回补做、订单结束自动建任务、统计、管理员查询、指派、验收、驳回、超时回收、待结算候选、正式结算单生成/确认、多保洁员协作分账策略、发放成功标记、发放失败记录、微信商家转账 API 适配、转账中间态落库、主动轮询同步、微信转账通知回调、微信转账预检、非生产 Mock 门禁和保洁管理后台起步界面;真实商户现场联调和更完整的管理界面待继续。 | | M08 微信原生小程序完整业务 | PARTIAL | `M08-B后台协作与结算详情增量` | docs/devlogs/2026-06-25-M08-B-保洁端.md | M08-A 已完成顾客端选店、门店/房间浏览、报价、预占、测试支付、订单生产微信预支付调起、充值微信支付调起与通知入账、受控 Wi-Fi、订单列表、订单详情、状态历史、取消测算、取消订单、订单权限开门、顾客续费、同门店换房、分享口令生成、个人中心余额/权益/账单汇总、充值计划/充值单入口、优惠券/套餐明细、优惠券/套餐/余额下单抵扣工程闭环和权益可视化选择器;真机合法域名、真实微信支付和实物开门验证按 BLOCKED_EXTERNAL 旁路。M08-B 已完成保洁任务底表、权限、任务大厅、我的任务、接单、开始、照片上传、提交验收、驳回补做、订单结束自动建任务、统计、管理员查询、指派、验收、驳回、超时回收、待结算候选、正式结算单生成/确认、多保洁员协作分账策略、发放成功标记、发放失败记录、微信商家转账 API 适配、转账中间态落库、主动轮询同步、微信转账通知回调、微信转账预检、非生产 Mock 门禁、后台任务成员协作管理和结算单详情界面;真实商户现场联调、统计明细和更完整的管理界面待继续。 |
| M09 后台管理端完整业务 | TODO | - | - | - | | M09 后台管理端完整业务 | TODO | - | - | - |
| M10 部署、域名、验收和运维闭环 | TODO | - | - | - | | M10 部署、域名、验收和运维闭环 | TODO | - | - | - |
+5 -1
View File
@@ -36,6 +36,7 @@ for (const route of [
'/assign', '/assign',
'/complete', '/complete',
'/reject', '/reject',
'/members',
'/cleaning/reclaim-timeouts', '/cleaning/reclaim-timeouts',
'/cleaning/settlements?', '/cleaning/settlements?',
'/cleaning/settlements', '/cleaning/settlements',
@@ -48,17 +49,20 @@ for (const route of [
} }
const tasks = read('admin/src/components/CleaningTasksPanel.vue'); const tasks = read('admin/src/components/CleaningTasksPanel.vue');
for (const pattern of ['指派', '验收', '驳回', '回收', 'photoUrls']) { for (const pattern of ['指派', '验收', '驳回', '回收', '成员', '协作者管理', 'photoUrls']) {
assert.match(tasks, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); assert.match(tasks, new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
} }
const settlements = read('admin/src/components/CleaningSettlementsPanel.vue'); const settlements = read('admin/src/components/CleaningSettlementsPanel.vue');
for (const pattern of [ for (const pattern of [
'详情',
'生成', '生成',
'确认', '确认',
'转账', '转账',
'同步', '同步',
'失败', '失败',
'结算单详情',
'getCleaningSettlementDetail',
'微信转账预检', '微信转账预检',
'preflightWechatTransfer', 'preflightWechatTransfer',
'MOCK' 'MOCK'