feat(cleaning): close settlement integrity and reconciliation

This commit is contained in:
Codex
2026-08-11 02:07:59 +08:00
parent 5e0f5e39ee
commit 1f1071501f
23 changed files with 1562 additions and 208 deletions
@@ -60,6 +60,8 @@ const expectedTables = [
'qipai_async_tasks',
'qipai_audit_logs',
'qipai_auth_sessions',
'qipai_cleaning_settlement_events',
'qipai_cleaning_settlement_reversals',
'qipai_cleaning_task_photos',
'qipai_cleaning_task_submissions',
'qipai_cleaning_templates',
@@ -142,13 +144,14 @@ 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', '2026081005']
'2026062220', '2026081002', '2026081003', '2026081004', '2026081005',
'2026081006']
);
return rows;
}
@@ -1836,7 +1839,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, '2026081005');
assert.equal(overview.latestMigration.version, '2026081006');
assert.ok(overview.counts.userCount > 0);
await repository.updateTenant(actor, context.tenantId, {
name: overview.tenant.name, timezone: overview.tenant.timezone
@@ -2957,8 +2960,163 @@ async function assertCleaningTaskTransactions(pool, context) {
exemptPolicy: snapshotRows[0].exemptPolicy
}, { version: 1, minPhotoCount: 2, exemptPolicy: 'BEFORE_START' });
const collaborativeTaskId = await insertTask({
status: 'COMPLETED', cleanerUserId: firstCleanerId, rewardCents: 100,
claimedAt: new Date(), completedAt: new Date()
});
await insertMember({
taskId: collaborativeTaskId, userId: firstCleanerId, memberRole: 'LEAD', rewardCents: 70
});
await insertMember({
taskId: collaborativeTaskId, userId: secondCleanerId, memberRole: 'ASSIST', rewardCents: 30
});
const concurrentSettlementResults = await Promise.allSettled([
repository.generateSettlement({
...managerActor('m09c-generate-one'), cleanerUserId: firstCleanerId, storeId
}),
repository.generateSettlement({
...managerActor('m09c-generate-two'), cleanerUserId: firstCleanerId, storeId
})
]);
assert.equal(
concurrentSettlementResults.filter((result) => result.status === 'fulfilled').length,
1
);
const rejectedSettlement = concurrentSettlementResults.find((result) => result.status === 'rejected');
assert.ok(rejectedSettlement?.reason instanceof CleaningTaskError);
assert.ok([
'CLEANING_SETTLEMENT_EMPTY', 'CLEANING_SETTLEMENT_CONCURRENTLY_CLAIMED'
].includes(rejectedSettlement.reason.code));
const leadDraft = concurrentSettlementResults.find((result) => result.status === 'fulfilled').value;
const assistDraft = await repository.generateSettlement({
...managerActor('m09c-generate-assist'), cleanerUserId: secondCleanerId, storeId
});
const [activeShareRows] = await pool.query(
`SELECT task_id AS taskId, cleaner_user_id AS cleanerUserId, COUNT(*) AS total
FROM qipai_cleaning_settlement_items
WHERE tenant_id = ? AND reversed_at IS NULL
AND task_id IN (?, ?)
GROUP BY task_id, cleaner_user_id`,
[context.tenantId, lifecycleTaskId, collaborativeTaskId]
);
assert.ok(activeShareRows.length >= 3);
assert.ok(activeShareRows.every((row) => Number(row.total) === 1));
const [draftTaskRows] = await pool.query(
`SELECT id, status, settled_at AS settledAt
FROM qipai_cleaning_tasks WHERE tenant_id = ? AND id IN (?, ?) ORDER BY id`,
[context.tenantId, lifecycleTaskId, collaborativeTaskId]
);
assert.ok(draftTaskRows.every((row) => row.status === 'COMPLETED' && row.settledAt === null));
const firstStatsInDraft = await repository.stats({
...cleanerActor(firstCleanerId, 'm09c-stats-draft')
});
assert.ok(firstStatsInDraft.pendingSettlementCents >= 170);
assert.equal(firstStatsInDraft.settledRewardCents, 0);
await assert.rejects(
() => repository.removeMember({
...managerActor('m09c-member-locked'), taskId: collaborativeTaskId,
cleanerUserId: secondCleanerId
}),
(error) => error instanceof CleaningTaskError
&& error.code === 'CLEANING_MEMBER_SETTLEMENT_LOCKED'
);
const cancelledLead = await repository.cancelSettlement({
...managerActor('m09c-cancel-draft'), settlementId: leadDraft.id,
reason: 'M09-C cancellation roundtrip'
});
assert.equal(cancelledLead.status, 'CANCELLED');
const cancelledDetail = await repository.getSettlementDetail({
...managerActor('m09c-cancel-detail'), settlementId: leadDraft.id
});
assert.equal(cancelledDetail.reversals.length, 1);
assert.equal(cancelledDetail.reversals[0].amountDeltaCents, -leadDraft.totalRewardCents);
assert.ok(cancelledDetail.items.every((item) => item.reversedAt instanceof Date));
assert.ok(cancelledDetail.events.some((event) => event.action === 'CANCEL'));
const retryLead = await repository.generateSettlement({
...managerActor('m09c-generate-retry'), cleanerUserId: firstCleanerId, storeId
});
await repository.confirmSettlement({
...managerActor('m09c-confirm-retry'), settlementId: retryLead.id
});
await repository.beginSettlementPayout({
...managerActor('m09c-payout-begin'), settlementId: retryLead.id,
payoutChannel: 'WECHAT_TRANSFER', payoutReference: `CLP${retryLead.id}`
});
await assert.rejects(
() => repository.cancelSettlement({
...managerActor('m09c-cancel-processing'), settlementId: retryLead.id,
reason: 'must not cancel uncertain transfer'
}),
(error) => error instanceof CleaningTaskError
&& error.code === 'CLEANING_SETTLEMENT_PAYOUT_PENDING'
);
await repository.recordSettlementPayoutFailure({
...managerActor('m09c-payout-explicit-fail'), settlementId: retryLead.id,
payoutChannel: 'WECHAT_TRANSFER', payoutReference: `CLP${retryLead.id}`,
error: 'PROVIDER_CONFIRMED_FAIL', providerConfirmed: true
});
await repository.cancelSettlement({
...managerActor('m09c-cancel-failed'), settlementId: retryLead.id,
reason: 'provider confirmed failure'
});
const paidLead = await repository.generateSettlement({
...managerActor('m09c-generate-paid'), cleanerUserId: firstCleanerId, storeId
});
await repository.confirmSettlement({
...managerActor('m09c-confirm-paid'), settlementId: paidLead.id
});
await repository.markSettlementPaid({
...managerActor('m09c-manual-paid-lead'), settlementId: paidLead.id,
payoutChannel: 'MANUAL', payoutReference: `MANUAL-${paidLead.id}`,
manualOverride: true
});
const [partiallyPaidRows] = await pool.query(
`SELECT status FROM qipai_cleaning_tasks WHERE tenant_id = ? AND id = ?`,
[context.tenantId, collaborativeTaskId]
);
assert.equal(partiallyPaidRows[0].status, 'COMPLETED');
await repository.confirmSettlement({
...managerActor('m09c-confirm-assist'), settlementId: assistDraft.id
});
await repository.markSettlementPaid({
...managerActor('m09c-manual-paid-assist'), settlementId: assistDraft.id,
payoutChannel: 'MANUAL', payoutReference: `MANUAL-${assistDraft.id}`,
manualOverride: true
});
const [fullyPaidRows] = await pool.query(
`SELECT status, settled_at IS NOT NULL AS settled
FROM qipai_cleaning_tasks WHERE tenant_id = ? AND id = ?`,
[context.tenantId, collaborativeTaskId]
);
assert.deepEqual(
{ status: fullyPaidRows[0].status, settled: Number(fullyPaidRows[0].settled) },
{ status: 'SETTLED', settled: 1 }
);
const firstStatsPaid = await repository.stats({
...cleanerActor(firstCleanerId, 'm09c-stats-paid')
});
assert.ok(firstStatsPaid.settledRewardCents >= 170);
const paidPage = await repository.listSettlements({
...managerActor('m09c-list-paid'), page: 1, pageSize: 50, status: 'PAID', storeId
});
const paidExport = await repository.exportSettlements({
...managerActor('m09c-export-paid'), status: 'PAID', storeId
});
assert.deepEqual(
paidExport.items.slice(0, paidPage.items.length).map((item) => item.id),
paidPage.items.map((item) => item.id)
);
assert.equal(paidExport.total, paidPage.total);
console.log(
'PASS: M09-A/M09-B cleaning transactions, template snapshots, photo revisions and exemption rules are consistent.'
'PASS: M09-A/M09-B/M09-C cleaning transactions, rules and settlement integrity are consistent.'
);
}
@@ -3009,7 +3167,8 @@ try {
{ version: '2026081002', name: 'm08d_content_asset_scope' },
{ version: '2026081003', name: 'm08d_franchise_leads' },
{ version: '2026081004', name: 'm08d_admin_password_auth' },
{ version: '2026081005', name: 'm09b_cleaning_rules' }
{ version: '2026081005', name: 'm09b_cleaning_rules' },
{ version: '2026081006', name: 'm09c_cleaning_settlement_integrity' }
]);
await assertTaskDurability(pool);
const loginContext = await assertPlatformTenantIsolation(pool);
@@ -3038,7 +3197,7 @@ try {
await executeMigrationPlan(pool, plans.down);
assert.deepEqual(await readCoreTables(pool), []);
await assertLegacyCompatibility(pool);
console.log('PASS: down removed all M01-B through M09-B migration tables.');
console.log('PASS: down removed all M01-B through M09-C migration tables.');
await executeMigrationPlan(pool, plans.up);
await executeMigrationPlan(pool, plans.verify);
@@ -3067,7 +3226,8 @@ try {
{ version: '2026081002', name: 'm08d_content_asset_scope' },
{ version: '2026081003', name: 'm08d_franchise_leads' },
{ version: '2026081004', name: 'm08d_admin_password_auth' },
{ version: '2026081005', name: 'm09b_cleaning_rules' }
{ version: '2026081005', name: 'm09b_cleaning_rules' },
{ version: '2026081006', name: 'm09c_cleaning_settlement_integrity' }
]);
await assertLegacyCompatibility(pool);
console.log('PASS: second up and verify restored the schema.');
@@ -3208,7 +3368,14 @@ try {
'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'
'task-level cleaning exemption policy denial',
'concurrent cleaning settlement generation has one winner per member share',
'draft and confirmed rewards remain pending until paid',
'active settlement freezes member reward allocation',
'settlement reversal preserves history and releases the share',
'processing payout blocks cancellation until provider-confirmed failure',
'collaborative task settles only after every member share is paid',
'settlement page and CSV export reuse identical query ordering'
]
}, null, 2));
} finally {