feat(M09-B): 完成清洁规则与照片验收闭环

This commit is contained in:
Codex
2026-08-10 21:49:07 +08:00
parent b507e87999
commit bc7cb8fab9
24 changed files with 1650 additions and 81 deletions
+91 -5
View File
@@ -77,6 +77,25 @@ const app = await buildApp({
},
async assertCanUploadPhoto(input) {
calls.push(['assertCanUploadPhoto', input]);
return { storeId: '11', maxPhotoCount: 3 };
},
async recordPhotoUpload(input) {
calls.push(['recordPhotoUpload', input]);
return { id: '701', ...input.image };
},
async listTemplates(input) {
calls.push(['listTemplates', input]);
return [{
id: '801', scopeKey: 'STORE:11', scopeType: 'STORE', storeId: '11', roomId: null,
name: '门店清洁', requirement: '桌面与地面', photoRequired: true,
minPhotoCount: 2, maxPhotoCount: 3, exemptPolicy: 'BEFORE_START',
version: 2, status: 'ACTIVE', appliesToExistingTasks: false
}];
},
async upsertTemplate(input) {
calls.push(['upsertTemplate', input]);
return { id: '801', scopeKey: `STORE:${input.scopeId}`, ...input,
storeId: input.scopeId, roomId: null, version: 3, appliesToExistingTasks: false };
},
async submit(input) {
calls.push(['submit', input]);
@@ -116,6 +135,15 @@ const app = await buildApp({
createdAt: new Date()
}];
},
async listSubmissions(input) {
calls.push(['listSubmissions', input]);
return [{
id: '9101', taskId: input.taskId, revision: 2, status: 'REJECTED',
photoUrls: ['https://api.txyundm.cn/uploads/old.webp'], note: 'first',
rejectReason: 'photo is unclear', submittedBy: '31', reviewedBy: '31',
submittedAt: new Date(), reviewedAt: new Date()
}];
},
async addMember(input) {
calls.push(['addMember', input]);
return [member('LEAD', '31', 500), member('ASSIST', input.cleanerUserId, input.rewardCents)];
@@ -171,6 +199,14 @@ const app = await buildApp({
calls.push(['reclaimTimeouts', input]);
return { reclaimed: 2, taskIds: ['101', '102'] };
},
async claimExpiredPhotoUploads(input) {
calls.push(['claimExpiredPhotoUploads', input]);
return [{ id: '701', storagePath: 'tenants/7/stores/11/orphan.webp' }];
},
async markPhotoUploadsDeleted(input) {
calls.push(['markPhotoUploadsDeleted', input]);
return { deleted: input.photoIds.length };
},
async stats(input) {
calls.push(['stats', input]);
return {
@@ -281,19 +317,44 @@ const app = await buildApp({
async storeImage(input) {
calls.push(['storeImage', input]);
return {
storagePath: 'tenants/7/shared/cleaning.webp',
publicUrl: 'https://api.txyundm.cn/uploads/tenants/7/shared/cleaning.webp',
storagePath: 'tenants/7/stores/11/cleaning.webp',
publicUrl: 'https://api.txyundm.cn/uploads/tenants/7/stores/11/cleaning.webp',
mimeType: 'image/webp',
byteSize: input.body.length,
width: 640,
height: 480,
checksumSha256: 'abc'
};
},
async deleteImage(storagePath) {
calls.push(['deleteImage', storagePath]);
}
}
}
});
const templates = await app.inject({
method: 'GET',
url: '/admin-api/cleaning/templates',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(templates.statusCode, 200);
assert.equal(templates.json().data[0].scopeKey, 'STORE:11');
const updatedTemplate = await app.inject({
method: 'PUT',
url: '/admin-api/cleaning/templates',
headers: { authorization: `Bearer ${token}` },
payload: {
scopeType: 'STORE', scopeId: '11', name: '门店清洁', requirement: '桌面与地面',
photoRequired: true, minPhotoCount: 2, maxPhotoCount: 3,
exemptPolicy: 'BEFORE_START', status: 'ACTIVE'
}
});
assert.equal(updatedTemplate.statusCode, 200);
assert.equal(updatedTemplate.json().data.appliesToExistingTasks, false);
assert.equal(calls.at(-1)[0], 'upsertTemplate');
const hall = await app.inject({
method: 'GET',
url: '/app-api/cleaning/tasks/hall?page=1&pageSize=10',
@@ -396,9 +457,11 @@ const photo = await app.inject({
payload: Buffer.from('fake-image')
});
assert.equal(photo.statusCode, 201);
assert.equal(photo.json().data.publicUrl, 'https://api.txyundm.cn/uploads/tenants/7/shared/cleaning.webp');
assert.equal(calls.at(-2)[0], 'assertCanUploadPhoto');
assert.equal(calls.at(-1)[0], 'storeImage');
assert.equal(photo.json().data.publicUrl, 'https://api.txyundm.cn/uploads/tenants/7/stores/11/cleaning.webp');
assert.equal(calls.at(-3)[0], 'assertCanUploadPhoto');
assert.equal(calls.at(-2)[0], 'storeImage');
assert.equal(calls.at(-2)[1].storeId, '11');
assert.equal(calls.at(-1)[0], 'recordPhotoUpload');
const submit = await app.inject({
method: 'POST',
@@ -498,6 +561,16 @@ assert.equal(calls.at(-1)[0], 'listEvents');
assert.equal(calls.at(-1)[1].taskId, '101');
assert.equal(events.json().data[0].action, 'SUBMIT');
const submissions = await app.inject({
method: 'GET',
url: '/admin-api/cleaning/tasks/101/submissions',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(submissions.statusCode, 200);
assert.equal(calls.at(-1)[0], 'listSubmissions');
assert.equal(submissions.json().data[0].revision, 2);
assert.equal(submissions.json().data[0].photoUrls[0], 'https://api.txyundm.cn/uploads/old.webp');
const addedMember = await app.inject({
method: 'POST',
url: '/admin-api/cleaning/tasks/101/members',
@@ -664,6 +737,19 @@ assert.equal(reclaimed.statusCode, 200);
assert.equal(calls.at(-1)[0], 'reclaimTimeouts');
assert.equal(calls.at(-1)[1].olderThanMinutes, 30);
const cleanedPhotos = await app.inject({
method: 'POST',
url: '/admin-api/cleaning/photos/cleanup',
headers: { authorization: `Bearer ${token}` },
payload: { limit: 10 }
});
assert.equal(cleanedPhotos.statusCode, 200);
assert.deepEqual(cleanedPhotos.json().data, { claimed: 1, deleted: 1, failedIds: [] });
assert.equal(calls.at(-3)[0], 'claimExpiredPhotoUploads');
assert.equal(calls.at(-2)[0], 'deleteImage');
assert.equal(calls.at(-1)[0], 'markPhotoUploadsDeleted');
assert.deepEqual(calls.at(-1)[1].photoIds, ['701']);
const stats = await app.inject({
method: 'GET',
url: '/app-api/cleaning/stats',
+18 -1
View File
@@ -21,7 +21,18 @@ try {
assert.equal(image.mimeType, 'image/webp');
assert.ok(image.width <= 1920);
assert.match(image.storagePath, /^tenants\/7\/stores\/11\/.+\.webp$/);
assert.ok((await readFile(join(root, ...image.storagePath.split('/')))).length > 0);
const storedPath = join(root, ...image.storagePath.split('/'));
const storedBytes = await readFile(storedPath);
assert.ok(storedBytes.length > 0);
const storedMetadata = await sharp(storedBytes).metadata();
assert.equal(storedMetadata.exif, undefined);
assert.equal(storedMetadata.icc, undefined);
await assert.rejects(
() => storage.storeImage({
tenantId: '7', originalName: 'spoofed.jpg', contentType: 'image/jpeg', body: png
}),
(error) => error instanceof MediaValidationError && error.code === 'IMAGE_DECODE_FAILED'
);
await assert.rejects(
() => storage.storeImage({
tenantId: '7', originalName: 'bad.txt', contentType: 'text/plain',
@@ -29,6 +40,12 @@ try {
}),
(error) => error instanceof MediaValidationError && error.code === 'IMAGE_TYPE_INVALID'
);
await storage.deleteImage(image.storagePath);
await assert.rejects(() => readFile(storedPath), (error) => error.code === 'ENOENT');
await assert.rejects(
() => storage.deleteImage('../outside.webp'),
(error) => error instanceof MediaValidationError && error.code === 'IMAGE_PATH_INVALID'
);
} finally {
await rm(root, { recursive: true, force: true });
}
+19 -1
View File
@@ -108,6 +108,9 @@ const franchiseVerifySql = read('database/migrations/2026081003_m08d_franchise_l
const adminAuthUpSql = read('database/migrations/2026081004_m08d_admin_password_auth.up.sql');
const adminAuthDownSql = read('database/migrations/2026081004_m08d_admin_password_auth.down.sql');
const adminAuthVerifySql = read('database/migrations/2026081004_m08d_admin_password_auth.verify.sql');
const cleaningRulesUpSql = read('database/migrations/2026081005_m09b_cleaning_rules.up.sql');
const cleaningRulesDownSql = read('database/migrations/2026081005_m09b_cleaning_rules.down.sql');
const cleaningRulesVerifySql = read('database/migrations/2026081005_m09b_cleaning_rules.verify.sql');
const coreTables = [
'qipai_schema_migrations',
@@ -489,7 +492,22 @@ assert.match(adminAuthUpSql, /uq_qipai_admin_credentials_login/);
assert.match(adminAuthUpSql, /'2026081004'/);
assert.match(adminAuthDownSql, /DROP TABLE IF EXISTS qipai_admin_credentials/);
assert.match(adminAuthVerifySql, /idx_qipai_auth_sessions_refresh/);
for (const table of [
'qipai_cleaning_templates', 'qipai_cleaning_task_photos', 'qipai_cleaning_task_submissions'
]) {
assert.match(cleaningRulesUpSql, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`));
assert.match(cleaningRulesDownSql, new RegExp(`DROP TABLE IF EXISTS ${table}`));
assert.match(cleaningRulesVerifySql, new RegExp(`'${table}'`));
}
for (const column of [
'cleaning_template_id', 'photo_required', 'min_photo_count', 'max_photo_count',
'exempt_policy', 'rework_count'
]) assert.match(cleaningRulesUpSql, new RegExp(`ADD COLUMN ${column}`));
assert.match(cleaningRulesUpSql, /uq_qipai_cleaning_submission_revision/);
assert.match(cleaningRulesUpSql, /retention_until DATETIME\(3\) NOT NULL/);
assert.match(cleaningRulesDownSql, /DROP FOREIGN KEY fk_qipai_cleaning_tasks_template/);
assert.match(cleaningRulesVerifySql, /'2026081005'/);
assert.match(franchiseDownSql, /DROP TABLE IF EXISTS qipai_franchise_follow_ups/);
assert.match(franchiseVerifySql, /idx_qipai_franchise_follow_up_history/);
console.log('PASS: M01-B through M08-D migration contracts are present.');
console.log('PASS: M01-B through M09-B migration contracts are present.');
+4 -2
View File
@@ -43,7 +43,8 @@ assert.match(plan.file, /2026062729_m08b_cleaning_transfer_state\.up\.sql/);
assert.match(plan.file, /2026081001_m08c_staff_management_access\.up\.sql/);
assert.match(plan.file, /2026081002_m08d_content_asset_scope\.up\.sql/);
assert.match(plan.file, /2026081003_m08d_franchise_leads\.up\.sql/);
assert.match(plan.file, /2026081004_m08d_admin_password_auth\.up\.sql$/);
assert.match(plan.file, /2026081004_m08d_admin_password_auth\.up\.sql/);
assert.match(plan.file, /2026081005_m09b_cleaning_rules\.up\.sql$/);
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
assert.ok(plan.statements.length >= 11);
@@ -51,7 +52,8 @@ const verifyPlan = await loadMigrationPlan('verify');
assert.match(verifyPlan.statements[90], /^SELECT column_name/);
assert.match(verifyPlan.statements[91], /^SELECT index_name/);
assert.match(verifyPlan.file, /2026081003_m08d_franchise_leads\.verify\.sql/);
assert.match(verifyPlan.file, /2026081004_m08d_admin_password_auth\.verify\.sql$/);
assert.match(verifyPlan.file, /2026081004_m08d_admin_password_auth\.verify\.sql/);
assert.match(verifyPlan.file, /2026081005_m09b_cleaning_rules\.verify\.sql$/);
const calls = [];
const fakePool = {
+150 -11
View File
@@ -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 {
@@ -6,6 +6,7 @@ import {
import { DeviceControlError } from '../dist/devices/device-control-service.js';
const calls = [];
let activeRoomOrder = false;
let currentOrder = {
id: 31,
tenantId: 7,
@@ -17,6 +18,11 @@ let currentOrder = {
};
const service = new OrderDeviceAutomationService({
async execute(sql, params) {
if (sql.includes('id <> ?')) {
assert.equal(params[0], '7');
assert.equal(params[2], '31');
return [activeRoomOrder ? [{ id: 32 }] : [], []];
}
if (sql.includes('FROM qipai_orders')) {
assert.equal(params[0], '7');
assert.equal(params[1], '31');
@@ -91,6 +97,17 @@ await service.handleTask(task({
assert.equal(calls.at(-2)[0], 'cancelTask');
assert.equal(calls.at(-1)[2].on, false);
currentOrder = { ...currentOrder, status: 'FINISHED' };
activeRoomOrder = true;
const callCountBeforeProtectedFinish = calls.length;
const protectedFinish = await service.handleTask(task({
tenantId: '7', orderId: '31', event: 'ORDER_FINISHED', traceId: 'm09b-finish'
}));
assert.equal(protectedFinish.skipped, true);
assert.equal(protectedFinish.reason, 'ROOM_HAS_ACTIVE_ORDER');
assert.equal(calls.length, callCountBeforeProtectedFinish);
activeRoomOrder = false;
currentOrder = { ...currentOrder, roomId: 52, status: 'IN_PROGRESS' };
const changed = await service.handleTask(task({
tenantId: '7',