feat(M09-B): 完成清洁规则与照片验收闭环
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user