fix(M09-A): 收口保洁任务事务与并发

This commit is contained in:
Codex
2026-08-10 14:48:04 +08:00
parent b35af9037f
commit 9b2f055ecd
2 changed files with 572 additions and 112 deletions
+158 -112
View File
@@ -72,6 +72,7 @@ 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 }
interface CurrentStatusRow extends RowDataPacket { status: CleaningTaskStatus; cleanerUserId: string | null } interface CurrentStatusRow extends RowDataPacket { status: CleaningTaskStatus; cleanerUserId: string | null }
interface ReclaimRow extends CurrentStatusRow { id: string }
interface FinishedOrderRow extends RowDataPacket { interface FinishedOrderRow extends RowDataPacket {
id: string; id: string;
orderNo: string; orderNo: string;
@@ -232,16 +233,28 @@ export class CleaningTaskRepository {
async claim(input: CleaningActor & { taskId: string }) { async claim(input: CleaningActor & { taskId: string }) {
this.assertCleaner(input.access, 'write'); this.assertCleaner(input.access, 'write');
await this.assertStoreVisible(input, input.taskId); await this.assertStoreVisible(input, input.taskId);
const [result] = await this.pool.execute<ResultSetHeader>( await this.transaction(async (connection) => {
`UPDATE qipai_cleaning_tasks const [rows] = await connection.execute<CurrentStatusRow[]>(
SET status = 'CLAIMED', cleaner_user_id = ?, claimed_at = UTC_TIMESTAMP(3) `SELECT status, cleaner_user_id AS cleanerUserId
WHERE tenant_id = ? AND id = ? AND status = 'WAITING' AND cleaner_user_id IS NULL FROM qipai_cleaning_tasks
AND deleted_at IS NULL`, WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL
[input.userId, input.tenantId, input.taskId] FOR UPDATE`,
); [input.tenantId, input.taskId]
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_TASK_NOT_CLAIMABLE'); );
await this.ensureLeadMember(this.pool, input.tenantId, input.taskId); if (rows[0]?.status !== 'WAITING' || rows[0].cleanerUserId) {
await this.recordEvent(input, input.taskId, 'WAITING', 'CLAIMED', 'CLAIM', ''); throw new CleaningTaskError('CLEANING_TASK_NOT_CLAIMABLE');
}
await connection.execute(
`UPDATE qipai_cleaning_tasks
SET status = 'CLAIMED', cleaner_user_id = ?, claimed_at = UTC_TIMESTAMP(3)
WHERE tenant_id = ? AND id = ?`,
[input.userId, input.tenantId, input.taskId]
);
await this.ensureLeadMember(connection, input.tenantId, input.taskId);
await this.recordEventWithConnection(
connection, input, input.taskId, 'WAITING', 'CLAIMED', 'CLAIM', ''
);
});
return this.getMineTask(input, input.taskId); return this.getMineTask(input, input.taskId);
} }
@@ -259,38 +272,58 @@ export class CleaningTaskRepository {
async assign(input: CleaningActor & { taskId: string; cleanerUserId: string; note?: string }) { async assign(input: CleaningActor & { taskId: string; cleanerUserId: string; note?: string }) {
this.assertCleaner(input.access, 'write'); this.assertCleaner(input.access, 'write');
await this.assertStoreVisible(input, input.taskId); await this.assertStoreVisible(input, input.taskId);
await this.assertCleanerUserForTask(input.tenantId, input.taskId, input.cleanerUserId); await this.transaction(async (connection) => {
const [result] = await this.pool.execute<ResultSetHeader>( const [rows] = await connection.execute<CurrentStatusRow[]>(
`UPDATE qipai_cleaning_tasks `SELECT status, cleaner_user_id AS cleanerUserId
SET status = 'CLAIMED', cleaner_user_id = ?, claimed_at = COALESCE(claimed_at, UTC_TIMESTAMP(3)), FROM qipai_cleaning_tasks
reject_reason = '' WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL
WHERE tenant_id = ? AND id = ? AND status IN ('WAITING', 'CLAIMED', 'REJECTED') FOR UPDATE`,
AND deleted_at IS NULL`, [input.tenantId, input.taskId]
[input.cleanerUserId, input.tenantId, input.taskId] );
); const current = rows[0];
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_TASK_ASSIGN_CONFLICT'); if (!current || !['WAITING', 'CLAIMED', 'REJECTED'].includes(current.status)) {
await this.ensureLeadMember(this.pool, input.tenantId, input.taskId); throw new CleaningTaskError('CLEANING_TASK_ASSIGN_CONFLICT');
await this.recordEvent(input, input.taskId, 'WAITING', 'CLAIMED', 'ASSIGN', input.note ?? ''); }
await this.assertCleanerUserForTask(
input.tenantId, input.taskId, input.cleanerUserId, connection
);
await connection.execute(
`UPDATE qipai_cleaning_tasks
SET status = 'CLAIMED', cleaner_user_id = ?, claimed_at = UTC_TIMESTAMP(3),
started_at = NULL, submitted_at = NULL, completed_at = NULL, settled_at = NULL,
cancelled_at = NULL, photo_urls_json = JSON_ARRAY(), reject_reason = ''
WHERE tenant_id = ? AND id = ?`,
[input.cleanerUserId, input.tenantId, input.taskId]
);
await this.ensureLeadMember(connection, input.tenantId, input.taskId);
await this.recordEventWithConnection(
connection, input, input.taskId, current.status, '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 }) {
const task = await this.moveManaged(input, 'SUBMITTED', 'COMPLETED', 'COMPLETE', 'completed_at', input.note ?? ''); return this.moveManaged(
await this.ensureLeadMember(this.pool, input.tenantId, input.taskId); input, 'SUBMITTED', 'COMPLETED', 'COMPLETE', 'completed_at', input.note ?? ''
return task; );
} }
async reject(input: CleaningActor & { taskId: string; reason: string }) { async reject(input: CleaningActor & { taskId: string; reason: string }) {
this.assertCleaner(input.access, 'write'); this.assertCleaner(input.access, 'write');
await this.assertStoreVisible(input, input.taskId); await this.assertStoreVisible(input, input.taskId);
const [result] = await this.pool.execute<ResultSetHeader>( await this.transaction(async (connection) => {
`UPDATE qipai_cleaning_tasks const [result] = await connection.execute<ResultSetHeader>(
SET status = 'REJECTED', reject_reason = ? `UPDATE qipai_cleaning_tasks
WHERE tenant_id = ? AND id = ? AND status = 'SUBMITTED' AND deleted_at IS NULL`, SET status = 'REJECTED', reject_reason = ?
[input.reason.slice(0, 512), input.tenantId, input.taskId] WHERE tenant_id = ? AND id = ? AND status = 'SUBMITTED' AND deleted_at IS NULL`,
); [input.reason.slice(0, 512), input.tenantId, input.taskId]
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT'); );
await this.recordEvent(input, input.taskId, 'SUBMITTED', 'REJECTED', 'REJECT', input.reason); if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT');
await this.recordEventWithConnection(
connection, input, input.taskId, 'SUBMITTED', 'REJECTED', 'REJECT', input.reason
);
});
return this.getTask(input, input.taskId); return this.getTask(input, input.taskId);
} }
@@ -753,54 +786,69 @@ export class CleaningTaskRepository {
async reclaimTimeouts(input: CleaningActor & { olderThanMinutes: number; limit: number }) { async reclaimTimeouts(input: CleaningActor & { olderThanMinutes: number; limit: number }) {
this.assertCleaner(input.access, 'write'); this.assertCleaner(input.access, 'write');
const [rows] = await this.pool.execute<RowDataPacket[]>( const safeLimit = Math.max(1, Math.min(100, Math.trunc(input.limit)));
`SELECT id FROM qipai_cleaning_tasks t return this.transaction(async (connection) => {
WHERE t.tenant_id = ? AND t.status IN ('CLAIMED', 'STARTED') const [rows] = await connection.execute<ReclaimRow[]>(
AND t.updated_at < TIMESTAMPADD(MINUTE, -?, UTC_TIMESTAMP(3)) `SELECT t.id, t.status, t.cleaner_user_id AS cleanerUserId
AND t.deleted_at IS NULL AND ${storeScopeSql(input.access, 't.store_id')} FROM qipai_cleaning_tasks t
ORDER BY t.updated_at ASC, t.id ASC WHERE t.tenant_id = ? AND t.status IN ('CLAIMED', 'STARTED')
LIMIT ?`, AND t.updated_at < TIMESTAMPADD(MINUTE, -?, UTC_TIMESTAMP(3))
[input.tenantId, input.olderThanMinutes, input.limit] AND t.deleted_at IS NULL AND ${storeScopeSql(input.access, 't.store_id')}
); ORDER BY t.updated_at ASC, t.id ASC
const ids = rows.map((row) => String(row.id)); LIMIT ${safeLimit} FOR UPDATE SKIP LOCKED`,
if (ids.length === 0) return { reclaimed: 0, taskIds: [] as string[] }; [input.tenantId, input.olderThanMinutes]
await this.pool.execute( );
`UPDATE qipai_cleaning_tasks for (const row of rows) {
SET status = 'WAITING', cleaner_user_id = NULL, claimed_at = NULL, const taskId = String(row.id);
started_at = NULL, reject_reason = '' await connection.execute(
WHERE tenant_id = ? AND id IN (${ids.map(() => '?').join(',')})`, `UPDATE qipai_cleaning_tasks
[input.tenantId, ...ids] SET status = 'WAITING', cleaner_user_id = NULL, claimed_at = NULL,
); started_at = NULL, submitted_at = NULL, completed_at = NULL,
for (const taskId of ids) { settled_at = NULL, cancelled_at = NULL, photo_urls_json = JSON_ARRAY(),
await this.recordEvent(input, taskId, 'CLAIMED', 'WAITING', 'TIMEOUT_RECLAIM', ''); reject_reason = ''
} WHERE tenant_id = ? AND id = ? AND status = ?`,
return { reclaimed: ids.length, taskIds: ids }; [input.tenantId, taskId, row.status]
);
await connection.execute(
`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 removed_at IS NULL AND settled_at IS NULL`,
[input.tenantId, taskId]
);
await this.recordEventWithConnection(
connection, input, taskId, row.status, 'WAITING', 'TIMEOUT_RECLAIM', ''
);
}
return { reclaimed: rows.length, taskIds: rows.map((row) => String(row.id)) };
});
} }
async submit(input: CleaningActor & { taskId: string; photoUrls: string[]; note?: string }) { async submit(input: CleaningActor & { taskId: string; photoUrls: string[]; note?: string }) {
this.assertCleaner(input.access, 'write'); this.assertCleaner(input.access, 'write');
if (input.photoUrls.length === 0) throw new CleaningTaskError('CLEANING_PHOTO_REQUIRED'); if (input.photoUrls.length === 0) throw new CleaningTaskError('CLEANING_PHOTO_REQUIRED');
const [rows] = await this.pool.execute<CurrentStatusRow[]>( await this.transaction(async (connection) => {
`SELECT status, cleaner_user_id AS cleanerUserId const [rows] = await connection.execute<CurrentStatusRow[]>(
FROM qipai_cleaning_tasks `SELECT status, cleaner_user_id AS cleanerUserId
WHERE tenant_id = ? AND id = ? AND cleaner_user_id = ? AND deleted_at IS NULL`, FROM qipai_cleaning_tasks
[input.tenantId, input.taskId, input.userId] WHERE tenant_id = ? AND id = ? AND cleaner_user_id = ? AND deleted_at IS NULL
); FOR UPDATE`,
const current = rows[0]; [input.tenantId, input.taskId, input.userId]
if (!current || !['STARTED', 'REJECTED'].includes(current.status)) { );
throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT'); const current = rows[0];
} if (!current || !['STARTED', 'REJECTED'].includes(current.status)) {
const [result] = await this.pool.execute<ResultSetHeader>( throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT');
`UPDATE qipai_cleaning_tasks }
SET status = 'SUBMITTED', submitted_at = UTC_TIMESTAMP(3), await connection.execute(
photo_urls_json = ?, reject_reason = '' `UPDATE qipai_cleaning_tasks
WHERE tenant_id = ? AND id = ? AND cleaner_user_id = ? AND status = ? SET status = 'SUBMITTED', submitted_at = UTC_TIMESTAMP(3),
AND deleted_at IS NULL`, photo_urls_json = ?, reject_reason = ''
[JSON.stringify(input.photoUrls.slice(0, 9)), input.tenantId, input.taskId, WHERE tenant_id = ? AND id = ?`,
input.userId, current.status] [JSON.stringify(input.photoUrls.slice(0, 9)), input.tenantId, input.taskId]
); );
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT'); await this.recordEventWithConnection(
await this.recordEvent(input, input.taskId, current.status, 'SUBMITTED', 'SUBMIT', input.note ?? ''); connection, input, input.taskId, current.status, 'SUBMITTED', 'SUBMIT', input.note ?? ''
);
});
return this.getMineTask(input, input.taskId); return this.getMineTask(input, input.taskId);
} }
@@ -1104,14 +1152,19 @@ export class CleaningTaskRepository {
) { ) {
this.assertCleaner(input.access, 'write'); this.assertCleaner(input.access, 'write');
await this.assertStoreVisible(input, input.taskId); await this.assertStoreVisible(input, input.taskId);
const [result] = await this.pool.execute<ResultSetHeader>( await this.transaction(async (connection) => {
`UPDATE qipai_cleaning_tasks const [result] = await connection.execute<ResultSetHeader>(
SET status = ?, ${timestampColumn} = UTC_TIMESTAMP(3) `UPDATE qipai_cleaning_tasks
WHERE tenant_id = ? AND id = ? AND status = ? AND deleted_at IS NULL`, SET status = ?, ${timestampColumn} = UTC_TIMESTAMP(3)
[to, input.tenantId, input.taskId, from] WHERE tenant_id = ? AND id = ? AND status = ? AND deleted_at IS NULL`,
); [to, input.tenantId, input.taskId, from]
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT'); );
await this.recordEvent(input, input.taskId, from, to, action, note); if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT');
await this.ensureLeadMember(connection, input.tenantId, input.taskId);
await this.recordEventWithConnection(
connection, input, input.taskId, from, to, action, note
);
});
return this.getTask(input, input.taskId); return this.getTask(input, input.taskId);
} }
@@ -1136,15 +1189,19 @@ export class CleaningTaskRepository {
extraParams.push(extra.reject_reason); extraParams.push(extra.reject_reason);
} }
const setExtra = extraAssignments.length > 0 ? `, ${extraAssignments.join(', ')}` : ''; const setExtra = extraAssignments.length > 0 ? `, ${extraAssignments.join(', ')}` : '';
const [result] = await this.pool.execute<ResultSetHeader>( await this.transaction(async (connection) => {
`UPDATE qipai_cleaning_tasks const [result] = await connection.execute<ResultSetHeader>(
SET status = ?, ${timestampColumn} = UTC_TIMESTAMP(3)${setExtra} `UPDATE qipai_cleaning_tasks
WHERE tenant_id = ? AND id = ? AND cleaner_user_id = ? AND status = ? SET status = ?, ${timestampColumn} = UTC_TIMESTAMP(3)${setExtra}
AND deleted_at IS NULL`, WHERE tenant_id = ? AND id = ? AND cleaner_user_id = ? AND status = ?
[to, ...extraParams, input.tenantId, input.taskId, input.userId, from] AND deleted_at IS NULL`,
); [to, ...extraParams, input.tenantId, input.taskId, input.userId, from]
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT'); );
await this.recordEvent(input, input.taskId, from, to, action, note); if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT');
await this.recordEventWithConnection(
connection, input, input.taskId, from, to, action, note
);
});
return this.getMineTask(input, input.taskId); return this.getMineTask(input, input.taskId);
} }
@@ -1236,8 +1293,13 @@ export class CleaningTaskRepository {
} }
} }
private async assertCleanerUserForTask(tenantId: string, taskId: string, cleanerUserId: string) { private async assertCleanerUserForTask(
const [rows] = await this.pool.execute<RowDataPacket[]>( tenantId: string,
taskId: string,
cleanerUserId: string,
connection: Pick<MySqlPool, 'execute'> | PoolConnection = this.pool
) {
const [rows] = await connection.execute<RowDataPacket[]>(
`SELECT 1 `SELECT 1
FROM qipai_cleaning_tasks t FROM qipai_cleaning_tasks t
INNER JOIN qipai_users u ON u.tenant_id = t.tenant_id AND u.id = ? INNER JOIN qipai_users u ON u.tenant_id = t.tenant_id AND u.id = ?
@@ -1254,22 +1316,6 @@ export class CleaningTaskRepository {
if (!rows[0]) throw new CleaningTaskError('CLEANING_ASSIGNEE_INVALID'); if (!rows[0]) throw new CleaningTaskError('CLEANING_ASSIGNEE_INVALID');
} }
private async recordEvent(
input: CleaningActor,
taskId: string,
from: CleaningTaskStatus,
to: CleaningTaskStatus,
action: string,
note: string
) {
await this.pool.execute(
`INSERT IGNORE INTO qipai_cleaning_task_events
(tenant_id, task_id, from_status, to_status, action, actor_id, trace_id, note, metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, JSON_OBJECT())`,
[input.tenantId, taskId, from, to, action, input.userId, input.traceId, note.slice(0, 512)]
);
}
private async recordEventWithConnection( private async recordEventWithConnection(
connection: PoolConnection, connection: PoolConnection,
input: CleaningActor, input: CleaningActor,
@@ -1280,7 +1326,7 @@ export class CleaningTaskRepository {
note: string note: string
) { ) {
await connection.execute( await connection.execute(
`INSERT IGNORE INTO qipai_cleaning_task_events `INSERT INTO qipai_cleaning_task_events
(tenant_id, task_id, from_status, to_status, action, actor_id, trace_id, note, metadata) (tenant_id, task_id, from_status, to_status, action, actor_id, trace_id, note, metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, JSON_OBJECT())`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, JSON_OBJECT())`,
[input.tenantId, taskId, from, to, action, input.userId, [input.tenantId, taskId, from, to, action, input.userId,
@@ -45,6 +45,9 @@ import { BusinessStatisticsRepository } from '../dist/operations/business-statis
import { SystemOperationsRepository } from '../dist/operations/system-operations-repository.js'; import { SystemOperationsRepository } from '../dist/operations/system-operations-repository.js';
import { AdminAuthRepository, AdminAuthError, hashToken } from '../dist/auth/admin-auth-repository.js'; import { AdminAuthRepository, AdminAuthError, hashToken } from '../dist/auth/admin-auth-repository.js';
import { hashPassword } from '../dist/auth/password.js'; import { hashPassword } from '../dist/auth/password.js';
import {
CleaningTaskError, CleaningTaskRepository
} from '../dist/cleaning/cleaning-task-repository.js';
import { import {
executeMigrationPlan, executeMigrationPlan,
loadMigrationPlan, loadMigrationPlan,
@@ -2129,6 +2132,408 @@ async function assertIotMessages(pool, context) {
assert.deepEqual(linkRows, [{ subId: 'SUB-AUTO-001', subtype: '14', model: '701C' }]); assert.deepEqual(linkRows, [{ subId: 'SUB-AUTO-001', subtype: '14', model: '701C' }]);
} }
async function assertCleaningTaskTransactions(pool, context) {
const [scopeRows] = await pool.query(
`SELECT s.id AS storeId, r.id AS roomId
FROM qipai_stores s
INNER JOIN qipai_rooms r ON r.tenant_id = s.tenant_id AND r.store_id = s.id
WHERE s.tenant_id = ? AND s.name = 'M03A Store'
ORDER BY r.id LIMIT 1`,
[context.tenantId]
);
const [managerRows] = await pool.query(
`SELECT u.id
FROM qipai_users u
INNER JOIN qipai_user_roles ur ON ur.tenant_id = u.tenant_id AND ur.user_id = u.id
INNER JOIN qipai_roles r ON r.tenant_id = ur.tenant_id AND r.id = ur.role_id
WHERE u.tenant_id = ? AND r.code = 'TENANT_ADMIN'
ORDER BY u.id LIMIT 1`,
[context.tenantId]
);
assert.ok(scopeRows[0], 'M09-A cleaning test requires the M03-A store and room.');
assert.ok(managerRows[0], 'M09-A cleaning test requires a tenant administrator.');
const storeId = String(scopeRows[0].storeId);
const roomId = String(scopeRows[0].roomId);
const managerId = String(managerRows[0].id);
const managerAccess = await new RbacRepository(pool).getAccessProfile(context.tenantId, managerId);
assert.ok(managerAccess.capabilities.includes('tenant.manage'));
const createCleaner = async (nickname, phone) => {
const [result] = await pool.query(
`INSERT INTO qipai_users (tenant_id, user_type, nickname, phone)
VALUES (?, 'STAFF', ?, ?)`,
[context.tenantId, nickname, phone]
);
const cleanerId = String(result.insertId);
await pool.query(
`INSERT INTO qipai_user_roles (tenant_id, user_id, role_id)
SELECT ?, ?, id FROM qipai_roles
WHERE tenant_id = ? AND code = 'CLEANER'`,
[context.tenantId, cleanerId, context.tenantId]
);
await pool.query(
`INSERT INTO qipai_user_store_scopes (tenant_id, user_id, store_id, scope_type)
VALUES (?, ?, ?, 'STAFF')`,
[context.tenantId, cleanerId, storeId]
);
return cleanerId;
};
const firstCleanerId = await createCleaner('M09A Cleaner One', '13900000901');
const secondCleanerId = await createCleaner('M09A Cleaner Two', '13900000902');
const cleanerAccess = {
roles: ['CLEANER'],
capabilities: ['cleaning.task.read', 'cleaning.task.write'],
storeIds: [storeId]
};
const cleanerActor = (userId, traceId) => ({
tenantId: context.tenantId,
userId,
access: cleanerAccess,
traceId
});
const managerActor = (traceId) => ({
tenantId: context.tenantId,
userId: managerId,
access: managerAccess,
traceId
});
const repository = new CleaningTaskRepository(pool);
let taskSequence = 0;
const insertTask = async ({
status = 'WAITING', cleanerUserId = null, rewardCents = 100,
photoUrls = [], rejectReason = '', claimedAt = null, startedAt = null,
submittedAt = null, completedAt = null, settledAt = null, cancelledAt = null,
updatedAt = new Date()
} = {}) => {
taskSequence += 1;
const [result] = await pool.query(
`INSERT INTO qipai_cleaning_tasks
(tenant_id, store_id, room_id, task_no, status, cleaner_user_id,
priority, reward_cents, requirement, photo_urls_json, reject_reason,
claimed_at, started_at, submitted_at, completed_at, settled_at, cancelled_at,
updated_at)
VALUES (?, ?, ?, ?, ?, ?, 5, ?, 'M09-A transaction test', ?, ?,
?, ?, ?, ?, ?, ?, ?)`,
[
context.tenantId, storeId, roomId, `M09A-${Date.now()}-${taskSequence}`,
status, cleanerUserId, rewardCents, JSON.stringify(photoUrls), rejectReason,
claimedAt, startedAt, submittedAt, completedAt, settledAt, cancelledAt, updatedAt
]
);
return String(result.insertId);
};
const insertMember = async ({
taskId, userId, memberRole = 'LEAD', rewardCents = 0,
removedAt = null, settledAt = null
}) => {
await pool.query(
`INSERT INTO qipai_cleaning_task_members
(tenant_id, task_id, user_id, member_role, reward_cents, joined_at, removed_at, settled_at)
VALUES (?, ?, ?, ?, ?, UTC_TIMESTAMP(3), ?, ?)`,
[context.tenantId, taskId, userId, memberRole, rewardCents, removedAt, settledAt]
);
};
const rollbackTaskId = await insertTask();
const rollbackTrace = `m09a-event-failure-${rollbackTaskId}`;
await pool.query(
`INSERT INTO qipai_cleaning_task_events
(tenant_id, task_id, from_status, to_status, action, actor_id, trace_id, note, metadata)
VALUES (?, ?, 'WAITING', 'WAITING', 'TEST_BLOCKER', ?, ?, '', JSON_OBJECT())`,
[context.tenantId, rollbackTaskId, firstCleanerId, rollbackTrace]
);
await assert.rejects(
() => repository.claim({
...cleanerActor(firstCleanerId, 'm09a-event-failure'), taskId: rollbackTaskId
}),
(error) => error?.code === 'ER_DUP_ENTRY'
);
const [rollbackRows] = await pool.query(
`SELECT t.status, t.cleaner_user_id AS cleanerUserId,
(SELECT COUNT(*) FROM qipai_cleaning_task_members m
WHERE m.tenant_id = t.tenant_id AND m.task_id = t.id) AS memberCount
FROM qipai_cleaning_tasks t WHERE t.tenant_id = ? AND t.id = ?`,
[context.tenantId, rollbackTaskId]
);
assert.equal(rollbackRows[0].status, 'WAITING');
assert.equal(rollbackRows[0].cleanerUserId, null);
assert.equal(Number(rollbackRows[0].memberCount), 0);
const concurrentTaskId = await insertTask();
const claimResults = await Promise.allSettled([
repository.claim({
...cleanerActor(firstCleanerId, 'm09a-concurrent-claim-one'), taskId: concurrentTaskId
}),
repository.claim({
...cleanerActor(secondCleanerId, 'm09a-concurrent-claim-two'), taskId: concurrentTaskId
})
]);
assert.equal(claimResults.filter((result) => result.status === 'fulfilled').length, 1);
const rejectedClaim = claimResults.find((result) => result.status === 'rejected');
assert.ok(rejectedClaim?.reason instanceof CleaningTaskError);
assert.equal(rejectedClaim.reason.code, 'CLEANING_TASK_NOT_CLAIMABLE');
const [claimedRows] = await pool.query(
`SELECT t.status, t.cleaner_user_id AS cleanerUserId,
(SELECT COUNT(*) FROM qipai_cleaning_task_events e
WHERE e.tenant_id = t.tenant_id AND e.task_id = t.id AND e.action = 'CLAIM') AS eventCount,
(SELECT COUNT(*) FROM qipai_cleaning_task_members m
WHERE m.tenant_id = t.tenant_id AND m.task_id = t.id
AND m.member_role = 'LEAD' AND m.removed_at IS NULL) AS leadCount
FROM qipai_cleaning_tasks t WHERE t.tenant_id = ? AND t.id = ?`,
[context.tenantId, concurrentTaskId]
);
assert.equal(claimedRows[0].status, 'CLAIMED');
assert.ok([firstCleanerId, secondCleanerId].includes(String(claimedRows[0].cleanerUserId)));
assert.equal(Number(claimedRows[0].eventCount), 1);
assert.equal(Number(claimedRows[0].leadCount), 1);
const lifecycleTaskId = await insertTask();
await repository.assign({
...managerActor('m09a-assign-waiting'), taskId: lifecycleTaskId,
cleanerUserId: firstCleanerId, note: 'WAITING assignment'
});
await repository.assign({
...managerActor('m09a-assign-claimed'), taskId: lifecycleTaskId,
cleanerUserId: secondCleanerId, note: 'CLAIMED reassignment'
});
const expiredAt = new Date(Date.now() - 3_600_000);
await pool.query(
`UPDATE qipai_cleaning_tasks
SET status = 'REJECTED', photo_urls_json = JSON_ARRAY('https://old.example/photo.jpg'),
reject_reason = 'old rejection', started_at = ?, submitted_at = ?, completed_at = ?,
settled_at = ?, cancelled_at = ?
WHERE tenant_id = ? AND id = ?`,
[expiredAt, expiredAt, expiredAt, expiredAt, expiredAt, context.tenantId, lifecycleTaskId]
);
await repository.assign({
...managerActor('m09a-assign-rejected'), taskId: lifecycleTaskId,
cleanerUserId: firstCleanerId, note: 'REJECTED reassignment'
});
const [resetRows] = await pool.query(
`SELECT status, cleaner_user_id AS cleanerUserId,
claimed_at IS NOT NULL AS claimedSet,
started_at IS NULL AS startedCleared,
submitted_at IS NULL AS submittedCleared,
completed_at IS NULL AS completedCleared,
settled_at IS NULL AS settledCleared,
cancelled_at IS NULL AS cancelledCleared,
JSON_LENGTH(photo_urls_json) AS photoCount, reject_reason AS rejectReason
FROM qipai_cleaning_tasks WHERE tenant_id = ? AND id = ?`,
[context.tenantId, lifecycleTaskId]
);
assert.deepEqual({
status: resetRows[0].status,
cleanerUserId: String(resetRows[0].cleanerUserId),
claimedSet: Number(resetRows[0].claimedSet),
startedCleared: Number(resetRows[0].startedCleared),
submittedCleared: Number(resetRows[0].submittedCleared),
completedCleared: Number(resetRows[0].completedCleared),
settledCleared: Number(resetRows[0].settledCleared),
cancelledCleared: Number(resetRows[0].cancelledCleared),
photoCount: Number(resetRows[0].photoCount),
rejectReason: resetRows[0].rejectReason
}, {
status: 'CLAIMED', cleanerUserId: firstCleanerId, claimedSet: 1,
startedCleared: 1, submittedCleared: 1, completedCleared: 1,
settledCleared: 1, cancelledCleared: 1, photoCount: 0, rejectReason: ''
});
const [reassignedMemberRows] = await pool.query(
`SELECT user_id AS userId, member_role AS memberRole, reward_cents AS rewardCents,
removed_at IS NOT NULL AS removed
FROM qipai_cleaning_task_members
WHERE tenant_id = ? AND task_id = ? ORDER BY user_id`,
[context.tenantId, lifecycleTaskId]
);
assert.deepEqual(reassignedMemberRows.map((row) => ({
userId: String(row.userId), memberRole: row.memberRole,
rewardCents: Number(row.rewardCents), removed: Number(row.removed)
})), [
{ userId: firstCleanerId, memberRole: 'LEAD', rewardCents: 100, removed: 0 },
{ userId: secondCleanerId, memberRole: 'LEAD', rewardCents: 0, removed: 1 }
].sort((left, right) => Number(left.userId) - Number(right.userId)));
await repository.start({
...cleanerActor(firstCleanerId, 'm09a-start'), taskId: lifecycleTaskId
});
await repository.submit({
...cleanerActor(firstCleanerId, 'm09a-submit-first'), taskId: lifecycleTaskId,
photoUrls: ['https://api.txyundm.cn/uploads/m09a-first.webp'], note: 'first submit'
});
await repository.reject({
...managerActor('m09a-reject'), taskId: lifecycleTaskId, reason: 'needs rework'
});
const [rejectedRows] = await pool.query(
`SELECT status, reject_reason AS rejectReason
FROM qipai_cleaning_tasks WHERE tenant_id = ? AND id = ?`,
[context.tenantId, lifecycleTaskId]
);
assert.deepEqual(rejectedRows, [{ status: 'REJECTED', rejectReason: 'needs rework' }]);
await repository.rework({
...cleanerActor(firstCleanerId, 'm09a-rework'), taskId: lifecycleTaskId
});
await repository.submit({
...cleanerActor(firstCleanerId, 'm09a-submit-second'), taskId: lifecycleTaskId,
photoUrls: ['https://api.txyundm.cn/uploads/m09a-second.webp'], note: 'second submit'
});
const completeInput = {
...managerActor('m09a-complete'), taskId: lifecycleTaskId, note: 'accepted'
};
await repository.complete(completeInput);
await assert.rejects(
() => repository.complete(completeInput),
(error) => error instanceof CleaningTaskError
&& error.code === 'CLEANING_TASK_STATUS_CONFLICT'
);
const [lifecycleRows] = await pool.query(
`SELECT status, completed_at IS NOT NULL AS completedSet
FROM qipai_cleaning_tasks WHERE tenant_id = ? AND id = ?`,
[context.tenantId, lifecycleTaskId]
);
assert.deepEqual({
status: lifecycleRows[0].status,
completedSet: Number(lifecycleRows[0].completedSet)
}, { status: 'COMPLETED', completedSet: 1 });
const [lifecycleEventRows] = await pool.query(
`SELECT action FROM qipai_cleaning_task_events
WHERE tenant_id = ? AND task_id = ? ORDER BY id`,
[context.tenantId, lifecycleTaskId]
);
assert.deepEqual(lifecycleEventRows.map((row) => row.action), [
'ASSIGN', 'ASSIGN', 'ASSIGN', 'START', 'SUBMIT', 'REJECT', 'REWORK', 'SUBMIT', 'COMPLETE'
]);
const staleBase = Date.now() - 24 * 3_600_000;
const lockedTaskId = await insertTask({
status: 'CLAIMED', cleanerUserId: firstCleanerId,
claimedAt: new Date(staleBase), updatedAt: new Date(staleBase)
});
const reclaimTaskId = await insertTask({
status: 'STARTED', cleanerUserId: secondCleanerId,
claimedAt: new Date(staleBase + 3_600_000), startedAt: new Date(staleBase + 3_600_000),
photoUrls: ['https://old.example/reclaim.jpg'], rejectReason: 'stale',
updatedAt: new Date(staleBase + 3_600_000)
});
const outsideBatchTaskId = await insertTask({
status: 'CLAIMED', cleanerUserId: firstCleanerId,
claimedAt: new Date(staleBase + 7_200_000), updatedAt: new Date(staleBase + 7_200_000)
});
const settledTaskId = await insertTask({
status: 'SETTLED', cleanerUserId: secondCleanerId,
claimedAt: new Date(staleBase - 3_600_000), completedAt: new Date(staleBase - 3_600_000),
settledAt: new Date(staleBase - 3_600_000), updatedAt: new Date(staleBase - 3_600_000)
});
await insertMember({ taskId: lockedTaskId, userId: firstCleanerId, rewardCents: 100 });
await insertMember({ taskId: reclaimTaskId, userId: secondCleanerId, rewardCents: 70 });
await insertMember({
taskId: reclaimTaskId, userId: firstCleanerId, memberRole: 'ASSIST', rewardCents: 30
});
await insertMember({ taskId: outsideBatchTaskId, userId: firstCleanerId, rewardCents: 100 });
await insertMember({
taskId: settledTaskId, userId: secondCleanerId, rewardCents: 100,
settledAt: new Date(staleBase)
});
const lockConnection = await pool.getConnection();
let reclaimed;
try {
await lockConnection.beginTransaction();
await lockConnection.query(
`SELECT id FROM qipai_cleaning_tasks
WHERE tenant_id = ? AND id = ? FOR UPDATE`,
[context.tenantId, lockedTaskId]
);
reclaimed = await repository.reclaimTimeouts({
...managerActor('m09a-reclaim'), olderThanMinutes: 30, limit: 1
});
} finally {
await lockConnection.rollback();
lockConnection.release();
}
assert.deepEqual(reclaimed, { reclaimed: 1, taskIds: [reclaimTaskId] });
const [reclaimRows] = await pool.query(
`SELECT id, status, cleaner_user_id AS cleanerUserId,
JSON_LENGTH(photo_urls_json) AS photoCount, reject_reason AS rejectReason
FROM qipai_cleaning_tasks
WHERE tenant_id = ? AND id IN (?, ?, ?, ?) ORDER BY id`,
[context.tenantId, lockedTaskId, reclaimTaskId, outsideBatchTaskId, settledTaskId]
);
const reclaimById = new Map(reclaimRows.map((row) => [String(row.id), row]));
assert.equal(reclaimById.get(lockedTaskId).status, 'CLAIMED');
assert.equal(reclaimById.get(outsideBatchTaskId).status, 'CLAIMED');
assert.equal(reclaimById.get(settledTaskId).status, 'SETTLED');
assert.equal(reclaimById.get(reclaimTaskId).status, 'WAITING');
assert.equal(reclaimById.get(reclaimTaskId).cleanerUserId, null);
assert.equal(Number(reclaimById.get(reclaimTaskId).photoCount), 0);
assert.equal(reclaimById.get(reclaimTaskId).rejectReason, '');
const [reclaimedMembers] = await pool.query(
`SELECT reward_cents AS rewardCents, removed_at IS NOT NULL AS removed
FROM qipai_cleaning_task_members
WHERE tenant_id = ? AND task_id = ? ORDER BY user_id`,
[context.tenantId, reclaimTaskId]
);
assert.deepEqual(reclaimedMembers.map((row) => ({
rewardCents: Number(row.rewardCents), removed: Number(row.removed)
})), [
{ rewardCents: 0, removed: 1 }, { rewardCents: 0, removed: 1 }
]);
const [settledMemberRows] = await pool.query(
`SELECT reward_cents AS rewardCents, removed_at AS removedAt,
settled_at IS NOT NULL AS settled
FROM qipai_cleaning_task_members
WHERE tenant_id = ? AND task_id = ?`,
[context.tenantId, settledTaskId]
);
assert.equal(Number(settledMemberRows[0].rewardCents), 100);
assert.equal(settledMemberRows[0].removedAt, null);
assert.equal(Number(settledMemberRows[0].settled), 1);
const [orderResult] = await pool.query(
`INSERT INTO qipai_orders
(tenant_id, store_id, room_id, order_no, status, start_at, end_at,
total_amount_cents, paid_amount_cents)
VALUES (?, ?, ?, ?, 'FINISHED', ?, ?, 1000, 1000)`,
[
context.tenantId, storeId, roomId, `M09A-FINISHED-${Date.now()}`,
new Date(Date.now() - 7_200_000), new Date(Date.now() - 3_600_000)
]
);
const finishedOrderId = String(orderResult.insertId);
const orderConnection = await pool.getConnection();
try {
await orderConnection.beginTransaction();
const creationInput = {
tenantId: context.tenantId,
orderId: finishedOrderId,
actorId: managerId,
traceId: 'm09a-finished-order'
};
await repository.createForFinishedOrder(orderConnection, creationInput);
await repository.createForFinishedOrder(orderConnection, creationInput);
await orderConnection.commit();
} catch (error) {
await orderConnection.rollback();
throw error;
} finally {
orderConnection.release();
}
const [generatedRows] = await pool.query(
`SELECT t.status,
(SELECT COUNT(*) FROM qipai_cleaning_task_events e
WHERE e.tenant_id = t.tenant_id AND e.task_id = t.id
AND e.action = 'AUTO_CREATE') AS eventCount
FROM qipai_cleaning_tasks t
WHERE t.tenant_id = ? AND t.order_id = ?`,
[context.tenantId, finishedOrderId]
);
assert.equal(generatedRows.length, 1);
assert.equal(generatedRows[0].status, 'WAITING');
assert.equal(Number(generatedRows[0].eventCount), 1);
console.log(
'PASS: M09-A cleaning claims, lifecycle events, rollback, reassignment, timeout reclaim and order generation are transactional.'
);
}
const config = loadConfig(); const config = loadConfig();
assert.equal(config.mysql.passwordConfigured, true, 'Live migration test requires a temporary password.'); assert.equal(config.mysql.passwordConfigured, true, 'Live migration test requires a temporary password.');
assert.match( assert.match(
@@ -2197,6 +2602,7 @@ try {
await assertProfitSharingDomain(pool, loginContext); await assertProfitSharingDomain(pool, loginContext);
await assertDeviceTopology(pool, loginContext); await assertDeviceTopology(pool, loginContext);
await assertIotMessages(pool, loginContext); await assertIotMessages(pool, loginContext);
await assertCleaningTaskTransactions(pool, loginContext);
await assertLegacyCompatibility(pool); await assertLegacyCompatibility(pool);
console.log('PASS: first up, verify, tenant isolation and revocable auth checks completed.'); console.log('PASS: first up, verify, tenant isolation and revocable auth checks completed.');
@@ -2351,6 +2757,14 @@ try {
'door record credential hashing and masking', 'door record credential hashing and masking',
'low-battery alert upsert', 'low-battery alert upsert',
'AddDevice ACK creates 701C parent-child topology' 'AddDevice ACK creates 701C parent-child topology'
,
'concurrent cleaning claim has one winner',
'cleaning state and event transaction rollback',
'WAITING CLAIMED and REJECTED reassignment cleanup',
'idempotent cleaning completion trace',
'SKIP LOCKED cleaning timeout reclaim batch',
'settled cleaning member preservation',
'finished order creates one cleaning task'
] ]
}, null, 2)); }, null, 2));
} finally { } finally {