feat(M08-B): 增加保洁多人协作策略

This commit is contained in:
Codex
2026-06-26 17:46:00 +08:00
parent 1dc36d716a
commit 03496d74ab
9 changed files with 484 additions and 27 deletions
+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';