feat(M09-B): 完成清洁规则与照片验收闭环
This commit is contained in:
@@ -60,6 +60,9 @@ const expectedTables = [
|
||||
'qipai_async_tasks',
|
||||
'qipai_audit_logs',
|
||||
'qipai_auth_sessions',
|
||||
'qipai_cleaning_task_photos',
|
||||
'qipai_cleaning_task_submissions',
|
||||
'qipai_cleaning_templates',
|
||||
'qipai_collection_accounts',
|
||||
'qipai_device_alerts',
|
||||
'qipai_device_channels',
|
||||
@@ -139,13 +142,13 @@ async function readMigrationVersions(pool) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT version, name
|
||||
FROM qipai_schema_migrations
|
||||
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ORDER BY version`,
|
||||
['2026061601', '2026061802', '2026061803', '2026061804',
|
||||
'2026061805', '2026061806', '2026061807', '2026061808', '2026061809',
|
||||
'2026061810', '2026061811', '2026062012', '2026062013', '2026062014',
|
||||
'2026062015', '2026062216', '2026062217', '2026062218', '2026062219',
|
||||
'2026062220', '2026081002', '2026081003', '2026081004']
|
||||
'2026062220', '2026081002', '2026081003', '2026081004', '2026081005']
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
@@ -1833,7 +1836,7 @@ async function assertSystemOperations(pool, context) {
|
||||
assert.equal(logs.items[0].metadata.nested.safe, 'visible');
|
||||
const overview = await repository.getSystemOverview(context.tenantId);
|
||||
assert.equal(overview.tenant.id, context.tenantId);
|
||||
assert.equal(overview.latestMigration.version, '2026081004');
|
||||
assert.equal(overview.latestMigration.version, '2026081005');
|
||||
assert.ok(overview.counts.userCount > 0);
|
||||
await repository.updateTenant(actor, context.tenantId, {
|
||||
name: overview.tenant.name, timezone: overview.tenant.timezone
|
||||
@@ -2528,6 +2531,19 @@ async function assertCleaningTaskTransactions(pool, context) {
|
||||
[context.tenantId, taskId, userId, memberRole, rewardCents, removedAt, settledAt]
|
||||
);
|
||||
};
|
||||
const recordPhoto = async (taskId, userId, suffix) => repository.recordPhotoUpload({
|
||||
...cleanerActor(userId, `m09b-photo-${suffix}`),
|
||||
taskId,
|
||||
image: {
|
||||
storagePath: `tenants/${context.tenantId}/stores/${storeId}/${suffix}.webp`,
|
||||
publicUrl: `https://api.txyundm.cn/uploads/tenants/${context.tenantId}/stores/${storeId}/${suffix}.webp`,
|
||||
mimeType: 'image/webp',
|
||||
byteSize: 1024,
|
||||
width: 1280,
|
||||
height: 720,
|
||||
checksumSha256: suffix.padEnd(64, '0').slice(0, 64)
|
||||
}
|
||||
});
|
||||
|
||||
const rollbackTaskId = await insertTask();
|
||||
const rollbackTrace = `m09a-event-failure-${rollbackTaskId}`;
|
||||
@@ -2650,9 +2666,10 @@ async function assertCleaningTaskTransactions(pool, context) {
|
||||
await repository.start({
|
||||
...cleanerActor(firstCleanerId, 'm09a-start'), taskId: lifecycleTaskId
|
||||
});
|
||||
const firstPhoto = await recordPhoto(lifecycleTaskId, firstCleanerId, 'm09b-first');
|
||||
await repository.submit({
|
||||
...cleanerActor(firstCleanerId, 'm09a-submit-first'), taskId: lifecycleTaskId,
|
||||
photoUrls: ['https://api.txyundm.cn/uploads/m09a-first.webp'], note: 'first submit'
|
||||
photoUrls: [firstPhoto.publicUrl], note: 'first submit'
|
||||
});
|
||||
await repository.reject({
|
||||
...managerActor('m09a-reject'), taskId: lifecycleTaskId, reason: 'needs rework'
|
||||
@@ -2666,9 +2683,10 @@ async function assertCleaningTaskTransactions(pool, context) {
|
||||
await repository.rework({
|
||||
...cleanerActor(firstCleanerId, 'm09a-rework'), taskId: lifecycleTaskId
|
||||
});
|
||||
const secondPhoto = await recordPhoto(lifecycleTaskId, firstCleanerId, 'm09b-second');
|
||||
await repository.submit({
|
||||
...cleanerActor(firstCleanerId, 'm09a-submit-second'), taskId: lifecycleTaskId,
|
||||
photoUrls: ['https://api.txyundm.cn/uploads/m09a-second.webp'], note: 'second submit'
|
||||
photoUrls: [secondPhoto.publicUrl], note: 'second submit'
|
||||
});
|
||||
const completeInput = {
|
||||
...managerActor('m09a-complete'), taskId: lifecycleTaskId, note: 'accepted'
|
||||
@@ -2688,6 +2706,74 @@ async function assertCleaningTaskTransactions(pool, context) {
|
||||
status: lifecycleRows[0].status,
|
||||
completedSet: Number(lifecycleRows[0].completedSet)
|
||||
}, { status: 'COMPLETED', completedSet: 1 });
|
||||
const [submissionRows] = await pool.query(
|
||||
`SELECT revision, status, JSON_UNQUOTE(JSON_EXTRACT(photo_urls_json, '$[0]')) AS photoUrl,
|
||||
reject_reason AS rejectReason
|
||||
FROM qipai_cleaning_task_submissions
|
||||
WHERE tenant_id = ? AND task_id = ? ORDER BY revision`,
|
||||
[context.tenantId, lifecycleTaskId]
|
||||
);
|
||||
assert.deepEqual(submissionRows.map((row) => ({
|
||||
revision: Number(row.revision), status: row.status,
|
||||
photoUrl: row.photoUrl, rejectReason: row.rejectReason
|
||||
})), [
|
||||
{ revision: 2, status: 'REJECTED', photoUrl: firstPhoto.publicUrl, rejectReason: 'needs rework' },
|
||||
{ revision: 3, status: 'ACCEPTED', photoUrl: secondPhoto.publicUrl, rejectReason: '' }
|
||||
]);
|
||||
const [photoRows] = await pool.query(
|
||||
`SELECT public_url AS publicUrl, status, attached_revision AS attachedRevision
|
||||
FROM qipai_cleaning_task_photos
|
||||
WHERE tenant_id = ? AND task_id = ? ORDER BY attached_revision`,
|
||||
[context.tenantId, lifecycleTaskId]
|
||||
);
|
||||
assert.deepEqual(photoRows.map((row) => ({
|
||||
publicUrl: row.publicUrl, status: row.status, revision: Number(row.attachedRevision)
|
||||
})), [
|
||||
{ publicUrl: firstPhoto.publicUrl, status: 'ATTACHED', revision: 2 },
|
||||
{ publicUrl: secondPhoto.publicUrl, status: 'ATTACHED', revision: 3 }
|
||||
]);
|
||||
const submissionHistory = await repository.listSubmissions({
|
||||
...managerActor('m09b-submission-history'), taskId: lifecycleTaskId
|
||||
});
|
||||
assert.deepEqual(submissionHistory.map((submission) => ({
|
||||
revision: submission.revision,
|
||||
status: submission.status,
|
||||
photoUrl: submission.photoUrls[0],
|
||||
rejectReason: submission.rejectReason
|
||||
})), [
|
||||
{ revision: 3, status: 'ACCEPTED', photoUrl: secondPhoto.publicUrl, rejectReason: '' },
|
||||
{ revision: 2, status: 'REJECTED', photoUrl: firstPhoto.publicUrl, rejectReason: 'needs rework' }
|
||||
]);
|
||||
|
||||
const cleanupTaskId = await insertTask({
|
||||
status: 'STARTED', cleanerUserId: firstCleanerId,
|
||||
claimedAt: new Date(), startedAt: new Date()
|
||||
});
|
||||
const expiredPhoto = await recordPhoto(cleanupTaskId, firstCleanerId, 'm09b-expired');
|
||||
await pool.query(
|
||||
`UPDATE qipai_cleaning_task_photos SET retention_until = TIMESTAMPADD(DAY, -1, UTC_TIMESTAMP(3))
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[context.tenantId, expiredPhoto.id]
|
||||
);
|
||||
const expiredClaims = await repository.claimExpiredPhotoUploads({
|
||||
...managerActor('m09b-photo-cleanup-claim'), limit: 10
|
||||
});
|
||||
assert.deepEqual(expiredClaims, [{ id: expiredPhoto.id, storagePath: expiredPhoto.storagePath }]);
|
||||
assert.deepEqual(
|
||||
await repository.markPhotoUploadsDeleted({
|
||||
...managerActor('m09b-photo-cleanup-delete'), photoIds: [expiredPhoto.id]
|
||||
}),
|
||||
{ deleted: 1 }
|
||||
);
|
||||
const [deletedPhotoRows] = await pool.query(
|
||||
`SELECT status, deleted_at IS NOT NULL AS deleted
|
||||
FROM qipai_cleaning_task_photos WHERE tenant_id = ? AND id = ?`,
|
||||
[context.tenantId, expiredPhoto.id]
|
||||
);
|
||||
assert.deepEqual(
|
||||
{ status: deletedPhotoRows[0].status, deleted: Number(deletedPhotoRows[0].deleted) },
|
||||
{ status: 'ORPHANED', deleted: 1 }
|
||||
);
|
||||
const [lifecycleEventRows] = await pool.query(
|
||||
`SELECT action FROM qipai_cleaning_task_events
|
||||
WHERE tenant_id = ? AND task_id = ? ORDER BY id`,
|
||||
@@ -2782,6 +2868,26 @@ async function assertCleaningTaskTransactions(pool, context) {
|
||||
assert.equal(settledMemberRows[0].removedAt, null);
|
||||
assert.equal(Number(settledMemberRows[0].settled), 1);
|
||||
|
||||
const deniedExemptTaskId = await insertTask();
|
||||
await pool.query(
|
||||
`UPDATE qipai_cleaning_tasks SET exempt_policy = 'DISABLED'
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[context.tenantId, deniedExemptTaskId]
|
||||
);
|
||||
await assert.rejects(
|
||||
() => repository.exempt({
|
||||
...managerActor('m09b-exempt-denied'), taskId: deniedExemptTaskId, note: 'not allowed'
|
||||
}),
|
||||
(error) => error instanceof CleaningTaskError && error.code === 'CLEANING_EXEMPT_POLICY_DENIED'
|
||||
);
|
||||
|
||||
const template = await repository.upsertTemplate({
|
||||
...managerActor('m09b-template-v1'), scopeType: 'TENANT', name: 'M09-B tenant default',
|
||||
requirement: '两张照片并在开始前决定免清洁', photoRequired: true,
|
||||
minPhotoCount: 2, maxPhotoCount: 3, exemptPolicy: 'BEFORE_START', status: 'ACTIVE'
|
||||
});
|
||||
assert.equal(template.version, 1);
|
||||
|
||||
const [orderResult] = await pool.query(
|
||||
`INSERT INTO qipai_orders
|
||||
(tenant_id, store_id, room_id, order_no, status, start_at, end_at,
|
||||
@@ -2812,7 +2918,10 @@ async function assertCleaningTaskTransactions(pool, context) {
|
||||
orderConnection.release();
|
||||
}
|
||||
const [generatedRows] = await pool.query(
|
||||
`SELECT t.status,
|
||||
`SELECT t.status, t.cleaning_template_id AS cleaningTemplateId,
|
||||
t.cleaning_template_version AS cleaningTemplateVersion,
|
||||
t.photo_required AS photoRequired, t.min_photo_count AS minPhotoCount,
|
||||
t.max_photo_count AS maxPhotoCount, t.exempt_policy AS exemptPolicy,
|
||||
(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
|
||||
@@ -2822,10 +2931,34 @@ async function assertCleaningTaskTransactions(pool, context) {
|
||||
);
|
||||
assert.equal(generatedRows.length, 1);
|
||||
assert.equal(generatedRows[0].status, 'WAITING');
|
||||
assert.equal(String(generatedRows[0].cleaningTemplateId), template.id);
|
||||
assert.equal(Number(generatedRows[0].cleaningTemplateVersion), 1);
|
||||
assert.equal(Number(generatedRows[0].photoRequired), 1);
|
||||
assert.equal(Number(generatedRows[0].minPhotoCount), 2);
|
||||
assert.equal(Number(generatedRows[0].maxPhotoCount), 3);
|
||||
assert.equal(generatedRows[0].exemptPolicy, 'BEFORE_START');
|
||||
assert.equal(Number(generatedRows[0].eventCount), 1);
|
||||
|
||||
const updatedTemplate = await repository.upsertTemplate({
|
||||
...managerActor('m09b-template-v2'), scopeType: 'TENANT', name: 'M09-B tenant default',
|
||||
requirement: '新任务无需照片', photoRequired: false,
|
||||
minPhotoCount: 0, maxPhotoCount: 2, exemptPolicy: 'DISABLED', status: 'ACTIVE'
|
||||
});
|
||||
assert.equal(updatedTemplate.version, 2);
|
||||
const [snapshotRows] = await pool.query(
|
||||
`SELECT cleaning_template_version AS version, min_photo_count AS minPhotoCount,
|
||||
exempt_policy AS exemptPolicy
|
||||
FROM qipai_cleaning_tasks WHERE tenant_id = ? AND order_id = ?`,
|
||||
[context.tenantId, finishedOrderId]
|
||||
);
|
||||
assert.deepEqual({
|
||||
version: Number(snapshotRows[0].version),
|
||||
minPhotoCount: Number(snapshotRows[0].minPhotoCount),
|
||||
exemptPolicy: snapshotRows[0].exemptPolicy
|
||||
}, { version: 1, minPhotoCount: 2, exemptPolicy: 'BEFORE_START' });
|
||||
|
||||
console.log(
|
||||
'PASS: M09-A cleaning claims, lifecycle events, rollback, reassignment, timeout reclaim and order generation are transactional.'
|
||||
'PASS: M09-A/M09-B cleaning transactions, template snapshots, photo revisions and exemption rules are consistent.'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2875,7 +3008,8 @@ try {
|
||||
{ version: '2026062220', name: 'm06c_iot_messages' },
|
||||
{ version: '2026081002', name: 'm08d_content_asset_scope' },
|
||||
{ version: '2026081003', name: 'm08d_franchise_leads' },
|
||||
{ version: '2026081004', name: 'm08d_admin_password_auth' }
|
||||
{ version: '2026081004', name: 'm08d_admin_password_auth' },
|
||||
{ version: '2026081005', name: 'm09b_cleaning_rules' }
|
||||
]);
|
||||
await assertTaskDurability(pool);
|
||||
const loginContext = await assertPlatformTenantIsolation(pool);
|
||||
@@ -2904,7 +3038,7 @@ try {
|
||||
await executeMigrationPlan(pool, plans.down);
|
||||
assert.deepEqual(await readCoreTables(pool), []);
|
||||
await assertLegacyCompatibility(pool);
|
||||
console.log('PASS: down removed all M01-B through M06-C migration tables.');
|
||||
console.log('PASS: down removed all M01-B through M09-B migration tables.');
|
||||
|
||||
await executeMigrationPlan(pool, plans.up);
|
||||
await executeMigrationPlan(pool, plans.verify);
|
||||
@@ -2932,7 +3066,8 @@ try {
|
||||
{ version: '2026062220', name: 'm06c_iot_messages' },
|
||||
{ version: '2026081002', name: 'm08d_content_asset_scope' },
|
||||
{ version: '2026081003', name: 'm08d_franchise_leads' },
|
||||
{ version: '2026081004', name: 'm08d_admin_password_auth' }
|
||||
{ version: '2026081004', name: 'm08d_admin_password_auth' },
|
||||
{ version: '2026081005', name: 'm09b_cleaning_rules' }
|
||||
]);
|
||||
await assertLegacyCompatibility(pool);
|
||||
console.log('PASS: second up and verify restored the schema.');
|
||||
@@ -3069,7 +3204,11 @@ try {
|
||||
'idempotent cleaning completion trace',
|
||||
'SKIP LOCKED cleaning timeout reclaim batch',
|
||||
'settled cleaning member preservation',
|
||||
'finished order creates one cleaning task'
|
||||
'finished order creates one cleaning task',
|
||||
'cleaning template snapshot remains stable after config version change',
|
||||
'rejected and accepted cleaning photo revisions remain distinguishable',
|
||||
'expired unattached cleaning photo retention cleanup',
|
||||
'task-level cleaning exemption policy denial'
|
||||
]
|
||||
}, null, 2));
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user