feat(cleaning): close settlement integrity and reconciliation
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user