Compare commits

...

2 Commits

Author SHA1 Message Date
Codex 1dc36d716a docs(M08-B): 记录保洁结算单进度 2026-06-26 15:52:07 +08:00
Codex 2ec4376b1c feat(M08-B): 增加保洁结算单和超时回收 2026-06-26 15:51:50 +08:00
16 changed files with 617 additions and 20 deletions
+3 -2
View File
@@ -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
- 最近工程提交:`59ce3ec`,补保洁管理侧查询、指派、验收、驳回和待结算候选 - 最近工程提交:`2ec4376`,补保洁超时回收、正式结算单生成和确认
- 下一工程目标:继续 M08-B,补超时回收、多保洁员模式、正式结算单生成和管理界面。 - 下一工程目标:继续 M08-B,补多保洁员协作策略、结算真实支付/发放和管理界面。
## 版本递进 ## 版本递进
@@ -140,3 +140,4 @@ WSL 已验证:EMQX `5.8.9`、MQTTX CLI `1.13.0`、EMQX 服务 `active (running
@@ -53,6 +53,30 @@ interface FinishedOrderRow extends RowDataPacket {
storeId: string; storeId: string;
roomId: string; roomId: string;
} }
interface SettlementCandidateRow extends RowDataPacket {
id: string;
storeId: string;
cleanerUserId: string;
rewardCents: number;
completedAt: Date | null;
}
interface SettlementRow extends RowDataPacket {
id: string;
settlementNo: string;
cleanerUserId: string;
cleanerName: string;
storeId: string | null;
storeName: string | null;
status: 'DRAFT' | 'CONFIRMED' | 'PAID' | 'CANCELLED';
taskCount: number;
totalRewardCents: number;
periodStart: Date | null;
periodEnd: Date | null;
confirmedAt: Date | null;
paidAt: Date | null;
note: string;
createdAt: Date;
}
export class CleaningTaskRepository { export class CleaningTaskRepository {
constructor(private readonly pool: MySqlPool) {} constructor(private readonly pool: MySqlPool) {}
@@ -173,6 +197,154 @@ export class CleaningTaskRepository {
return this.listByWhere(where, [input.tenantId], input.page, input.pageSize, 't.completed_at ASC, t.id ASC'); return this.listByWhere(where, [input.tenantId], input.page, input.pageSize, 't.completed_at ASC, t.id ASC');
} }
async listSettlements(input: CleaningActor & {
page: number; pageSize: number; status?: 'DRAFT' | 'CONFIRMED' | 'PAID' | 'CANCELLED';
}) {
this.assertSettlement(input.access, 'read');
const where = [
's.tenant_id = ?',
's.deleted_at IS NULL',
storeScopeSql(input.access, 'COALESCE(s.store_id, 0)')
];
const params: Array<string | number> = [input.tenantId];
if (input.status) {
where.push('s.status = ?');
params.push(input.status);
}
const whereSql = where.join(' AND ');
const offset = (input.page - 1) * input.pageSize;
const [counts] = await this.pool.execute<CountRow[]>(
`SELECT COUNT(*) AS total FROM qipai_cleaning_settlements s WHERE ${whereSql}`,
params
);
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.confirmed_at AS confirmedAt, s.paid_at AS paidAt,
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 ${whereSql}
ORDER BY s.created_at DESC, s.id DESC
LIMIT ? OFFSET ?`,
[...params, input.pageSize, offset]
);
return {
items: rows.map(publicSettlement),
total: Number(counts[0]?.total ?? 0),
page: input.page,
pageSize: input.pageSize
};
}
async generateSettlement(input: CleaningActor & {
cleanerUserId: string; storeId?: string; note?: string;
}) {
this.assertSettlement(input.access, 'write');
return this.transaction(async (connection) => {
const scopeSql = storeScopeSql(input.access, 't.store_id');
const params: Array<string | number> = [input.tenantId, input.cleanerUserId];
const storeFilter = input.storeId ? 'AND t.store_id = ?' : '';
if (input.storeId) params.push(input.storeId);
const [tasks] = await connection.execute<SettlementCandidateRow[]>(
`SELECT t.id, t.store_id AS storeId, t.cleaner_user_id AS cleanerUserId,
t.reward_cents AS rewardCents, t.completed_at AS completedAt
FROM qipai_cleaning_tasks t
WHERE t.tenant_id = ? AND t.cleaner_user_id = ?
AND t.status = 'COMPLETED' AND t.settled_at IS NULL AND t.deleted_at IS NULL
${storeFilter} AND ${scopeSql}
ORDER BY t.completed_at ASC, t.id ASC
FOR UPDATE`,
params
);
if (tasks.length === 0) throw new CleaningTaskError('CLEANING_SETTLEMENT_EMPTY');
const storeIds = Array.from(new Set(tasks.map((task) => String(task.storeId))));
const totalRewardCents = tasks.reduce((sum, task) => sum + Number(task.rewardCents), 0);
const periodStart = minDate(tasks.map((task) => task.completedAt));
const periodEnd = maxDate(tasks.map((task) => task.completedAt));
const settlementNo = `CLS-${Date.now()}-${input.cleanerUserId}`;
const [created] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_cleaning_settlements
(tenant_id, settlement_no, cleaner_user_id, store_id, status, task_count,
total_reward_cents, period_start, period_end, generated_by, note)
VALUES (?, ?, ?, ?, 'DRAFT', ?, ?, ?, ?, ?, ?)`,
[
input.tenantId, settlementNo, input.cleanerUserId,
storeIds.length === 1 ? storeIds[0] : null,
tasks.length, totalRewardCents, periodStart, periodEnd,
input.userId, input.note ?? ''
]
);
const settlementId = String(created.insertId);
for (const task of tasks) {
await connection.execute(
`INSERT INTO qipai_cleaning_settlement_items
(tenant_id, settlement_id, task_id, reward_cents)
VALUES (?, ?, ?, ?)`,
[input.tenantId, settlementId, task.id, Number(task.rewardCents)]
);
}
await connection.execute(
`UPDATE qipai_cleaning_tasks
SET status = 'SETTLED', settled_at = UTC_TIMESTAMP(3)
WHERE tenant_id = ? AND id IN (${tasks.map(() => '?').join(',')})`,
[input.tenantId, ...tasks.map((task) => task.id)]
);
for (const task of tasks) {
await this.recordEventWithConnection(
connection, input, String(task.id), 'COMPLETED', 'SETTLED', 'SETTLE', settlementNo
);
}
return this.getSettlement(connection, input.tenantId, settlementId);
});
}
async confirmSettlement(input: CleaningActor & { settlementId: string; note?: string }) {
this.assertSettlement(input.access, 'write');
return this.transaction(async (connection) => {
const [result] = await connection.execute<ResultSetHeader>(
`UPDATE qipai_cleaning_settlements s
SET s.status = 'CONFIRMED', s.confirmed_by = ?, s.confirmed_at = UTC_TIMESTAMP(3),
s.note = CASE WHEN ? = '' THEN s.note ELSE ? END
WHERE s.tenant_id = ? AND s.id = ? AND s.status = 'DRAFT' AND s.deleted_at IS NULL
AND ${storeScopeSql(input.access, 'COALESCE(s.store_id, 0)')}`,
[input.userId, input.note ?? '', input.note ?? '', input.tenantId, input.settlementId]
);
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_SETTLEMENT_STATUS_CONFLICT');
return this.getSettlement(connection, input.tenantId, input.settlementId);
});
}
async reclaimTimeouts(input: CleaningActor & { olderThanMinutes: number; limit: number }) {
this.assertCleaner(input.access, 'write');
const [rows] = await this.pool.execute<RowDataPacket[]>(
`SELECT id FROM qipai_cleaning_tasks t
WHERE t.tenant_id = ? AND t.status IN ('CLAIMED', 'STARTED')
AND t.updated_at < TIMESTAMPADD(MINUTE, -?, UTC_TIMESTAMP(3))
AND t.deleted_at IS NULL AND ${storeScopeSql(input.access, 't.store_id')}
ORDER BY t.updated_at ASC, t.id ASC
LIMIT ?`,
[input.tenantId, input.olderThanMinutes, input.limit]
);
const ids = rows.map((row) => String(row.id));
if (ids.length === 0) return { reclaimed: 0, taskIds: [] as string[] };
await this.pool.execute(
`UPDATE qipai_cleaning_tasks
SET status = 'WAITING', cleaner_user_id = NULL, claimed_at = NULL,
started_at = NULL, reject_reason = ''
WHERE tenant_id = ? AND id IN (${ids.map(() => '?').join(',')})`,
[input.tenantId, ...ids]
);
for (const taskId of ids) {
await this.recordEvent(input, taskId, 'CLAIMED', 'WAITING', 'TIMEOUT_RECLAIM', '');
}
return { reclaimed: ids.length, taskIds: ids };
}
async submit(input: CleaningActor & { taskId: string; photoUrls: string[]; note?: string }) { async submit(input: CleaningActor & { taskId: string; photoUrls: string[]; note?: string }) {
this.assertCleaner(input.access, 'write'); this.assertCleaner(input.access, 'write');
if (input.photoUrls.length === 0) throw new CleaningTaskError('CLEANING_PHOTO_REQUIRED'); if (input.photoUrls.length === 0) throw new CleaningTaskError('CLEANING_PHOTO_REQUIRED');
@@ -417,6 +589,24 @@ export class CleaningTaskRepository {
); );
} }
private async recordEventWithConnection(
connection: PoolConnection,
input: CleaningActor,
taskId: string,
from: CleaningTaskStatus,
to: CleaningTaskStatus,
action: string,
note: string
) {
await connection.execute(
`INSERT IGNORE INTO qipai_cleaning_task_events
(tenant_id, task_id, from_status, to_status, action, actor_id, trace_id, note, metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, JSON_OBJECT())`,
[input.tenantId, taskId, from, to, action, input.userId,
`${input.traceId}-${taskId}`.slice(0, 128), note.slice(0, 512)]
);
}
private assertCleaner(access: AccessProfile, mode: 'read' | 'write') { private assertCleaner(access: AccessProfile, mode: 'read' | 'write') {
const permission = mode === 'read' ? 'cleaning.task.read' : 'cleaning.task.write'; const permission = mode === 'read' ? 'cleaning.task.read' : 'cleaning.task.write';
if (!access.capabilities.includes(permission) if (!access.capabilities.includes(permission)
@@ -425,6 +615,53 @@ export class CleaningTaskRepository {
throw new CleaningTaskError('CLEANING_TASK_FORBIDDEN'); throw new CleaningTaskError('CLEANING_TASK_FORBIDDEN');
} }
} }
private assertSettlement(access: AccessProfile, mode: 'read' | 'write') {
const permission = mode === 'read' ? 'cleaning.settlement.read' : 'cleaning.settlement.write';
if (!access.capabilities.includes(permission)
&& !access.capabilities.includes('tenant.manage')
&& !access.roles.includes('PLATFORM_ADMIN')) {
throw new CleaningTaskError('CLEANING_SETTLEMENT_FORBIDDEN');
}
}
private async getSettlement(
connection: Pick<MySqlPool, 'execute'> | PoolConnection,
tenantId: string,
settlementId: string
) {
const [rows] = await connection.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.confirmed_at AS confirmedAt, s.paid_at AS paidAt,
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`,
[tenantId, settlementId]
);
if (!rows[0]) throw new CleaningTaskError('CLEANING_SETTLEMENT_NOT_FOUND');
return publicSettlement(rows[0]);
}
private async transaction<T>(work: (connection: PoolConnection) => Promise<T>) {
const connection = await this.pool.getConnection();
try {
await connection.beginTransaction();
const result = await work(connection);
await connection.commit();
return result;
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
}
} }
function storeScopeSql(access: AccessProfile, storeExpression: string) { function storeScopeSql(access: AccessProfile, storeExpression: string) {
@@ -462,6 +699,38 @@ function publicTask(row: CleaningTaskRow) {
}; };
} }
function publicSettlement(row: SettlementRow) {
return {
id: String(row.id),
settlementNo: row.settlementNo,
cleanerUserId: String(row.cleanerUserId),
cleanerName: row.cleanerName,
storeId: row.storeId === null ? null : String(row.storeId),
storeName: row.storeName,
status: row.status,
taskCount: Number(row.taskCount),
totalRewardCents: Number(row.totalRewardCents),
periodStart: row.periodStart,
periodEnd: row.periodEnd,
confirmedAt: row.confirmedAt,
paidAt: row.paidAt,
note: row.note,
createdAt: row.createdAt
};
}
function minDate(values: Array<Date | null>) {
const dates = values.filter((value): value is Date => value instanceof Date);
if (dates.length === 0) return null;
return new Date(Math.min(...dates.map((date) => date.getTime())));
}
function maxDate(values: Array<Date | null>) {
const dates = values.filter((value): value is Date => value instanceof Date);
if (dates.length === 0) return null;
return new Date(Math.max(...dates.map((date) => date.getTime())));
}
function parseJsonArray(value: string | string[] | null): string[] { function parseJsonArray(value: string | string[] | null): string[] {
if (Array.isArray(value)) return value.map(String); if (Array.isArray(value)) return value.map(String);
if (!value) return []; if (!value) return [];
+7 -3
View File
@@ -45,7 +45,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026062422_m07b_recharge_plans.up.sql', 'database/migrations/2026062422_m07b_recharge_plans.up.sql',
'database/migrations/2026062423_m07c_benefits.up.sql', 'database/migrations/2026062423_m07c_benefits.up.sql',
'database/migrations/2026062524_m08a_recharge_wechat.up.sql', 'database/migrations/2026062524_m08a_recharge_wechat.up.sql',
'database/migrations/2026062525_m08b_cleaner_tasks.up.sql' 'database/migrations/2026062525_m08b_cleaner_tasks.up.sql',
'database/migrations/2026062626_m08b_cleaning_settlements.up.sql'
], ],
verify: [ verify: [
'database/migrations/2026061601_m01b_core_schema.verify.sql', 'database/migrations/2026061601_m01b_core_schema.verify.sql',
@@ -72,9 +73,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026062422_m07b_recharge_plans.verify.sql', 'database/migrations/2026062422_m07b_recharge_plans.verify.sql',
'database/migrations/2026062423_m07c_benefits.verify.sql', 'database/migrations/2026062423_m07c_benefits.verify.sql',
'database/migrations/2026062524_m08a_recharge_wechat.verify.sql', 'database/migrations/2026062524_m08a_recharge_wechat.verify.sql',
'database/migrations/2026062525_m08b_cleaner_tasks.verify.sql' 'database/migrations/2026062525_m08b_cleaner_tasks.verify.sql',
'database/migrations/2026062626_m08b_cleaning_settlements.verify.sql'
], ],
down: [ down: [
'database/migrations/2026062626_m08b_cleaning_settlements.down.sql',
'database/migrations/2026062525_m08b_cleaner_tasks.down.sql', 'database/migrations/2026062525_m08b_cleaner_tasks.down.sql',
'database/migrations/2026062524_m08a_recharge_wechat.down.sql', 'database/migrations/2026062524_m08a_recharge_wechat.down.sql',
'database/migrations/2026062423_m07c_benefits.down.sql', 'database/migrations/2026062423_m07c_benefits.down.sql',
@@ -239,7 +242,8 @@ export async function executeMigrationPlan(
1, 1, 1, 4, 7, 1, 1, 1, 1, 4, 7, 1,
1, 1, 7, 7, 1, 1, 1, 7, 7, 1,
5, 10, 7, 3, 1, 5, 10, 7, 3, 1,
2, 8, 4, 3, 1 2, 8, 4, 3, 1,
2, 9, 5, 2, 1
][index] ?? 1; ][index] ?? 1;
if (!Array.isArray(result) || result.length < minimumRows) { if (!Array.isArray(result) || result.length < minimumRows) {
throw new Error( throw new Error(
+75 -1
View File
@@ -27,11 +27,31 @@ const assignSchema = z.object({
}).strict(); }).strict();
const completeSchema = z.object({ note: z.string().trim().max(512).optional() }).strict(); const completeSchema = z.object({ note: z.string().trim().max(512).optional() }).strict();
const rejectSchema = z.object({ reason: z.string().trim().min(1).max(512) }).strict(); const rejectSchema = z.object({ reason: z.string().trim().min(1).max(512) }).strict();
const settlementStatusSchema = z.enum(['DRAFT', 'CONFIRMED', 'PAID', 'CANCELLED']);
const settlementListSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(50).default(20),
status: settlementStatusSchema.optional()
});
const settlementParamsSchema = z.object({ settlementId: z.string().regex(/^[1-9]\d{0,19}$/) });
const settlementGenerateSchema = z.object({
cleanerUserId: z.string().regex(/^[1-9]\d{0,19}$/),
storeId: z.string().regex(/^[1-9]\d{0,19}$/).optional(),
note: z.string().trim().max(512).optional()
}).strict();
const settlementConfirmSchema = z.object({
note: z.string().trim().max(512).optional()
}).strict();
const reclaimSchema = z.object({
olderThanMinutes: z.coerce.number().int().min(5).max(1440).default(60),
limit: z.coerce.number().int().min(1).max(100).default(20)
});
export interface CleaningRouteOptions { 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' | 'settlementCandidates' | 'assign' | 'complete' | 'reject' | 'settlementCandidates'
| 'listSettlements' | 'generateSettlement' | 'confirmSettlement' | 'reclaimTimeouts'
| 'assertCanUploadPhoto' | 'stats'>; | 'assertCanUploadPhoto' | 'stats'>;
mediaStorage?: MediaStorage; mediaStorage?: MediaStorage;
authRepository: Pick<AuthRepository, 'validateSession'>; authRepository: Pick<AuthRepository, 'validateSession'>;
@@ -109,6 +129,18 @@ export async function registerCleaningRoutes(
})); }));
}); });
app.get('/admin-api/cleaning/settlements', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
if (!actor) return;
const query = settlementListSchema.safeParse(request.query);
if (!query.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.listSettlements({ ...actor, ...query.data }),
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;
@@ -239,6 +271,47 @@ export async function registerCleaningRoutes(
traceId: request.traceId traceId: request.traceId
})); }));
}); });
app.post('/admin-api/cleaning/reclaim-timeouts', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
const body = reclaimSchema.safeParse(request.body ?? {});
if (!body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.reclaimTimeouts({ ...actor, ...body.data }),
traceId: request.traceId
}));
});
app.post('/admin-api/cleaning/settlements', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
const body = settlementGenerateSchema.safeParse(request.body ?? {});
if (!body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => reply.status(201).send({
code: 0,
data: await options.repository.generateSettlement({ ...actor, ...body.data }),
traceId: request.traceId
}));
});
app.post('/admin-api/cleaning/settlements/:settlementId/confirm', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
const params = settlementParamsSchema.safeParse(request.params);
const body = settlementConfirmSchema.safeParse(request.body ?? {});
if (!params.success || !body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.confirmSettlement({
...actor,
settlementId: params.data.settlementId,
note: body.data.note
}),
traceId: request.traceId
}));
});
} }
async function requireActor( async function requireActor(
@@ -290,7 +363,8 @@ async function handle(reply: FastifyReply, traceId: string, work: () => Promise<
}); });
} }
if (!(error instanceof CleaningTaskError)) throw error; if (!(error instanceof CleaningTaskError)) throw error;
const statusCode = error.code === 'CLEANING_TASK_FORBIDDEN' ? 403 : 409; const statusCode = error.code === 'CLEANING_TASK_FORBIDDEN'
|| error.code === 'CLEANING_SETTLEMENT_FORBIDDEN' ? 403 : 409;
return reply.status(statusCode).send({ return reply.status(statusCode).send({
code: error.code, code: error.code,
message: 'The cleaning task request cannot be completed.', message: 'The cleaning task request cannot be completed.',
+78 -1
View File
@@ -41,7 +41,10 @@ const app = await buildApp({
return userId === '31' return userId === '31'
? { ? {
roles: ['CLEANER'], roles: ['CLEANER'],
capabilities: ['cleaning.task.read', 'cleaning.task.write', 'cleaning.statistics.read'], capabilities: [
'cleaning.task.read', 'cleaning.task.write', 'cleaning.statistics.read',
'cleaning.settlement.read', 'cleaning.settlement.write'
],
storeIds: ['11'] storeIds: ['11']
} }
: { roles: ['CUSTOMER'], capabilities: ['profile.read'], storeIds: [] }; : { roles: ['CUSTOMER'], capabilities: ['profile.read'], storeIds: [] };
@@ -95,6 +98,22 @@ const app = await buildApp({
calls.push(['settlementCandidates', input]); calls.push(['settlementCandidates', input]);
return { items: [task('COMPLETED')], total: 1, page: input.page, pageSize: input.pageSize }; return { items: [task('COMPLETED')], total: 1, page: input.page, pageSize: input.pageSize };
}, },
async listSettlements(input) {
calls.push(['listSettlements', input]);
return { items: [settlementResponse(input.status || 'DRAFT')], total: 1, page: input.page, pageSize: input.pageSize };
},
async generateSettlement(input) {
calls.push(['generateSettlement', input]);
return settlementResponse('DRAFT');
},
async confirmSettlement(input) {
calls.push(['confirmSettlement', input]);
return settlementResponse('CONFIRMED');
},
async reclaimTimeouts(input) {
calls.push(['reclaimTimeouts', input]);
return { reclaimed: 2, taskIds: ['101', '102'] };
},
async stats(input) { async stats(input) {
calls.push(['stats', input]); calls.push(['stats', input]);
return { byStatus: { SUBMITTED: 2 }, pendingSettlementCents: 1200 }; return { byStatus: { SUBMITTED: 2 }, pendingSettlementCents: 1200 };
@@ -232,6 +251,45 @@ const settlement = await app.inject({
assert.equal(settlement.statusCode, 200); assert.equal(settlement.statusCode, 200);
assert.equal(calls.at(-1)[0], 'settlementCandidates'); assert.equal(calls.at(-1)[0], 'settlementCandidates');
const settlementList = await app.inject({
method: 'GET',
url: '/admin-api/cleaning/settlements?status=DRAFT',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(settlementList.statusCode, 200);
assert.equal(calls.at(-1)[0], 'listSettlements');
assert.equal(calls.at(-1)[1].status, 'DRAFT');
const generatedSettlement = await app.inject({
method: 'POST',
url: '/admin-api/cleaning/settlements',
headers: { authorization: `Bearer ${token}` },
payload: { cleanerUserId: '31', storeId: '11', note: 'weekly settlement' }
});
assert.equal(generatedSettlement.statusCode, 201);
assert.equal(calls.at(-1)[0], 'generateSettlement');
assert.equal(calls.at(-1)[1].cleanerUserId, '31');
const confirmedSettlement = await app.inject({
method: 'POST',
url: '/admin-api/cleaning/settlements/501/confirm',
headers: { authorization: `Bearer ${token}` },
payload: { note: 'confirmed' }
});
assert.equal(confirmedSettlement.statusCode, 200);
assert.equal(calls.at(-1)[0], 'confirmSettlement');
assert.equal(calls.at(-1)[1].settlementId, '501');
const reclaimed = await app.inject({
method: 'POST',
url: '/admin-api/cleaning/reclaim-timeouts',
headers: { authorization: `Bearer ${token}` },
payload: { olderThanMinutes: 30, limit: 10 }
});
assert.equal(reclaimed.statusCode, 200);
assert.equal(calls.at(-1)[0], 'reclaimTimeouts');
assert.equal(calls.at(-1)[1].olderThanMinutes, 30);
const stats = await app.inject({ const stats = await app.inject({
method: 'GET', method: 'GET',
url: '/app-api/cleaning/stats', url: '/app-api/cleaning/stats',
@@ -269,3 +327,22 @@ function task(status) {
photoUrls: [] photoUrls: []
}; };
} }
function settlementResponse(status) {
return {
id: '501',
settlementNo: 'CLS-20260626-0001',
cleanerUserId: '31',
cleanerName: 'Cleaner',
storeId: '11',
storeName: 'Test Store',
status,
taskCount: 2,
totalRewardCents: 1200,
periodStart: new Date(),
periodEnd: new Date(),
confirmedAt: status === 'CONFIRMED' ? new Date() : null,
paidAt: null,
note: ''
};
}
+14
View File
@@ -84,6 +84,9 @@ const rechargeWechatVerifySql = read('database/migrations/2026062524_m08a_rechar
const cleaningUpSql = read('database/migrations/2026062525_m08b_cleaner_tasks.up.sql'); const cleaningUpSql = read('database/migrations/2026062525_m08b_cleaner_tasks.up.sql');
const cleaningDownSql = read('database/migrations/2026062525_m08b_cleaner_tasks.down.sql'); const cleaningDownSql = read('database/migrations/2026062525_m08b_cleaner_tasks.down.sql');
const cleaningVerifySql = read('database/migrations/2026062525_m08b_cleaner_tasks.verify.sql'); const cleaningVerifySql = read('database/migrations/2026062525_m08b_cleaner_tasks.verify.sql');
const cleaningSettlementUpSql = read('database/migrations/2026062626_m08b_cleaning_settlements.up.sql');
const cleaningSettlementDownSql = read('database/migrations/2026062626_m08b_cleaning_settlements.down.sql');
const cleaningSettlementVerifySql = read('database/migrations/2026062626_m08b_cleaning_settlements.verify.sql');
const coreTables = [ const coreTables = [
'qipai_schema_migrations', 'qipai_schema_migrations',
@@ -398,4 +401,15 @@ assert.match(cleaningUpSql, /uq_qipai_cleaning_task_order/);
assert.match(cleaningUpSql, /cleaning\.task\.write/); assert.match(cleaningUpSql, /cleaning\.task\.write/);
assert.match(cleaningUpSql, /cleaning\.statistics\.read/); assert.match(cleaningUpSql, /cleaning\.statistics\.read/);
for (const table of ['qipai_cleaning_settlements', 'qipai_cleaning_settlement_items']) {
assert.match(cleaningSettlementUpSql, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`));
assert.match(cleaningSettlementDownSql, new RegExp(`DROP TABLE IF EXISTS ${table}`));
assert.match(cleaningSettlementVerifySql, new RegExp(`'${table}'`));
}
assert.match(cleaningSettlementUpSql, /settlement_no VARCHAR\(64\) NOT NULL/);
assert.match(cleaningSettlementUpSql, /total_reward_cents INT UNSIGNED NOT NULL/);
assert.match(cleaningSettlementUpSql, /uq_qipai_cleaning_settlement_item_task/);
assert.match(cleaningSettlementUpSql, /cleaning\.settlement\.read/);
assert.match(cleaningSettlementUpSql, /cleaning\.settlement\.write/);
console.log('PASS: M01-B through M08-B migration contracts are present.'); console.log('PASS: M01-B through M08-B migration contracts are present.');
+2 -1
View File
@@ -35,7 +35,8 @@ assert.match(plan.file, /2026062220_m06c_iot_messages\.up\.sql/);
assert.match(plan.file, /2026062421_m07a_wallet_ledger\.up\.sql/); assert.match(plan.file, /2026062421_m07a_wallet_ledger\.up\.sql/);
assert.match(plan.file, /2026062422_m07b_recharge_plans\.up\.sql/); assert.match(plan.file, /2026062422_m07b_recharge_plans\.up\.sql/);
assert.match(plan.file, /2026062524_m08a_recharge_wechat\.up\.sql/); assert.match(plan.file, /2026062524_m08a_recharge_wechat\.up\.sql/);
assert.match(plan.file, /2026062525_m08b_cleaner_tasks\.up\.sql$/); assert.match(plan.file, /2026062525_m08b_cleaner_tasks\.up\.sql/);
assert.match(plan.file, /2026062626_m08b_cleaning_settlements\.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);
@@ -0,0 +1,12 @@
DELETE rp FROM qipai_role_permissions rp
INNER JOIN qipai_permissions p ON p.id = rp.permission_id
WHERE p.code IN ('cleaning.settlement.read', 'cleaning.settlement.write');
DELETE FROM qipai_permissions
WHERE code IN ('cleaning.settlement.read', 'cleaning.settlement.write');
DROP TABLE IF EXISTS qipai_cleaning_settlement_items;
DROP TABLE IF EXISTS qipai_cleaning_settlements;
DELETE FROM qipai_schema_migrations
WHERE version = '2026062626';
@@ -0,0 +1,61 @@
CREATE TABLE IF NOT EXISTS qipai_cleaning_settlements (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
settlement_no VARCHAR(64) NOT NULL,
cleaner_user_id BIGINT UNSIGNED NOT NULL,
store_id BIGINT UNSIGNED NULL,
status VARCHAR(32) NOT NULL DEFAULT 'DRAFT',
task_count INT UNSIGNED NOT NULL DEFAULT 0,
total_reward_cents INT UNSIGNED NOT NULL DEFAULT 0,
period_start DATETIME(3) NULL,
period_end DATETIME(3) NULL,
generated_by BIGINT UNSIGNED NOT NULL,
confirmed_by BIGINT UNSIGNED NULL,
confirmed_at DATETIME(3) NULL,
paid_at DATETIME(3) NULL,
note VARCHAR(512) NOT NULL DEFAULT '',
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),
deleted_at DATETIME(3) NULL,
CONSTRAINT fk_qipai_cleaning_settlements_tenant FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
CONSTRAINT fk_qipai_cleaning_settlements_cleaner FOREIGN KEY (cleaner_user_id) REFERENCES qipai_users(id),
CONSTRAINT fk_qipai_cleaning_settlements_store FOREIGN KEY (store_id) REFERENCES qipai_stores(id),
CONSTRAINT fk_qipai_cleaning_settlements_generated_by FOREIGN KEY (generated_by) REFERENCES qipai_users(id),
CONSTRAINT fk_qipai_cleaning_settlements_confirmed_by FOREIGN KEY (confirmed_by) REFERENCES qipai_users(id),
CONSTRAINT chk_qipai_cleaning_settlement_status CHECK (
status IN ('DRAFT', 'CONFIRMED', 'PAID', 'CANCELLED')
),
UNIQUE KEY uq_qipai_cleaning_settlement_no (tenant_id, settlement_no),
KEY idx_qipai_cleaning_settlement_cleaner (tenant_id, cleaner_user_id, status, created_at),
KEY idx_qipai_cleaning_settlement_store (tenant_id, store_id, status, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qipai_cleaning_settlement_items (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
settlement_id BIGINT UNSIGNED NOT NULL,
task_id BIGINT UNSIGNED NOT NULL,
reward_cents INT UNSIGNED NOT NULL DEFAULT 0,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
CONSTRAINT fk_qipai_cleaning_settlement_items_tenant FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
CONSTRAINT fk_qipai_cleaning_settlement_items_settlement FOREIGN KEY (settlement_id) REFERENCES qipai_cleaning_settlements(id),
CONSTRAINT fk_qipai_cleaning_settlement_items_task FOREIGN KEY (task_id) REFERENCES qipai_cleaning_tasks(id),
UNIQUE KEY uq_qipai_cleaning_settlement_item_task (tenant_id, task_id),
KEY idx_qipai_cleaning_settlement_items_parent (tenant_id, settlement_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT IGNORE INTO qipai_permissions (code, name, category) VALUES
('cleaning.settlement.read', '查看保洁结算', 'cleaning'),
('cleaning.settlement.write', '生成保洁结算', 'cleaning');
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 = 'CLEANER' AND p.code = 'cleaning.settlement.read')
OR (r.code IN ('STORE_ADMIN', 'TENANT_ADMIN', 'PLATFORM_ADMIN')
AND p.code IN ('cleaning.settlement.read', 'cleaning.settlement.write'))
WHERE r.status = 'ACTIVE' AND r.deleted_at IS NULL;
INSERT IGNORE INTO qipai_schema_migrations (version, name)
VALUES ('2026062626', 'm08b_cleaning_settlements');
@@ -0,0 +1,33 @@
SELECT table_name
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name IN ('qipai_cleaning_settlements', 'qipai_cleaning_settlement_items');
SELECT column_name
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'qipai_cleaning_settlements'
AND column_name IN (
'settlement_no', 'cleaner_user_id', 'status', 'task_count',
'total_reward_cents', 'period_start', 'period_end', 'confirmed_at', 'paid_at'
);
SELECT index_name
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name IN ('qipai_cleaning_settlements', 'qipai_cleaning_settlement_items')
AND index_name IN (
'uq_qipai_cleaning_settlement_no',
'idx_qipai_cleaning_settlement_cleaner',
'idx_qipai_cleaning_settlement_store',
'uq_qipai_cleaning_settlement_item_task',
'idx_qipai_cleaning_settlement_items_parent'
);
SELECT code
FROM qipai_permissions
WHERE code IN ('cleaning.settlement.read', 'cleaning.settlement.write');
SELECT version
FROM qipai_schema_migrations
WHERE version = '2026062626';
@@ -78,3 +78,27 @@
- 权限:`cleaning.task.read``tenant.manage``PLATFORM_ADMIN` - 权限:`cleaning.task.read``tenant.manage``PLATFORM_ADMIN`
- 查询:`page``pageSize` - 查询:`page``pageSize`
- 说明:返回 `COMPLETED``settled_at IS NULL` 的待结算保洁任务。 - 说明:返回 `COMPLETED``settled_at IS NULL` 的待结算保洁任务。
- `POST /admin-api/cleaning/reclaim-timeouts`
- 登录:必需。
- 权限:`cleaning.task.write``tenant.manage``PLATFORM_ADMIN`
- Body`olderThanMinutes?: number``limit?: number`
- 说明:将超时停留在 `CLAIMED``STARTED` 的任务回收到 `WAITING`,并清空保洁员领取状态。
- `GET /admin-api/cleaning/settlements`
- 登录:必需。
- 权限:`cleaning.task.read``tenant.manage``PLATFORM_ADMIN`
- 查询:`page``pageSize``status`
- 说明:查询保洁结算单,状态支持 `DRAFT``CONFIRMED``PAID``CANCELLED`
- `POST /admin-api/cleaning/settlements`
- 登录:必需。
- 权限:`cleaning.task.write``tenant.manage``PLATFORM_ADMIN`
- Body`cleanerUserId: string``storeId?: string``note?: string`
- 说明:将指定保洁员已完成未结算任务生成 `DRAFT` 结算单,任务同步转为 `SETTLED` 并写入结算明细。
- `POST /admin-api/cleaning/settlements/:settlementId/confirm`
- 登录:必需。
- 权限:`cleaning.task.write``tenant.manage``PLATFORM_ADMIN`
- Body`note?: string`
- 说明:将 `DRAFT` 结算单确认成 `CONFIRMED`,后续真实支付/发放仍待继续。
+3 -3
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 检查脚本。
## 总体结论 ## 总体结论
@@ -13,7 +13,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 和门店范围 | `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 |
| 后台管理端 | 仅有 `admin/.gitkeep` | 当前文件扫描 | M09 未开始,不能标记 DONE | | 后台管理端 | 仅有 `admin/.gitkeep` | 当前文件扫描 | 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 和现场实物配线。
@@ -5,6 +5,9 @@
- `database/migrations/2026062525_m08b_cleaner_tasks.up.sql` - `database/migrations/2026062525_m08b_cleaner_tasks.up.sql`
- `database/migrations/2026062525_m08b_cleaner_tasks.down.sql` - `database/migrations/2026062525_m08b_cleaner_tasks.down.sql`
- `database/migrations/2026062525_m08b_cleaner_tasks.verify.sql` - `database/migrations/2026062525_m08b_cleaner_tasks.verify.sql`
- `database/migrations/2026062626_m08b_cleaning_settlements.up.sql`
- `database/migrations/2026062626_m08b_cleaning_settlements.down.sql`
- `database/migrations/2026062626_m08b_cleaning_settlements.verify.sql`
## 新增表 ## 新增表
@@ -18,8 +21,19 @@
- 保洁任务状态事件表,记录 from/to 状态、动作、操作人、trace、备注和 metadata。 - 保洁任务状态事件表,记录 from/to 状态、动作、操作人、trace、备注和 metadata。
- 租户、任务、trace 唯一,便于幂等追踪。 - 租户、任务、trace 唯一,便于幂等追踪。
- `qipai_cleaning_settlements`
- 保洁结算单主表,包含结算号、保洁员、门店、状态、任务数、总奖励金额、结算周期、生成人、确认人、确认/支付时间和备注。
- 状态约束:`DRAFT``CONFIRMED``PAID``CANCELLED`
- 唯一约束:租户内结算号唯一。
- `qipai_cleaning_settlement_items`
- 保洁结算单明细表,关联结算单与任务并固化任务奖励金额。
- 唯一约束:同一租户内同一任务只能进入一个结算单。
## 权限 ## 权限
- 新增 `cleaning.task.write` - 新增 `cleaning.task.write`
- 新增 `cleaning.statistics.read` - 新增 `cleaning.statistics.read`
-`cleaning.task.read``cleaning.task.write``cleaning.statistics.read` 授予现有 `CLEANER``STORE_ADMIN``TENANT_ADMIN``PLATFORM_ADMIN` 角色。 -`cleaning.task.read``cleaning.task.write``cleaning.statistics.read` 授予现有 `CLEANER``STORE_ADMIN``TENANT_ADMIN``PLATFORM_ADMIN` 角色。
- 新增 `cleaning.settlement.read``cleaning.settlement.write`
-`cleaning.settlement.read` 授予 `CLEANER``STORE_ADMIN``TENANT_ADMIN``PLATFORM_ADMIN`;将 `cleaning.settlement.write` 授予 `STORE_ADMIN``TENANT_ADMIN``PLATFORM_ADMIN`
+13 -2
View File
@@ -26,6 +26,14 @@
- 新增管理侧驳回 `/admin-api/cleaning/tasks/:taskId/reject`,写入驳回原因并允许保洁端进入补做链路。 - 新增管理侧驳回 `/admin-api/cleaning/tasks/:taskId/reject`,写入驳回原因并允许保洁端进入补做链路。
- 新增 `/admin-api/cleaning/settlement-candidates`,返回 `COMPLETED` 且未结算的任务,作为后续结算单生成前置。 - 新增 `/admin-api/cleaning/settlement-candidates`,返回 `COMPLETED` 且未结算的任务,作为后续结算单生成前置。
## 2026-06-26 结算增量
- 新增 `2026062626_m08b_cleaning_settlements` 迁移,建立保洁结算单主表和明细表,任务明细唯一进入一个结算单。
- 新增 `cleaning.settlement.read``cleaning.settlement.write` 权限,保洁员可读本人结算相关能力,门店/租户/平台管理员可生成和确认结算。
- 新增 `GET /admin-api/cleaning/settlements``POST /admin-api/cleaning/settlements``POST /admin-api/cleaning/settlements/:id/confirm`,支持从已完成未结算任务生成 `DRAFT` 结算单并确认到 `CONFIRMED`
- 结算生成会锁定当前候选任务,写入结算明细,固化任务奖励金额,并把任务转为 `SETTLED`,避免同一任务重复结算。
- 新增 `POST /admin-api/cleaning/reclaim-timeouts`,可按分钟阈值和数量限制回收超时停留在 `CLAIMED`/`STARTED` 的任务,恢复到任务大厅。
## 验收结果 ## 验收结果
- `npm run build``backend/`):PASS。 - `npm run build``backend/`):PASS。
@@ -39,15 +47,18 @@
- `npm test -- --runInBand``backend/`):PASS。 - `npm test -- --runInBand``backend/`):PASS。
- `node scripts/check-miniapp-m08-a.mjs`PASS。 - `node scripts/check-miniapp-m08-a.mjs`PASS。
- 继续增量后 `npm test -- --runInBand``backend/`):PASS,覆盖管理侧指派、验收、驳回和待结算候选路由。 - 继续增量后 `npm test -- --runInBand``backend/`):PASS,覆盖管理侧指派、验收、驳回和待结算候选路由。
- 结算增量后 `npm test -- --runInBand``backend/`):PASS,覆盖结算迁移、结算单路由和超时回收路由。
- 结算增量后 `node scripts/check-miniapp-m08-a.mjs`PASS。
## 状态 ## 状态
M08-B 保持 `PARTIAL`。当前已完成保洁端任务大厅、我的任务、接单、开始、照片上传、提交验收、驳回补做、订单结束自动建任务、统计、管理员任务查询、指派、验收、驳回和待结算候选基础工程闭环;超时回收、多保洁员模式、正式结算单生成和后台/小程序管理界面仍待继续。M08-A 的真机合法域名、真实微信支付和实物开门验证按外部条件阻塞保留,不阻断 M08-B 工程推进。 M08-B 保持 `PARTIAL`。当前已完成保洁端任务大厅、我的任务、接单、开始、照片上传、提交验收、驳回补做、订单结束自动建任务、统计、管理员任务查询、指派、验收、驳回、超时回收、待结算候选、正式结算单生成和确认基础工程闭环;多保洁员协作策略、结算真实支付/发放和后台/小程序管理界面仍待继续。M08-A 的真机合法域名、真实微信支付和实物开门验证按外部条件阻塞保留,不阻断 M08-B 工程推进。
## 提交 ## 提交
- 工程提交:`59ce3ec` - 工程提交:`2ec4376`
- 远端校验:待本轮 push 后执行 `HEAD == origin/main` - 远端校验:待本轮 push 后执行 `HEAD == origin/main`
+4 -3
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 | `59ce3ec` | 微信原生模板已接入顾客端登录态、选店跳转、门店详情、房间列表、报价、预占、测试支付、订单生产微信预支付调起、充值微信支付调起、余额支付、优惠券/套餐下单抵扣参数与可视化选择器、受控 Wi-Fi、订单列表、订单详情、状态历史、取消入口、订单权限开门入口、续费、同门店换房、分享口令生成、个人中心余额/权益/账单汇总、充值计划/充值单入口、优惠券/套餐明细,以及保洁端任务大厅、我的任务、接单、开始、照片上传、提交验收、驳回补做入口、统计和管理侧验收/驳回/指派 API;`scripts/check-miniapp-m08-a.mjs`、相关会员/权益/充值/保洁测试通过。 | M08-A 真机联调缺微信后台/商户/硬件条件;M08-B 超时回收、多保洁员模式、正式结算单和管理界面待继续。 | 继续 M08-B,补超时回收、多保洁员模式、正式结算单生成和管理界面。 | | SYS-001 | 微信原生小程序 | M00-C/M08 | PARTIAL | `2ec4376` | 微信原生模板已接入顾客端登录态、选店跳转、门店详情、房间列表、报价、预占、测试支付、订单生产微信预支付调起、充值微信支付调起、余额支付、优惠券/套餐下单抵扣参数与可视化选择器、受控 Wi-Fi、订单列表、订单详情、状态历史、取消入口、订单权限开门入口、续费、同门店换房、分享口令生成、个人中心余额/权益/账单汇总、充值计划/充值单入口、优惠券/套餐明细,以及保洁端任务大厅、我的任务、接单、开始、照片上传、提交验收、驳回补做入口、统计和管理侧验收/驳回/指派/结算 API`scripts/check-miniapp-m08-a.mjs`、相关会员/权益/充值/保洁测试通过。 | 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 | `59ce3ec` | 新增 `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 并支持相机/相册上传。 | 超时回收、多保洁员模式、正式结算单生成和管理界面仍待继续。 | 继续 M08-B,补超时回收、多保洁员模式、正式结算单生成和管理界面。 | | CLN-002 | 保洁任务大厅 | M08-B/M09 | PARTIAL | `2ec4376` | 新增 `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 并支持相机/相册上传。 | 多保洁员协作策略、结算真实支付/发放和管理界面仍待继续。 | 继续 M08-B,补多保洁员协作策略、结算真实支付/发放和管理界面。 |
| CLN-003 | 保洁统计 | M08-B/M09 | PARTIAL | `2a4dcd0` | 新增 `GET /app-api/cleaning/stats`,返回当前保洁员状态计数和待结算金额,小程序展示已抢、进行、待验、完成和待结算金额。 | 统计明细、周期筛选、驳回数结算单反查待继续。 | M09-C 补统计明细结算口径校验。 | | CLN-003 | 保洁统计 | M08-B/M09 | PARTIAL | `2ec4376` | 新增 `GET /app-api/cleaning/stats`,返回当前保洁员状态计数和待结算金额,小程序展示已抢、进行、待验、完成和待结算金额;管理侧新增结算单和结算明细底表,生成结算时固化任务奖励金额。 | 统计明细、周期筛选、驳回数结算单反查详情和真实发放待继续。 | 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 起业务资源继承门店范围。 |
@@ -60,3 +60,4 @@
+5 -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: `59ce3ec` last_engineering_commit: `2ec4376`
last_push_verified: true last_push_verified: true
base_branch: main base_branch: main
blocked_reason: "M08-A 真机合法域名、真实微信支付和实物开门验证缺微信后台/商户/硬件现场条件;M08-B 已完成照片上传、驳回补做、订单结束自动建保洁任务、管理员查询/指派/验收/驳回和待结算候选,仍需继续补超时回收、多保洁员模式、正式结算单生成和管理界面。M06-G 真实硬件联调仍缺生产 MQTT 账号/ACL、DeviceID、控制箱、门锁、插座和现场配线。" blocked_reason: "M08-A 真机合法域名、真实微信支付和实物开门验证缺微信后台/商户/硬件现场条件;M08-B 已完成照片上传、驳回补做、订单结束自动建保洁任务、管理员查询/指派/验收/驳回、超时回收、待结算候选和正式结算单生成/确认,仍需继续补多保洁员协作策略、结算真实支付/发放和管理界面。M06-G 真实硬件联调仍缺生产 MQTT 账号/ACL、DeviceID、控制箱、门锁、插座和现场配线。"
updated_at: 2026-06-26T15:55:00+08:00 updated_at: 2026-06-26T16:20: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 | `59ce3ec` | docs/devlogs/2026-06-25-M08-B-保洁端.md | M08-A 已完成顾客端选店、门店/房间浏览、报价、预占、测试支付、订单生产微信预支付调起、充值微信支付调起与通知入账、受控 Wi-Fi、订单列表、订单详情、状态历史、取消测算、取消订单、订单权限开门、顾客续费、同门店换房、分享口令生成、个人中心余额/权益/账单汇总、充值计划/充值单入口、优惠券/套餐明细、优惠券/套餐/余额下单抵扣工程闭环和权益可视化选择器;真机合法域名、真实微信支付和实物开门验证按 BLOCKED_EXTERNAL 旁路。M08-B 已完成保洁任务底表、权限、任务大厅、我的任务、接单、开始、照片上传、提交验收、驳回补做、订单结束自动建任务、统计、管理员查询、指派、验收、驳回和待结算候选基础流程;超时回收、多保洁员模式、正式结算单生成和管理界面待继续。 | | M08 微信原生小程序完整业务 | PARTIAL | `2ec4376` | docs/devlogs/2026-06-25-M08-B-保洁端.md | M08-A 已完成顾客端选店、门店/房间浏览、报价、预占、测试支付、订单生产微信预支付调起、充值微信支付调起与通知入账、受控 Wi-Fi、订单列表、订单详情、状态历史、取消测算、取消订单、订单权限开门、顾客续费、同门店换房、分享口令生成、个人中心余额/权益/账单汇总、充值计划/充值单入口、优惠券/套餐明细、优惠券/套餐/余额下单抵扣工程闭环和权益可视化选择器;真机合法域名、真实微信支付和实物开门验证按 BLOCKED_EXTERNAL 旁路。M08-B 已完成保洁任务底表、权限、任务大厅、我的任务、接单、开始、照片上传、提交验收、驳回补做、订单结束自动建任务、统计、管理员查询、指派、验收、驳回、超时回收、待结算候选和正式结算单生成/确认基础流程;多保洁员协作策略、结算真实支付/发放和管理界面待继续。 |
| M09 后台管理端完整业务 | TODO | - | - | - | | M09 后台管理端完整业务 | TODO | - | - | - |
| M10 部署、域名、验收和运维闭环 | TODO | - | - | - | | M10 部署、域名、验收和运维闭环 | TODO | - | - | - |
@@ -32,3 +32,4 @@ execution_cursor: