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
+51 -4
View File
@@ -60,6 +60,8 @@ const actor = {
mode: 'API'
});
assert.equal(result.transferState, 'SUCCESS');
assert.equal(harness.repository.begun.length, 1);
assert.equal(harness.repository.begun[0].payoutReference, 'CLP501');
assert.equal(harness.repository.paid[0].payoutChannel, 'WECHAT_TRANSFER');
assert.equal(harness.state.transferInput.amountCents, 1200);
assert.equal(harness.state.transferInput.openid, 'openid-cleaner');
@@ -79,6 +81,20 @@ const actor = {
assert.equal(harness.repository.paid.length, 0);
}
{
const harness = createHarness({ payoutState: 'PROCESSING' });
await assert.rejects(
() => harness.service.executeWechatTransfer({
...actor,
settlementId: '501',
mode: 'API'
}),
(error) => error instanceof CleaningPayoutError
&& error.code === 'CLEANING_PAYOUT_ALREADY_STARTED'
);
assert.equal(harness.repository.begun.length, 0);
}
{
const harness = createHarness({ queryState: 'SUCCESS' });
const result = await harness.service.syncWechatTransfer({
@@ -87,7 +103,7 @@ const actor = {
note: 'poll success'
});
assert.equal(result.transferState, 'SUCCESS');
assert.equal(harness.state.queryOutBillNo, 'CLS-20260627-501');
assert.equal(harness.state.queryOutBillNo, 'CLP501');
assert.equal(harness.repository.paid[0].payoutReference, 'wx-transfer-query-501');
}
@@ -115,7 +131,7 @@ const actor = {
const harness = createHarness({
notificationPayload: {
mch_id: '1900000109',
out_bill_no: 'CLS-20260627-501',
out_bill_no: 'CLP501',
transfer_bill_no: 'wx-notify-501',
state: 'SUCCESS'
}
@@ -132,6 +148,27 @@ const actor = {
assert.equal(harness.repository.paid[0].payoutReference, 'wx-notify-501');
}
{
const harness = createHarness({
status: 'PAID',
payoutState: 'SUCCESS',
notificationPayload: {
mch_id: '1900000109',
out_bill_no: 'CLP501',
transfer_bill_no: 'wx-notify-501',
state: 'SUCCESS'
}
});
const result = await harness.service.processWechatTransferNotification({
timestamp: '1782700000',
nonce: 'notify-nonce',
serial: 'PLATFORM-SERIAL',
signature: 'signature'
}, '{"resource":"encrypted"}', 'notify-replay-trace');
assert.equal(result.idempotent, true);
assert.equal(harness.repository.paid.length, 0);
}
{
const harness = createHarness({ transferState: 'FAIL', failReason: 'REAL_NAME_CHECK_FAILED' });
const result = await harness.service.executeWechatTransfer({
@@ -175,12 +212,14 @@ function createHarness(options = {}) {
id: '501',
settlementNo: 'CLS-20260627-501',
cleanerUserId: '31',
generatedBy: '21',
storeId: '11',
status: options.status ?? 'CONFIRMED',
totalRewardCents: 1200,
payoutChannel: options.payoutChannel ?? 'WECHAT_TRANSFER',
payoutReference: options.payoutReference ?? 'CLS-20260627-501',
payoutState: options.payoutState ?? 'WAIT_USER_CONFIRM',
payoutRequestNo: options.payoutRequestNo ?? 'CLP501',
payoutReference: options.payoutReference ?? 'CLP501',
payoutState: options.payoutState ?? '',
payoutPackageInfo: options.payoutPackageInfo ?? 'package-info'
},
account: {
@@ -210,9 +249,17 @@ function createHarness(options = {}) {
}
};
const repository = {
begun: [],
paid: [],
failures: [],
pending: [],
async beginSettlementPayout(input) {
this.begun.push(input);
state.settlement.payoutChannel = input.payoutChannel;
state.settlement.payoutReference = input.payoutReference;
state.settlement.payoutState = 'PROCESSING';
return { ...state.settlement };
},
async markSettlementPaid(input) {
this.paid.push(input);
return { ...state.settlement, status: 'PAID', payoutReference: input.payoutReference };
+41 -1
View File
@@ -160,11 +160,21 @@ const app = await buildApp({
calls.push(['listSettlements', input]);
return { items: [settlementResponse(input.status || 'DRAFT')], total: 1, page: input.page, pageSize: input.pageSize };
},
async exportSettlements(input) {
calls.push(['exportSettlements', input]);
return { items: [settlementResponse(input.status || 'DRAFT')], total: 1, exported: 1, truncated: false };
},
async getSettlementDetail(input) {
calls.push(['getSettlementDetail', input]);
return {
settlement: settlementResponse('DRAFT'),
items: [settlementItemResponse()]
items: [settlementItemResponse()],
events: [{
id: '701', settlementId: input.settlementId, fromStatus: null, toStatus: 'DRAFT',
action: 'GENERATE', actorId: input.userId, traceId: input.traceId,
note: '', createdAt: new Date()
}],
reversals: []
};
},
async generateSettlement(input) {
@@ -175,6 +185,15 @@ const app = await buildApp({
calls.push(['confirmSettlement', input]);
return settlementResponse('CONFIRMED');
},
async cancelSettlement(input) {
calls.push(['cancelSettlement', input]);
return {
...settlementResponse('CANCELLED'),
cancelledBy: input.userId,
cancelledAt: new Date(),
note: input.reason
};
},
async markSettlementPaid(input) {
calls.push(['markSettlementPaid', input]);
return {
@@ -385,6 +404,16 @@ assert.equal(calls.at(-1)[1].status, 'SUBMITTED');
assert.equal(calls.at(-1)[1].storeId, '11');
assert.equal(calls.at(-1)[1].cleanerUserId, '31');
const settlementExport = await app.inject({
method: 'GET',
url: '/admin-api/cleaning/settlements/export?status=DRAFT&payoutState=FAIL&storeId=11&cleanerUserId=31',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(settlementExport.statusCode, 200);
assert.equal(calls.at(-1)[0], 'exportSettlements');
assert.equal(calls.at(-1)[1].status, 'DRAFT');
assert.equal(settlementExport.json().data.exported, 1);
const appManageList = await app.inject({
method: 'GET',
url: '/app-api/management/cleaning/tasks?status=SUBMITTED&storeId=11',
@@ -643,6 +672,17 @@ assert.equal(confirmedSettlement.statusCode, 200);
assert.equal(calls.at(-1)[0], 'confirmSettlement');
assert.equal(calls.at(-1)[1].settlementId, '501');
const cancelledSettlement = await app.inject({
method: 'POST',
url: '/admin-api/cleaning/settlements/501/cancel',
headers: { authorization: `Bearer ${token}` },
payload: { reason: 'duplicate weekly batch' }
});
assert.equal(cancelledSettlement.statusCode, 200);
assert.equal(cancelledSettlement.json().data.status, 'CANCELLED');
assert.equal(calls.at(-1)[0], 'cancelSettlement');
assert.equal(calls.at(-1)[1].reason, 'duplicate weekly batch');
const failedPayout = await app.inject({
method: 'POST',
url: '/admin-api/cleaning/settlements/501/payout-failure',
+27 -1
View File
@@ -111,6 +111,15 @@ const adminAuthVerifySql = read('database/migrations/2026081004_m08d_admin_passw
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 cleaningSettlementIntegrityUpSql = read(
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.up.sql'
);
const cleaningSettlementIntegrityDownSql = read(
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.down.sql'
);
const cleaningSettlementIntegrityVerifySql = read(
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.verify.sql'
);
const coreTables = [
'qipai_schema_migrations',
@@ -507,7 +516,24 @@ 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'/);
for (const table of [
'qipai_cleaning_settlement_events', 'qipai_cleaning_settlement_reversals'
]) {
assert.match(cleaningSettlementIntegrityUpSql, new RegExp('CREATE TABLE IF NOT EXISTS ' + table));
assert.match(cleaningSettlementIntegrityDownSql, new RegExp('DROP TABLE IF EXISTS ' + table));
assert.match(cleaningSettlementIntegrityVerifySql, new RegExp("'" + table + "'"));
}
assert.match(cleaningSettlementIntegrityUpSql, /amount_delta_cents BIGINT NOT NULL/);
assert.match(cleaningSettlementIntegrityUpSql, /ADD COLUMN cancelled_by/);
assert.match(cleaningSettlementIntegrityUpSql, /ADD COLUMN cancelled_at/);
assert.match(cleaningSettlementIntegrityUpSql, /ADD COLUMN payout_request_no/);
assert.match(cleaningSettlementIntegrityUpSql, /GENERATED ALWAYS AS/);
assert.match(cleaningSettlementIntegrityUpSql, /uq_qipai_cleaning_settlement_active_share/);
assert.match(cleaningSettlementIntegrityUpSql, /uq_qipai_cleaning_settlement_payout_request/);
assert.match(cleaningSettlementIntegrityDownSql, /DROP FOREIGN KEY fk_qipai_cleaning_settlement_items_reversal/);
assert.match(cleaningSettlementIntegrityDownSql, /ADD UNIQUE KEY uq_qipai_cleaning_settlement_item_task_user/);
assert.match(cleaningSettlementIntegrityVerifySql, /'2026081006'/);
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 M09-B migration contracts are present.');
console.log('PASS: M01-B through M09-C migration contracts are present.');
+11 -2
View File
@@ -44,7 +44,8 @@ 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, /2026081005_m09b_cleaning_rules\.up\.sql$/);
assert.match(plan.file, /2026081005_m09b_cleaning_rules\.up\.sql/);
assert.match(plan.file, /2026081006_m09c_cleaning_settlement_integrity\.up\.sql$/);
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
assert.ok(plan.statements.length >= 11);
@@ -53,7 +54,15 @@ 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, /2026081005_m09b_cleaning_rules\.verify\.sql$/);
assert.match(verifyPlan.file, /2026081005_m09b_cleaning_rules\.verify\.sql/);
assert.match(verifyPlan.file, /2026081006_m09c_cleaning_settlement_integrity\.verify\.sql$/);
const downPlan = await loadMigrationPlan('down');
assert.match(downPlan.file, /2026081006_m09c_cleaning_settlement_integrity\.down\.sql/);
assert.ok(
downPlan.file.indexOf('2026081006_m09c_cleaning_settlement_integrity.down.sql')
< downPlan.file.indexOf('2026081005_m09b_cleaning_rules.down.sql')
);
const calls = [];
const fakePool = {
@@ -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 {