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