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
@@ -45,6 +45,9 @@ import { BusinessStatisticsRepository } from '../dist/operations/business-statis
import { SystemOperationsRepository } from '../dist/operations/system-operations-repository.js';
import { AdminAuthRepository, AdminAuthError, hashToken } from '../dist/auth/admin-auth-repository.js';
import { hashPassword } from '../dist/auth/password.js';
import {
CleaningTaskError, CleaningTaskRepository
} from '../dist/cleaning/cleaning-task-repository.js';
import {
executeMigrationPlan,
loadMigrationPlan,
@@ -2129,6 +2132,408 @@ async function assertIotMessages(pool, context) {
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();
assert.equal(config.mysql.passwordConfigured, true, 'Live migration test requires a temporary password.');
assert.match(
@@ -2197,6 +2602,7 @@ try {
await assertProfitSharingDomain(pool, loginContext);
await assertDeviceTopology(pool, loginContext);
await assertIotMessages(pool, loginContext);
await assertCleaningTaskTransactions(pool, loginContext);
await assertLegacyCompatibility(pool);
console.log('PASS: first up, verify, tenant isolation and revocable auth checks completed.');
@@ -2351,6 +2757,14 @@ try {
'door record credential hashing and masking',
'low-battery alert upsert',
'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));
} finally {