Compare commits

..

2 Commits

Author SHA1 Message Date
Codex 5d224693ae docs(M08-B): 记录保洁多人协作进度 2026-06-26 17:47:39 +08:00
Codex 03496d74ab feat(M08-B): 增加保洁多人协作策略 2026-06-26 17:46:00 +08:00
16 changed files with 542 additions and 41 deletions
+2 -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
- 最近工程提交:`2ec4376`,补保洁超时回收、正式结算单生成和确认 - 最近工程提交:`03496d7`,补保洁多人协作、成员分账和按成员结算
- 下一工程目标:继续 M08-B,补多保洁员协作策略、结算真实支付/发放和管理界面。 - 下一工程目标:继续 M08-B,补结算真实支付/发放和管理界面。
## 版本递进 ## 版本递进
+267 -22
View File
@@ -40,9 +40,21 @@ interface CleaningTaskRow extends RowDataPacket {
startedAt: Date | null; startedAt: Date | null;
submittedAt: Date | null; submittedAt: Date | null;
completedAt: Date | null; completedAt: Date | null;
memberCount: number;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
} }
interface CleaningTaskMemberRow extends RowDataPacket {
id: string;
taskId: string;
userId: string;
nickname: string;
memberRole: 'LEAD' | 'ASSIST';
rewardCents: number;
joinedAt: Date;
removedAt: Date | null;
settledAt: Date | null;
}
interface CountRow extends RowDataPacket { total: number } interface CountRow extends RowDataPacket { total: number }
interface StatusCountRow extends RowDataPacket { status: CleaningTaskStatus; total: number } interface StatusCountRow extends RowDataPacket { status: CleaningTaskStatus; total: number }
interface AmountRow extends RowDataPacket { amount: number | null } interface AmountRow extends RowDataPacket { amount: number | null }
@@ -99,9 +111,13 @@ export class CleaningTaskRepository {
const where = [ const where = [
't.tenant_id = ?', 't.tenant_id = ?',
't.deleted_at IS NULL', 't.deleted_at IS NULL',
't.cleaner_user_id = ?' `(t.cleaner_user_id = ? OR EXISTS (
SELECT 1 FROM qipai_cleaning_task_members mm
WHERE mm.tenant_id = t.tenant_id AND mm.task_id = t.id
AND mm.user_id = ? AND mm.removed_at IS NULL
))`
]; ];
const params: Array<string | number> = [input.tenantId, input.userId]; const params: Array<string | number> = [input.tenantId, input.userId, input.userId];
if (input.status) { if (input.status) {
where.push('t.status = ?'); where.push('t.status = ?');
params.push(input.status); params.push(input.status);
@@ -135,6 +151,7 @@ export class CleaningTaskRepository {
[input.userId, input.tenantId, input.taskId] [input.userId, input.tenantId, input.taskId]
); );
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_TASK_NOT_CLAIMABLE'); if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_TASK_NOT_CLAIMABLE');
await this.ensureLeadMember(this.pool, input.tenantId, input.taskId);
await this.recordEvent(input, input.taskId, 'WAITING', 'CLAIMED', 'CLAIM', ''); await this.recordEvent(input, input.taskId, 'WAITING', 'CLAIMED', 'CLAIM', '');
return this.getMineTask(input, input.taskId); return this.getMineTask(input, input.taskId);
} }
@@ -163,12 +180,15 @@ export class CleaningTaskRepository {
[input.cleanerUserId, input.tenantId, input.taskId] [input.cleanerUserId, input.tenantId, input.taskId]
); );
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_TASK_ASSIGN_CONFLICT'); if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_TASK_ASSIGN_CONFLICT');
await this.ensureLeadMember(this.pool, input.tenantId, input.taskId);
await this.recordEvent(input, input.taskId, 'WAITING', 'CLAIMED', 'ASSIGN', input.note ?? ''); await this.recordEvent(input, input.taskId, 'WAITING', 'CLAIMED', 'ASSIGN', input.note ?? '');
return this.getTask(input, input.taskId); return this.getTask(input, input.taskId);
} }
async complete(input: CleaningActor & { taskId: string; note?: string }) { async complete(input: CleaningActor & { taskId: string; note?: string }) {
return this.moveManaged(input, 'SUBMITTED', 'COMPLETED', 'COMPLETE', 'completed_at', input.note ?? ''); const task = await this.moveManaged(input, 'SUBMITTED', 'COMPLETED', 'COMPLETE', 'completed_at', input.note ?? '');
await this.ensureLeadMember(this.pool, input.tenantId, input.taskId);
return task;
} }
async reject(input: CleaningActor & { taskId: string; reason: string }) { async reject(input: CleaningActor & { taskId: string; reason: string }) {
@@ -185,6 +205,105 @@ export class CleaningTaskRepository {
return this.getTask(input, input.taskId); return this.getTask(input, input.taskId);
} }
async listMembers(input: CleaningActor & { taskId: string }) {
this.assertCleaner(input.access, 'read');
await this.assertStoreVisible(input, input.taskId);
return this.getMembers(input.tenantId, input.taskId);
}
async addMember(input: CleaningActor & {
taskId: string; cleanerUserId: string; rewardCents: number; note?: string;
}) {
this.assertCleaner(input.access, 'write');
await this.assertStoreVisible(input, input.taskId);
await this.assertCleanerUserForTask(input.tenantId, input.taskId, input.cleanerUserId);
return this.transaction(async (connection) => {
const [tasks] = await connection.execute<CleaningTaskRow[]>(
`SELECT t.id, t.task_no AS taskNo, t.store_id AS storeId, '' AS storeName,
t.room_id AS roomId, '' AS roomName, '' AS roomNo, t.order_id AS orderId,
NULL AS orderNo, t.status, t.cleaner_user_id AS cleanerUserId,
t.priority, t.reward_cents AS rewardCents, t.requirement,
t.photo_urls_json AS photoUrlsJson, t.reject_reason AS rejectReason,
t.claimed_at AS claimedAt, t.started_at AS startedAt,
t.submitted_at AS submittedAt, t.completed_at AS completedAt,
0 AS memberCount, t.created_at AS createdAt, t.updated_at AS updatedAt
FROM qipai_cleaning_tasks t
WHERE t.tenant_id = ? AND t.id = ? AND t.deleted_at IS NULL
FOR UPDATE`,
[input.tenantId, input.taskId]
);
const task = tasks[0];
if (!task) throw new CleaningTaskError('CLEANING_TASK_NOT_FOUND');
if (!task.cleanerUserId) throw new CleaningTaskError('CLEANING_TASK_ASSIGNEE_REQUIRED');
if (String(task.cleanerUserId) === input.cleanerUserId) {
throw new CleaningTaskError('CLEANING_MEMBER_DUPLICATE_LEAD');
}
if (!['CLAIMED', 'STARTED', 'SUBMITTED', 'COMPLETED'].includes(task.status)) {
throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT');
}
const [existingMembers] = await connection.execute<CleaningTaskMemberRow[]>(
`SELECT m.id, m.task_id AS taskId, m.user_id AS userId, '' AS nickname,
m.member_role AS memberRole, m.reward_cents AS rewardCents,
m.joined_at AS joinedAt, m.removed_at AS removedAt, m.settled_at AS settledAt
FROM qipai_cleaning_task_members m
WHERE m.tenant_id = ? AND m.task_id = ? AND m.user_id = ?
FOR UPDATE`,
[input.tenantId, input.taskId, input.cleanerUserId]
);
if (existingMembers[0]?.settledAt) throw new CleaningTaskError('CLEANING_MEMBER_SETTLED');
await this.ensureLeadMember(connection, input.tenantId, input.taskId);
await connection.execute(
`INSERT INTO qipai_cleaning_task_members
(tenant_id, task_id, user_id, member_role, reward_cents, joined_at, removed_at, settled_at)
VALUES (?, ?, ?, 'ASSIST', ?, UTC_TIMESTAMP(3), NULL, NULL)
ON DUPLICATE KEY UPDATE
member_role = 'ASSIST',
reward_cents = VALUES(reward_cents),
removed_at = NULL,
settled_at = NULL,
updated_at = UTC_TIMESTAMP(3)`,
[input.tenantId, input.taskId, input.cleanerUserId, input.rewardCents]
);
await this.syncLeadReward(connection, input.tenantId, input.taskId);
await this.recordEventWithConnection(
connection, input, input.taskId, task.status, task.status, 'ADD_MEMBER', input.note ?? ''
);
return this.getMembers(input.tenantId, input.taskId, connection);
});
}
async removeMember(input: CleaningActor & { taskId: string; cleanerUserId: string; note?: string }) {
this.assertCleaner(input.access, 'write');
await this.assertStoreVisible(input, input.taskId);
return this.transaction(async (connection) => {
const [tasks] = await connection.execute<CurrentStatusRow[]>(
`SELECT status, cleaner_user_id AS cleanerUserId
FROM qipai_cleaning_tasks
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL
FOR UPDATE`,
[input.tenantId, input.taskId]
);
const task = tasks[0];
if (!task) throw new CleaningTaskError('CLEANING_TASK_NOT_FOUND');
if (String(task.cleanerUserId) === input.cleanerUserId) {
throw new CleaningTaskError('CLEANING_MEMBER_LEAD_NOT_REMOVABLE');
}
const [result] = await connection.execute<ResultSetHeader>(
`UPDATE qipai_cleaning_task_members
SET removed_at = UTC_TIMESTAMP(3), reward_cents = 0, updated_at = UTC_TIMESTAMP(3)
WHERE tenant_id = ? AND task_id = ? AND user_id = ?
AND member_role = 'ASSIST' AND removed_at IS NULL AND settled_at IS NULL`,
[input.tenantId, input.taskId, input.cleanerUserId]
);
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_MEMBER_NOT_FOUND');
await this.syncLeadReward(connection, input.tenantId, input.taskId);
await this.recordEventWithConnection(
connection, input, input.taskId, task.status, task.status, 'REMOVE_MEMBER', input.note ?? ''
);
return this.getMembers(input.tenantId, input.taskId, connection);
});
}
async settlementCandidates(input: CleaningActor & { page: number; pageSize: number }) { async settlementCandidates(input: CleaningActor & { page: number; pageSize: number }) {
this.assertCleaner(input.access, 'read'); this.assertCleaner(input.access, 'read');
const where = [ const where = [
@@ -246,20 +365,34 @@ export class CleaningTaskRepository {
}) { }) {
this.assertSettlement(input.access, 'write'); this.assertSettlement(input.access, 'write');
return this.transaction(async (connection) => { return this.transaction(async (connection) => {
await connection.execute(
`INSERT IGNORE INTO qipai_cleaning_task_members
(tenant_id, task_id, user_id, member_role, reward_cents, joined_at)
SELECT t.tenant_id, t.id, t.cleaner_user_id, 'LEAD', t.reward_cents,
COALESCE(t.claimed_at, t.completed_at, UTC_TIMESTAMP(3))
FROM qipai_cleaning_tasks t
WHERE t.tenant_id = ? AND t.cleaner_user_id = ?
AND t.status = 'COMPLETED' AND t.deleted_at IS NULL
AND t.cleaner_user_id IS NOT NULL`,
[input.tenantId, input.cleanerUserId]
);
const scopeSql = storeScopeSql(input.access, 't.store_id'); const scopeSql = storeScopeSql(input.access, 't.store_id');
const params: Array<string | number> = [input.tenantId, input.cleanerUserId]; const params: Array<string | number> = [input.tenantId, input.cleanerUserId];
const storeFilter = input.storeId ? 'AND t.store_id = ?' : ''; const storeFilter = input.storeId ? 'AND t.store_id = ?' : '';
if (input.storeId) params.push(input.storeId); if (input.storeId) params.push(input.storeId);
const [tasks] = await connection.execute<SettlementCandidateRow[]>( const [tasks] = await connection.execute<SettlementCandidateRow[]>(
`SELECT t.id, t.store_id AS storeId, t.cleaner_user_id AS cleanerUserId, `SELECT t.id, t.store_id AS storeId, m.user_id AS cleanerUserId,
t.reward_cents AS rewardCents, t.completed_at AS completedAt m.reward_cents AS rewardCents, t.completed_at AS completedAt
FROM qipai_cleaning_tasks t FROM qipai_cleaning_tasks t
WHERE t.tenant_id = ? AND t.cleaner_user_id = ? INNER JOIN qipai_cleaning_task_members m ON m.tenant_id = t.tenant_id
AND t.status = 'COMPLETED' AND t.settled_at IS NULL AND t.deleted_at IS NULL AND m.task_id = t.id AND m.user_id = ? AND m.removed_at IS NULL
AND m.settled_at IS NULL AND m.reward_cents > 0
WHERE t.tenant_id = ?
AND t.status = 'COMPLETED' AND t.deleted_at IS NULL
${storeFilter} AND ${scopeSql} ${storeFilter} AND ${scopeSql}
ORDER BY t.completed_at ASC, t.id ASC ORDER BY t.completed_at ASC, t.id ASC
FOR UPDATE`, FOR UPDATE`,
params [input.cleanerUserId, input.tenantId, ...params.slice(2)]
); );
if (tasks.length === 0) throw new CleaningTaskError('CLEANING_SETTLEMENT_EMPTY'); if (tasks.length === 0) throw new CleaningTaskError('CLEANING_SETTLEMENT_EMPTY');
const storeIds = Array.from(new Set(tasks.map((task) => String(task.storeId)))); const storeIds = Array.from(new Set(tasks.map((task) => String(task.storeId))));
@@ -283,20 +416,32 @@ export class CleaningTaskRepository {
for (const task of tasks) { for (const task of tasks) {
await connection.execute( await connection.execute(
`INSERT INTO qipai_cleaning_settlement_items `INSERT INTO qipai_cleaning_settlement_items
(tenant_id, settlement_id, task_id, reward_cents) (tenant_id, settlement_id, task_id, cleaner_user_id, reward_cents)
VALUES (?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?)`,
[input.tenantId, settlementId, task.id, Number(task.rewardCents)] [input.tenantId, settlementId, task.id, input.cleanerUserId, Number(task.rewardCents)]
); );
} }
await connection.execute( await connection.execute(
`UPDATE qipai_cleaning_tasks `UPDATE qipai_cleaning_task_members
SET status = 'SETTLED', settled_at = UTC_TIMESTAMP(3) SET settled_at = UTC_TIMESTAMP(3)
WHERE tenant_id = ? AND id IN (${tasks.map(() => '?').join(',')})`, WHERE tenant_id = ? AND user_id = ? AND task_id IN (${tasks.map(() => '?').join(',')})`,
[input.tenantId, input.cleanerUserId, ...tasks.map((task) => task.id)]
);
await connection.execute(
`UPDATE qipai_cleaning_tasks t
SET t.status = 'SETTLED', t.settled_at = UTC_TIMESTAMP(3)
WHERE t.tenant_id = ? AND t.id IN (${tasks.map(() => '?').join(',')})
AND NOT EXISTS (
SELECT 1 FROM qipai_cleaning_task_members pending
WHERE pending.tenant_id = t.tenant_id AND pending.task_id = t.id
AND pending.removed_at IS NULL AND pending.reward_cents > 0
AND pending.settled_at IS NULL
)`,
[input.tenantId, ...tasks.map((task) => task.id)] [input.tenantId, ...tasks.map((task) => task.id)]
); );
for (const task of tasks) { for (const task of tasks) {
await this.recordEventWithConnection( await this.recordEventWithConnection(
connection, input, String(task.id), 'COMPLETED', 'SETTLED', 'SETTLE', settlementNo connection, input, String(task.id), 'COMPLETED', 'COMPLETED', 'SETTLE_MEMBER', settlementNo
); );
} }
return this.getSettlement(connection, input.tenantId, settlementId); return this.getSettlement(connection, input.tenantId, settlementId);
@@ -422,16 +567,23 @@ export class CleaningTaskRepository {
this.assertCleaner(input.access, 'read'); this.assertCleaner(input.access, 'read');
const [counts] = await this.pool.execute<StatusCountRow[]>( const [counts] = await this.pool.execute<StatusCountRow[]>(
`SELECT status, COUNT(*) AS total `SELECT status, COUNT(*) AS total
FROM qipai_cleaning_tasks FROM qipai_cleaning_tasks t
WHERE tenant_id = ? AND cleaner_user_id = ? AND deleted_at IS NULL WHERE tenant_id = ? AND deleted_at IS NULL
AND (cleaner_user_id = ? OR EXISTS (
SELECT 1 FROM qipai_cleaning_task_members m
WHERE m.tenant_id = t.tenant_id AND m.task_id = t.id
AND m.user_id = ? AND m.removed_at IS NULL
))
GROUP BY status`, GROUP BY status`,
[input.tenantId, input.userId] [input.tenantId, input.userId, input.userId]
); );
const [amountRows] = await this.pool.execute<AmountRow[]>( const [amountRows] = await this.pool.execute<AmountRow[]>(
`SELECT COALESCE(SUM(reward_cents), 0) AS amount `SELECT COALESCE(SUM(m.reward_cents), 0) AS amount
FROM qipai_cleaning_tasks FROM qipai_cleaning_task_members m
WHERE tenant_id = ? AND cleaner_user_id = ? AND status IN ('SUBMITTED', 'COMPLETED') INNER JOIN qipai_cleaning_tasks t ON t.tenant_id = m.tenant_id AND t.id = m.task_id
AND settled_at IS NULL AND deleted_at IS NULL`, WHERE m.tenant_id = ? AND m.user_id = ? AND m.removed_at IS NULL
AND m.settled_at IS NULL AND m.reward_cents > 0
AND t.status IN ('SUBMITTED', 'COMPLETED') AND t.deleted_at IS NULL`,
[input.tenantId, input.userId] [input.tenantId, input.userId]
); );
return { return {
@@ -461,6 +613,9 @@ export class CleaningTaskRepository {
t.requirement, t.photo_urls_json AS photoUrlsJson, t.reject_reason AS rejectReason, t.requirement, t.photo_urls_json AS photoUrlsJson, t.reject_reason AS rejectReason,
t.claimed_at AS claimedAt, t.started_at AS startedAt, t.claimed_at AS claimedAt, t.started_at AS startedAt,
t.submitted_at AS submittedAt, t.completed_at AS completedAt, t.submitted_at AS submittedAt, t.completed_at AS completedAt,
(SELECT COUNT(*) FROM qipai_cleaning_task_members m
WHERE m.tenant_id = t.tenant_id AND m.task_id = t.id
AND m.removed_at IS NULL) AS memberCount,
t.created_at AS createdAt, t.updated_at AS updatedAt t.created_at AS createdAt, t.updated_at AS updatedAt
FROM qipai_cleaning_tasks t FROM qipai_cleaning_tasks t
INNER JOIN qipai_stores s ON s.tenant_id = t.tenant_id AND s.id = t.store_id INNER JOIN qipai_stores s ON s.tenant_id = t.tenant_id AND s.id = t.store_id
@@ -542,6 +697,81 @@ export class CleaningTaskRepository {
return this.getMineTask(input, input.taskId); return this.getMineTask(input, input.taskId);
} }
private async getMembers(
tenantId: string,
taskId: string,
connection: Pick<MySqlPool, 'execute'> | PoolConnection = this.pool
) {
const [rows] = await connection.execute<CleaningTaskMemberRow[]>(
`SELECT m.id, m.task_id AS taskId, m.user_id AS userId,
u.nickname, m.member_role AS memberRole, m.reward_cents AS rewardCents,
m.joined_at AS joinedAt, m.removed_at AS removedAt, m.settled_at AS settledAt
FROM qipai_cleaning_task_members m
INNER JOIN qipai_users u ON u.tenant_id = m.tenant_id AND u.id = m.user_id
WHERE m.tenant_id = ? AND m.task_id = ? AND m.removed_at IS NULL
ORDER BY FIELD(m.member_role, 'LEAD', 'ASSIST'), m.joined_at ASC, m.id ASC`,
[tenantId, taskId]
);
return rows.map(publicTaskMember);
}
private async ensureLeadMember(
connection: Pick<MySqlPool, 'execute'> | PoolConnection,
tenantId: string,
taskId: string
) {
await connection.execute(
`UPDATE qipai_cleaning_task_members m
INNER JOIN qipai_cleaning_tasks t ON t.tenant_id = m.tenant_id AND t.id = m.task_id
SET m.removed_at = UTC_TIMESTAMP(3), m.reward_cents = 0, m.updated_at = UTC_TIMESTAMP(3)
WHERE m.tenant_id = ? AND m.task_id = ? AND m.member_role = 'LEAD'
AND m.user_id <> t.cleaner_user_id AND m.removed_at IS NULL AND m.settled_at IS NULL`,
[tenantId, taskId]
);
await connection.execute(
`INSERT INTO qipai_cleaning_task_members
(tenant_id, task_id, user_id, member_role, reward_cents, joined_at)
SELECT t.tenant_id, t.id, t.cleaner_user_id, 'LEAD', t.reward_cents,
COALESCE(t.claimed_at, UTC_TIMESTAMP(3))
FROM qipai_cleaning_tasks t
WHERE t.tenant_id = ? AND t.id = ? AND t.cleaner_user_id IS NOT NULL
ON DUPLICATE KEY UPDATE
member_role = 'LEAD',
removed_at = NULL,
updated_at = UTC_TIMESTAMP(3)`,
[tenantId, taskId]
);
await this.syncLeadReward(connection, tenantId, taskId);
}
private async syncLeadReward(
connection: Pick<MySqlPool, 'execute'> | PoolConnection,
tenantId: string,
taskId: string
) {
const [rows] = await connection.execute<AmountRow[]>(
`SELECT t.reward_cents - COALESCE(SUM(
CASE WHEN m.member_role = 'ASSIST' AND m.removed_at IS NULL THEN m.reward_cents ELSE 0 END
), 0) AS amount
FROM qipai_cleaning_tasks t
LEFT JOIN qipai_cleaning_task_members m ON m.tenant_id = t.tenant_id AND m.task_id = t.id
WHERE t.tenant_id = ? AND t.id = ?
GROUP BY t.reward_cents`,
[tenantId, taskId]
);
const leadReward = Number(rows[0]?.amount ?? 0);
if (leadReward < 0) throw new CleaningTaskError('CLEANING_MEMBER_REWARD_EXCEEDS_TASK');
await connection.execute(
`UPDATE qipai_cleaning_task_members m
INNER JOIN qipai_cleaning_tasks t ON t.tenant_id = m.tenant_id
AND t.id = m.task_id AND t.cleaner_user_id = m.user_id
SET m.reward_cents = ?, m.member_role = 'LEAD', m.removed_at = NULL,
m.updated_at = UTC_TIMESTAMP(3)
WHERE m.tenant_id = ? AND m.task_id = ?`,
[leadReward, tenantId, taskId]
);
}
private async assertStoreVisible(input: CleaningActor, taskId: string) { private async assertStoreVisible(input: CleaningActor, taskId: string) {
if (input.access.capabilities.includes('tenant.manage') || input.access.roles.includes('PLATFORM_ADMIN')) return; if (input.access.capabilities.includes('tenant.manage') || input.access.roles.includes('PLATFORM_ADMIN')) return;
const [rows] = await this.pool.execute<RowDataPacket[]>( const [rows] = await this.pool.execute<RowDataPacket[]>(
@@ -694,11 +924,26 @@ function publicTask(row: CleaningTaskRow) {
startedAt: row.startedAt, startedAt: row.startedAt,
submittedAt: row.submittedAt, submittedAt: row.submittedAt,
completedAt: row.completedAt, completedAt: row.completedAt,
memberCount: Number(row.memberCount ?? 0),
createdAt: row.createdAt, createdAt: row.createdAt,
updatedAt: row.updatedAt updatedAt: row.updatedAt
}; };
} }
function publicTaskMember(row: CleaningTaskMemberRow) {
return {
id: String(row.id),
taskId: String(row.taskId),
userId: String(row.userId),
nickname: row.nickname,
memberRole: row.memberRole,
rewardCents: Number(row.rewardCents),
joinedAt: row.joinedAt,
removedAt: row.removedAt,
settledAt: row.settledAt
};
}
function publicSettlement(row: SettlementRow) { function publicSettlement(row: SettlementRow) {
return { return {
id: String(row.id), id: String(row.id),
+7 -3
View File
@@ -46,7 +46,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'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' 'database/migrations/2026062626_m08b_cleaning_settlements.up.sql',
'database/migrations/2026062627_m08b_cleaning_collaboration.up.sql'
], ],
verify: [ verify: [
'database/migrations/2026061601_m01b_core_schema.verify.sql', 'database/migrations/2026061601_m01b_core_schema.verify.sql',
@@ -74,9 +75,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'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' 'database/migrations/2026062626_m08b_cleaning_settlements.verify.sql',
'database/migrations/2026062627_m08b_cleaning_collaboration.verify.sql'
], ],
down: [ down: [
'database/migrations/2026062627_m08b_cleaning_collaboration.down.sql',
'database/migrations/2026062626_m08b_cleaning_settlements.down.sql', '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',
@@ -243,7 +246,8 @@ export async function executeMigrationPlan(
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 2, 9, 5, 2, 1,
1, 7, 5, 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(
+62 -1
View File
@@ -25,6 +25,18 @@ const assignSchema = z.object({
cleanerUserId: z.string().regex(/^[1-9]\d{0,19}$/), cleanerUserId: z.string().regex(/^[1-9]\d{0,19}$/),
note: z.string().trim().max(512).optional() note: z.string().trim().max(512).optional()
}).strict(); }).strict();
const memberParamsSchema = z.object({
taskId: z.string().regex(/^[1-9]\d{0,19}$/),
cleanerUserId: z.string().regex(/^[1-9]\d{0,19}$/)
});
const memberSchema = z.object({
cleanerUserId: z.string().regex(/^[1-9]\d{0,19}$/),
rewardCents: z.coerce.number().int().min(0).max(1000000),
note: z.string().trim().max(512).optional()
}).strict();
const memberRemoveSchema = z.object({
note: z.string().trim().max(512).optional()
}).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 settlementStatusSchema = z.enum(['DRAFT', 'CONFIRMED', 'PAID', 'CANCELLED']);
@@ -50,7 +62,7 @@ const reclaimSchema = z.object({
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' | 'listMembers' | 'addMember' | 'removeMember' | 'settlementCandidates'
| 'listSettlements' | 'generateSettlement' | 'confirmSettlement' | 'reclaimTimeouts' | 'listSettlements' | 'generateSettlement' | 'confirmSettlement' | 'reclaimTimeouts'
| 'assertCanUploadPhoto' | 'stats'>; | 'assertCanUploadPhoto' | 'stats'>;
mediaStorage?: MediaStorage; mediaStorage?: MediaStorage;
@@ -171,6 +183,55 @@ export async function registerCleaningRoutes(
})); }));
}); });
app.get('/admin-api/cleaning/tasks/:taskId/members', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
if (!actor) return;
const params = paramsSchema.safeParse(request.params);
if (!params.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.listMembers({ ...actor, taskId: params.data.taskId }),
traceId: request.traceId
}));
});
app.post('/admin-api/cleaning/tasks/:taskId/members', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
const params = paramsSchema.safeParse(request.params);
const body = memberSchema.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.addMember({
...actor,
taskId: params.data.taskId,
cleanerUserId: body.data.cleanerUserId,
rewardCents: body.data.rewardCents,
note: body.data.note
}),
traceId: request.traceId
}));
});
app.post('/admin-api/cleaning/tasks/:taskId/members/:cleanerUserId/remove', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
const params = memberParamsSchema.safeParse(request.params);
const body = memberRemoveSchema.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.removeMember({
...actor,
taskId: params.data.taskId,
cleanerUserId: params.data.cleanerUserId,
note: body.data.note
}),
traceId: request.traceId
}));
});
app.post('/app-api/cleaning/tasks/:taskId/start', async (request, reply) => { app.post('/app-api/cleaning/tasks/:taskId/start', 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;
+56
View File
@@ -94,6 +94,18 @@ const app = await buildApp({
calls.push(['reject', input]); calls.push(['reject', input]);
return { ...task('REJECTED'), rejectReason: input.reason }; return { ...task('REJECTED'), rejectReason: input.reason };
}, },
async listMembers(input) {
calls.push(['listMembers', input]);
return [member('LEAD', '31', 500), member('ASSIST', '33', 100)];
},
async addMember(input) {
calls.push(['addMember', input]);
return [member('LEAD', '31', 500), member('ASSIST', input.cleanerUserId, input.rewardCents)];
},
async removeMember(input) {
calls.push(['removeMember', input]);
return [member('LEAD', '31', 600)];
},
async settlementCandidates(input) { async settlementCandidates(input) {
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 };
@@ -243,6 +255,36 @@ assert.equal(reject.statusCode, 200);
assert.equal(calls.at(-1)[0], 'reject'); assert.equal(calls.at(-1)[0], 'reject');
assert.equal(calls.at(-1)[1].reason, 'photo is unclear'); assert.equal(calls.at(-1)[1].reason, 'photo is unclear');
const members = await app.inject({
method: 'GET',
url: '/admin-api/cleaning/tasks/101/members',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(members.statusCode, 200);
assert.equal(calls.at(-1)[0], 'listMembers');
assert.equal(members.json().data.length, 2);
const addedMember = await app.inject({
method: 'POST',
url: '/admin-api/cleaning/tasks/101/members',
headers: { authorization: `Bearer ${token}` },
payload: { cleanerUserId: '33', rewardCents: 100, note: 'support' }
});
assert.equal(addedMember.statusCode, 200);
assert.equal(calls.at(-1)[0], 'addMember');
assert.equal(calls.at(-1)[1].cleanerUserId, '33');
assert.equal(calls.at(-1)[1].rewardCents, 100);
const removedMember = await app.inject({
method: 'POST',
url: '/admin-api/cleaning/tasks/101/members/33/remove',
headers: { authorization: `Bearer ${token}` },
payload: { note: 'done' }
});
assert.equal(removedMember.statusCode, 200);
assert.equal(calls.at(-1)[0], 'removeMember');
assert.equal(calls.at(-1)[1].cleanerUserId, '33');
const settlement = await app.inject({ const settlement = await app.inject({
method: 'GET', method: 'GET',
url: '/admin-api/cleaning/settlement-candidates?page=1&pageSize=10', url: '/admin-api/cleaning/settlement-candidates?page=1&pageSize=10',
@@ -346,3 +388,17 @@ function settlementResponse(status) {
note: '' note: ''
}; };
} }
function member(memberRole, userId, rewardCents) {
return {
id: `${userId}01`,
taskId: '101',
userId,
nickname: `Cleaner ${userId}`,
memberRole,
rewardCents,
joinedAt: new Date(),
removedAt: null,
settledAt: null
};
}
+14
View File
@@ -87,6 +87,9 @@ const cleaningVerifySql = read('database/migrations/2026062525_m08b_cleaner_task
const cleaningSettlementUpSql = read('database/migrations/2026062626_m08b_cleaning_settlements.up.sql'); const cleaningSettlementUpSql = read('database/migrations/2026062626_m08b_cleaning_settlements.up.sql');
const cleaningSettlementDownSql = read('database/migrations/2026062626_m08b_cleaning_settlements.down.sql'); const cleaningSettlementDownSql = read('database/migrations/2026062626_m08b_cleaning_settlements.down.sql');
const cleaningSettlementVerifySql = read('database/migrations/2026062626_m08b_cleaning_settlements.verify.sql'); const cleaningSettlementVerifySql = read('database/migrations/2026062626_m08b_cleaning_settlements.verify.sql');
const cleaningCollaborationUpSql = read('database/migrations/2026062627_m08b_cleaning_collaboration.up.sql');
const cleaningCollaborationDownSql = read('database/migrations/2026062627_m08b_cleaning_collaboration.down.sql');
const cleaningCollaborationVerifySql = read('database/migrations/2026062627_m08b_cleaning_collaboration.verify.sql');
const coreTables = [ const coreTables = [
'qipai_schema_migrations', 'qipai_schema_migrations',
@@ -412,4 +415,15 @@ assert.match(cleaningSettlementUpSql, /uq_qipai_cleaning_settlement_item_task/);
assert.match(cleaningSettlementUpSql, /cleaning\.settlement\.read/); assert.match(cleaningSettlementUpSql, /cleaning\.settlement\.read/);
assert.match(cleaningSettlementUpSql, /cleaning\.settlement\.write/); assert.match(cleaningSettlementUpSql, /cleaning\.settlement\.write/);
assert.match(cleaningCollaborationUpSql, /CREATE TABLE IF NOT EXISTS qipai_cleaning_task_members/);
assert.match(cleaningCollaborationUpSql, /member_role VARCHAR\(16\) NOT NULL DEFAULT 'ASSIST'/);
assert.match(cleaningCollaborationUpSql, /settled_at DATETIME\(3\) NULL/);
assert.match(cleaningCollaborationUpSql, /DROP INDEX uq_qipai_cleaning_settlement_item_task/);
assert.match(cleaningCollaborationUpSql, /cleaner_user_id BIGINT UNSIGNED NOT NULL/);
assert.match(cleaningCollaborationUpSql, /uq_qipai_cleaning_settlement_item_task_user/);
assert.match(cleaningCollaborationDownSql, /DROP TABLE IF EXISTS qipai_cleaning_task_members/);
assert.match(cleaningCollaborationDownSql, /ADD UNIQUE KEY uq_qipai_cleaning_settlement_item_task/);
assert.match(cleaningCollaborationVerifySql, /'qipai_cleaning_task_members'/);
assert.match(cleaningCollaborationVerifySql, /'cleaner_user_id'/);
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
@@ -36,7 +36,8 @@ 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.file, /2026062626_m08b_cleaning_settlements\.up\.sql/);
assert.match(plan.file, /2026062627_m08b_cleaning_collaboration\.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,11 @@
DELETE FROM qipai_schema_migrations
WHERE version = '2026062627';
ALTER TABLE qipai_cleaning_settlement_items
DROP FOREIGN KEY fk_qipai_cleaning_settlement_items_cleaner,
DROP INDEX uq_qipai_cleaning_settlement_item_task_user,
DROP INDEX idx_qipai_cleaning_settlement_items_cleaner,
DROP COLUMN cleaner_user_id,
ADD UNIQUE KEY uq_qipai_cleaning_settlement_item_task (tenant_id, task_id);
DROP TABLE IF EXISTS qipai_cleaning_task_members;
@@ -0,0 +1,34 @@
CREATE TABLE IF NOT EXISTS qipai_cleaning_task_members (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
task_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
member_role VARCHAR(16) NOT NULL DEFAULT 'ASSIST',
reward_cents INT UNSIGNED NOT NULL DEFAULT 0,
joined_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
removed_at DATETIME(3) NULL,
settled_at DATETIME(3) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
CONSTRAINT fk_qipai_cleaning_task_members_tenant FOREIGN KEY (tenant_id)
REFERENCES qipai_tenants(id),
CONSTRAINT fk_qipai_cleaning_task_members_task FOREIGN KEY (task_id)
REFERENCES qipai_cleaning_tasks(id),
CONSTRAINT fk_qipai_cleaning_task_members_user FOREIGN KEY (user_id)
REFERENCES qipai_users(id),
UNIQUE KEY uq_qipai_cleaning_task_member (tenant_id, task_id, user_id),
KEY idx_qipai_cleaning_task_member_user (tenant_id, user_id, removed_at, settled_at),
KEY idx_qipai_cleaning_task_member_task (tenant_id, task_id, removed_at),
CONSTRAINT chk_qipai_cleaning_task_member_role CHECK (member_role IN ('LEAD', 'ASSIST'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
ALTER TABLE qipai_cleaning_settlement_items
DROP INDEX uq_qipai_cleaning_settlement_item_task,
ADD COLUMN cleaner_user_id BIGINT UNSIGNED NOT NULL AFTER task_id,
ADD CONSTRAINT fk_qipai_cleaning_settlement_items_cleaner FOREIGN KEY (cleaner_user_id)
REFERENCES qipai_users(id),
ADD UNIQUE KEY uq_qipai_cleaning_settlement_item_task_user (tenant_id, task_id, cleaner_user_id),
ADD KEY idx_qipai_cleaning_settlement_items_cleaner (tenant_id, cleaner_user_id);
INSERT IGNORE INTO qipai_schema_migrations (version, name)
VALUES ('2026062627', 'm08b_cleaning_collaboration');
@@ -0,0 +1,31 @@
SELECT table_name
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name = 'qipai_cleaning_task_members';
SELECT table_name, column_name
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND (
(table_name = 'qipai_cleaning_task_members'
AND column_name IN ('task_id', 'user_id', 'member_role', 'reward_cents', 'removed_at', 'settled_at'))
OR (table_name = 'qipai_cleaning_settlement_items'
AND column_name = 'cleaner_user_id')
);
SELECT table_name, index_name
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name IN ('qipai_cleaning_task_members', 'qipai_cleaning_settlement_items')
AND index_name IN (
'uq_qipai_cleaning_task_member',
'idx_qipai_cleaning_task_member_user',
'idx_qipai_cleaning_task_member_task',
'uq_qipai_cleaning_settlement_item_task_user',
'idx_qipai_cleaning_settlement_items_cleaner'
)
GROUP BY table_name, index_name;
SELECT version, name
FROM qipai_schema_migrations
WHERE version = '2026062627';
@@ -102,3 +102,21 @@
- 权限:`cleaning.task.write``tenant.manage``PLATFORM_ADMIN` - 权限:`cleaning.task.write``tenant.manage``PLATFORM_ADMIN`
- Body`note?: string` - Body`note?: string`
- 说明:将 `DRAFT` 结算单确认成 `CONFIRMED`,后续真实支付/发放仍待继续。 - 说明:将 `DRAFT` 结算单确认成 `CONFIRMED`,后续真实支付/发放仍待继续。
## 2026-06-26 多保洁员协作增量
- `GET /admin-api/cleaning/tasks/:taskId/members`
- 登录:必须。
- 权限:`cleaning.task.read``tenant.manage``PLATFORM_ADMIN`
- 说明:返回任务有效保洁成员,包含 `LEAD` 负责人和 `ASSIST` 协作者、成员分账金额与结算时间。
- `POST /admin-api/cleaning/tasks/:taskId/members`
- 登录:必须。
- 权限:`cleaning.task.write``tenant.manage``PLATFORM_ADMIN`
- Body`cleanerUserId: string``rewardCents: number``note?: string`
- 说明:添加或更新协作者分账金额;协作者必须为有效 `CLEANER` 且拥有任务门店范围;协作者金额总和不能超过任务奖励。
- `POST /admin-api/cleaning/tasks/:taskId/members/:cleanerUserId/remove`
- 登录:必须。
- 权限:`cleaning.task.write``tenant.manage``PLATFORM_ADMIN`
- Body`note?: string`
- 说明:移除未结算协作者并把剩余奖励回算到负责人;负责人不可通过该接口移除。
- `POST /admin-api/cleaning/settlements`
- 追加说明:多人协作任务按 `qipai_cleaning_task_members.reward_cents` 分别为每个保洁员生成结算明细;所有有效成员均完成结算后任务才整体转为 `SETTLED`
+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 和现场实物配线。
@@ -37,3 +37,16 @@
-`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``cleaning.settlement.write`
-`cleaning.settlement.read` 授予 `CLEANER``STORE_ADMIN``TENANT_ADMIN``PLATFORM_ADMIN`;将 `cleaning.settlement.write` 授予 `STORE_ADMIN``TENANT_ADMIN``PLATFORM_ADMIN` -`cleaning.settlement.read` 授予 `CLEANER``STORE_ADMIN``TENANT_ADMIN``PLATFORM_ADMIN`;将 `cleaning.settlement.write` 授予 `STORE_ADMIN``TENANT_ADMIN``PLATFORM_ADMIN`
## 2026-06-26 多保洁员协作增量
- 新增迁移:
- `database/migrations/2026062627_m08b_cleaning_collaboration.up.sql`
- `database/migrations/2026062627_m08b_cleaning_collaboration.down.sql`
- `database/migrations/2026062627_m08b_cleaning_collaboration.verify.sql`
- 新增 `qipai_cleaning_task_members`
- 记录任务保洁成员,`member_role` 支持 `LEAD``ASSIST`
- `reward_cents` 记录成员分账金额,`removed_at` 记录移除,`settled_at` 记录成员维度结算完成。
- 同一租户、任务、用户唯一,支持负责人和协作者查询索引。
- 调整 `qipai_cleaning_settlement_items`
- 新增 `cleaner_user_id`,结算明细从“任务唯一”升级为“任务 + 保洁员唯一”。
-`uq_qipai_cleaning_settlement_item_task` 替换为 `uq_qipai_cleaning_settlement_item_task_user`,支持同一协作任务按不同成员分别结算。
+15 -2
View File
@@ -34,6 +34,15 @@
- 结算生成会锁定当前候选任务,写入结算明细,固化任务奖励金额,并把任务转为 `SETTLED`,避免同一任务重复结算。 - 结算生成会锁定当前候选任务,写入结算明细,固化任务奖励金额,并把任务转为 `SETTLED`,避免同一任务重复结算。
- 新增 `POST /admin-api/cleaning/reclaim-timeouts`,可按分钟阈值和数量限制回收超时停留在 `CLAIMED`/`STARTED` 的任务,恢复到任务大厅。 - 新增 `POST /admin-api/cleaning/reclaim-timeouts`,可按分钟阈值和数量限制回收超时停留在 `CLAIMED`/`STARTED` 的任务,恢复到任务大厅。
## 2026-06-26 多人协作增量
- 新增 `2026062627_m08b_cleaning_collaboration` 迁移,建立 `qipai_cleaning_task_members`,支持 `LEAD` 负责人和 `ASSIST` 协作者、成员分账金额、移除和成员维度结算时间。
- 调整 `qipai_cleaning_settlement_items`,新增 `cleaner_user_id`,唯一键从“任务唯一”升级为“任务 + 保洁员唯一”,允许同一协作任务按成员分别进入不同保洁员结算单。
- `CleaningTaskRepository` 在接单、指派和验收时自动维护负责人成员;重新指派会撤掉旧未结算负责人,避免同一任务出现多个有效 `LEAD`
- 新增管理侧成员接口:查看成员、添加/更新协作者、移除未结算协作者。协作者必须拥有 `CLEANER` 角色和任务门店范围,协作者分账总额不能超过任务奖励。
- `GET /app-api/cleaning/tasks/mine``GET /app-api/cleaning/stats` 扩展为负责人/协作者视角;待结算金额按成员分账金额统计。
- 结算生成改为按成员 `reward_cents` 生成明细,单个成员结算后只标记该成员 `settled_at`;所有有效成员均结清后任务才整体转为 `SETTLED`
## 验收结果 ## 验收结果
- `npm run build``backend/`):PASS。 - `npm run build``backend/`):PASS。
@@ -49,14 +58,18 @@
- 继续增量后 `npm test -- --runInBand``backend/`):PASS,覆盖管理侧指派、验收、驳回和待结算候选路由。 - 继续增量后 `npm test -- --runInBand``backend/`):PASS,覆盖管理侧指派、验收、驳回和待结算候选路由。
- 结算增量后 `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 run build``backend/`):PASS。
- 多人协作增量后 `node tests/migration-contract.test.mjs`PASS。
- 多人协作增量后 `node tests/migration-runner.test.mjs`PASS。
- 多人协作增量后 `node tests/cleaning-route.test.mjs`:PASS,覆盖成员查看、添加和移除路由。
## 状态 ## 状态
M08-B 保持 `PARTIAL`。当前已完成保洁端任务大厅、我的任务、接单、开始、照片上传、提交验收、驳回补做、订单结束自动建任务、统计、管理员任务查询、指派、验收、驳回、超时回收、待结算候选、正式结算单生成和确认基础工程闭环;多保洁员协作策略、结算真实支付/发放和后台/小程序管理界面仍待继续。M08-A 的真机合法域名、真实微信支付和实物开门验证按外部条件阻塞保留,不阻断 M08-B 工程推进。 M08-B 保持 `PARTIAL`。当前已完成保洁端任务大厅、我的任务、接单、开始、照片上传、提交验收、驳回补做、订单结束自动建任务、统计、管理员任务查询、指派、验收、驳回、超时回收、多人协作分账、待结算候选、正式结算单生成和确认基础工程闭环;结算真实支付/发放和后台/小程序管理界面仍待继续。M08-A 的真机合法域名、真实微信支付和实物开门验证按外部条件阻塞保留,不阻断 M08-B 工程推进。
## 提交 ## 提交
- 工程提交:`2ec4376` - 工程提交:`03496d7`
- 远端校验:待本轮 push 后执行 `HEAD == origin/main` - 远端校验:待本轮 push 后执行 `HEAD == origin/main`
+3 -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 | `2ec4376` | 微信原生模板已接入顾客端登录态、选店跳转、门店详情、房间列表、报价、预占、测试支付、订单生产微信预支付调起、充值微信支付调起、余额支付、优惠券/套餐下单抵扣参数与可视化选择器、受控 Wi-Fi、订单列表、订单详情、状态历史、取消入口、订单权限开门入口、续费、同门店换房、分享口令生成、个人中心余额/权益/账单汇总、充值计划/充值单入口、优惠券/套餐明细,以及保洁端任务大厅、我的任务、接单、开始、照片上传、提交验收、驳回补做入口、统计和管理侧验收/驳回/指派/结算 API;`scripts/check-miniapp-m08-a.mjs`、相关会员/权益/充值/保洁测试通过。 | M08-A 真机联调缺微信后台/商户/硬件条件;M08-B 多保洁员协作策略、结算真实支付/发放和管理界面待继续。 | 继续 M08-B,补多保洁员协作策略、结算真实支付/发放和管理界面。 | | SYS-001 | 微信原生小程序 | M00-C/M08 | PARTIAL | `03496d7` | 微信原生模板已接入顾客端登录态、选店跳转、门店详情、房间列表、报价、预占、测试支付、订单生产微信预支付调起、充值微信支付调起、余额支付、优惠券/套餐下单抵扣参数与可视化选择器、受控 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 | `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-002 | 保洁任务大厅 | M08-B/M09 | PARTIAL | `03496d7` | 新增 `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 | `2ec4376` | 新增 `GET /app-api/cleaning/stats`,返回当前保洁员状态计数和待结算金额,小程序展示已抢、进行、待验、完成和待结算金额;管理侧新增结算单结算明细底表,生成结算时固化任务奖励金额。 | 统计明细、周期筛选、驳回数、结算单反查详情和真实发放待继续。 | M08-B/M09-C 补统计明细、结算口径校验和管理界面。 | | CLN-003 | 保洁统计 | M08-B/M09 | PARTIAL | `03496d7` | 新增 `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 起业务资源继承门店范围。 |
+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: `2ec4376` last_engineering_commit: `03496d7`
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-26T16:20:00+08:00 updated_at: 2026-06-26T17:45: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 | `2ec4376` | docs/devlogs/2026-06-25-M08-B-保洁端.md | M08-A 已完成顾客端选店、门店/房间浏览、报价、预占、测试支付、订单生产微信预支付调起、充值微信支付调起与通知入账、受控 Wi-Fi、订单列表、订单详情、状态历史、取消测算、取消订单、订单权限开门、顾客续费、同门店换房、分享口令生成、个人中心余额/权益/账单汇总、充值计划/充值单入口、优惠券/套餐明细、优惠券/套餐/余额下单抵扣工程闭环和权益可视化选择器;真机合法域名、真实微信支付和实物开门验证按 BLOCKED_EXTERNAL 旁路。M08-B 已完成保洁任务底表、权限、任务大厅、我的任务、接单、开始、照片上传、提交验收、驳回补做、订单结束自动建任务、统计、管理员查询、指派、验收、驳回、超时回收、待结算候选正式结算单生成/确认基础流程;多保洁员协作策略结算真实支付/发放和管理界面待继续。 | | M08 微信原生小程序完整业务 | PARTIAL | `03496d7` | docs/devlogs/2026-06-25-M08-B-保洁端.md | M08-A 已完成顾客端选店、门店/房间浏览、报价、预占、测试支付、订单生产微信预支付调起、充值微信支付调起与通知入账、受控 Wi-Fi、订单列表、订单详情、状态历史、取消测算、取消订单、订单权限开门、顾客续费、同门店换房、分享口令生成、个人中心余额/权益/账单汇总、充值计划/充值单入口、优惠券/套餐明细、优惠券/套餐/余额下单抵扣工程闭环和权益可视化选择器;真机合法域名、真实微信支付和实物开门验证按 BLOCKED_EXTERNAL 旁路。M08-B 已完成保洁任务底表、权限、任务大厅、我的任务、接单、开始、照片上传、提交验收、驳回补做、订单结束自动建任务、统计、管理员查询、指派、验收、驳回、超时回收、待结算候选正式结算单生成/确认多保洁员协作分账策略结算真实支付/发放和管理界面待继续。 |
| M09 后台管理端完整业务 | TODO | - | - | - | | M09 后台管理端完整业务 | TODO | - | - | - |
| M10 部署、域名、验收和运维闭环 | TODO | - | - | - | | M10 部署、域名、验收和运维闭环 | TODO | - | - | - |