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
+63 -27
View File
@@ -16,10 +16,12 @@ interface SettlementPayoutRow extends RowDataPacket {
id: string;
settlementNo: string;
cleanerUserId: string;
generatedBy: string;
storeId: string | null;
status: 'DRAFT' | 'CONFIRMED' | 'PAID' | 'CANCELLED';
totalRewardCents: number;
payoutChannel: string;
payoutRequestNo: string;
payoutReference: string;
payoutState: string;
payoutPackageInfo: string;
@@ -56,7 +58,8 @@ export class CleaningPayoutService {
constructor(
private readonly pool: MySqlPool,
private readonly repository: Pick<CleaningTaskRepository,
'markSettlementPaid' | 'recordSettlementPayoutFailure' | 'recordSettlementPayoutPending'>,
'beginSettlementPayout' | 'markSettlementPaid'
| 'recordSettlementPayoutFailure' | 'recordSettlementPayoutPending'>,
private readonly client: WechatPayClient,
private readonly credentials: ReadonlyMap<string, WechatPayCredential>,
private readonly mockEnabled: boolean
@@ -213,6 +216,9 @@ export class CleaningPayoutService {
if (settlement.status !== 'CONFIRMED') {
throw new CleaningPayoutError('CLEANING_PAYOUT_SETTLEMENT_NOT_CONFIRMED');
}
if (['PROCESSING', 'WAIT_USER_CONFIRM', 'SUCCESS'].includes(settlement.payoutState)) {
throw new CleaningPayoutError('CLEANING_PAYOUT_ALREADY_STARTED');
}
if (Number(settlement.totalRewardCents) <= 0) {
throw new CleaningPayoutError('CLEANING_PAYOUT_AMOUNT_INVALID');
}
@@ -234,6 +240,7 @@ export class CleaningPayoutService {
...input,
payoutChannel: 'WECHAT_TRANSFER_MOCK',
payoutReference: outBillNo,
providerConfirmed: true,
note: input.note
});
return { settlement: paid, idempotent: false, transferState: 'SUCCESS' };
@@ -245,6 +252,12 @@ export class CleaningPayoutService {
);
const sceneId = credential.transferSceneId;
if (!sceneId) throw new CleaningPayoutError('WECHAT_TRANSFER_SCENE_NOT_CONFIGURED');
await this.repository.beginSettlementPayout({
...input,
payoutChannel: 'WECHAT_TRANSFER',
payoutReference: outBillNo,
note: input.note
});
try {
const result = await this.client.createMerchantTransfer(credential, {
outBillNo,
@@ -260,6 +273,7 @@ export class CleaningPayoutService {
...input,
payoutChannel: 'WECHAT_TRANSFER',
payoutReference: result.transferBillNo || result.outBillNo,
providerConfirmed: true,
note: input.note
});
return { settlement: paid, idempotent: false, transferState: result.state };
@@ -270,6 +284,7 @@ export class CleaningPayoutService {
payoutChannel: 'WECHAT_TRANSFER',
payoutReference: result.transferBillNo || result.outBillNo,
error: result.failReason || 'WECHAT_TRANSFER_FAILED',
providerConfirmed: true,
note: input.note
});
return { settlement: failed, idempotent: false, transferState: result.state };
@@ -284,15 +299,8 @@ export class CleaningPayoutService {
});
return { settlement: pending, idempotent: false, transferState: result.state };
} catch (error) {
if (error instanceof WechatPayError) {
await this.repository.recordSettlementPayoutFailure({
...input,
payoutChannel: 'WECHAT_TRANSFER',
payoutReference: outBillNo,
error: error.code,
note: input.note
});
}
// A transport/API exception is not proof that WeChat rejected the transfer.
// Keep PROCESSING so cancellation and duplicate payout are blocked until sync/reconciliation.
throw error;
}
}
@@ -327,6 +335,7 @@ export class CleaningPayoutService {
...input,
payoutChannel: 'WECHAT_TRANSFER',
payoutReference: result.transferBillNo || result.outBillNo,
providerConfirmed: true,
note: input.note
});
return { settlement: paid, idempotent: false, transferState: result.state };
@@ -337,6 +346,7 @@ export class CleaningPayoutService {
payoutChannel: 'WECHAT_TRANSFER',
payoutReference: result.transferBillNo || result.outBillNo,
error: result.failReason || 'WECHAT_TRANSFER_FAILED',
providerConfirmed: true,
note: input.note
});
return { settlement: failed, idempotent: false, transferState: result.state };
@@ -364,20 +374,29 @@ export class CleaningPayoutService {
}
const outBillNo = stringPayload(payload, 'out_bill_no');
const transferBillNo = optionalStringPayload(payload, 'transfer_bill_no');
const settlement = await this.findSettlementByTransferReference(outBillNo, transferBillNo);
const settlement = await this.findSettlementByTransferReference(
credential.merchantId,
outBillNo,
transferBillNo
);
const actor: CleaningActor = {
tenantId: settlement.tenantId,
userId: '0',
userId: String(settlement.generatedBy),
access: { roles: ['PLATFORM_ADMIN'], capabilities: ['tenant.manage'], storeIds: [] },
traceId
traceId,
actorType: 'SYSTEM'
};
const state = stringPayload(payload, 'state');
if (settlement.status === 'PAID' && state === 'SUCCESS') {
return { settlement, transferState: state, idempotent: true };
}
if (state === 'SUCCESS') {
const paid = await this.repository.markSettlementPaid({
...actor,
settlementId: settlement.id,
payoutChannel: 'WECHAT_TRANSFER',
payoutReference: transferBillNo || outBillNo,
providerConfirmed: true,
note: '微信转账回调确认成功'
});
return { settlement: paid, transferState: state, idempotent: false };
@@ -389,6 +408,7 @@ export class CleaningPayoutService {
payoutChannel: 'WECHAT_TRANSFER',
payoutReference: transferBillNo || outBillNo,
error: optionalStringPayload(payload, 'fail_reason') || 'WECHAT_TRANSFER_FAILED',
providerConfirmed: true,
note: '微信转账回调确认失败'
});
return { settlement: failed, transferState: state, idempotent: false };
@@ -408,8 +428,10 @@ export class CleaningPayoutService {
private async loadSettlement(tenantId: string, settlementId: string) {
const [rows] = await this.pool.execute<SettlementPayoutRow[]>(
`SELECT id, settlement_no AS settlementNo, cleaner_user_id AS cleanerUserId,
generated_by AS generatedBy,
store_id AS storeId, status, total_reward_cents AS totalRewardCents,
payout_channel AS payoutChannel, payout_reference AS payoutReference,
payout_channel AS payoutChannel, payout_request_no AS payoutRequestNo,
payout_reference AS payoutReference,
payout_state AS payoutState, payout_package_info AS payoutPackageInfo
FROM qipai_cleaning_settlements
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL LIMIT 1`,
@@ -468,19 +490,33 @@ export class CleaningPayoutService {
throw new WechatPayError('WECHAT_CERTIFICATE_NOT_FOUND');
}
private async findSettlementByTransferReference(outBillNo: string, transferBillNo: string) {
private async findSettlementByTransferReference(
merchantId: string,
outBillNo: string,
transferBillNo: string
) {
const references = transferBillNo ? [outBillNo, transferBillNo] : [outBillNo];
const [rows] = await this.pool.execute<Array<SettlementPayoutRow & { tenantId: string }>>(
`SELECT tenant_id AS tenantId, id, settlement_no AS settlementNo,
cleaner_user_id AS cleanerUserId, store_id AS storeId, status,
total_reward_cents AS totalRewardCents, payout_channel AS payoutChannel,
payout_reference AS payoutReference, payout_state AS payoutState,
payout_package_info AS payoutPackageInfo
FROM qipai_cleaning_settlements
WHERE payout_channel = 'WECHAT_TRANSFER' AND deleted_at IS NULL
AND payout_reference IN (${references.map(() => '?').join(',')})
ORDER BY updated_at DESC, id DESC LIMIT 1`,
references
`SELECT s.tenant_id AS tenantId, s.id, s.settlement_no AS settlementNo,
s.cleaner_user_id AS cleanerUserId, s.generated_by AS generatedBy,
s.store_id AS storeId, s.status,
s.total_reward_cents AS totalRewardCents, s.payout_channel AS payoutChannel,
s.payout_request_no AS payoutRequestNo,
s.payout_reference AS payoutReference, s.payout_state AS payoutState,
s.payout_package_info AS payoutPackageInfo
FROM qipai_cleaning_settlements s
INNER JOIN qipai_collection_accounts account
ON account.tenant_id = s.tenant_id
AND account.provider = 'WECHAT' AND account.merchant_id = ?
AND account.enabled = 1
AND (account.store_id IS NULL OR account.store_id <=> s.store_id)
WHERE s.payout_channel = 'WECHAT_TRANSFER'
AND s.deleted_at IS NULL
AND (s.payout_request_no IN (${references.map(() => '?').join(',')})
OR s.payout_reference IN (${references.map(() => '?').join(',')}))
ORDER BY (account.store_id IS NOT NULL) DESC,
s.updated_at DESC, s.id DESC LIMIT 1`,
[merchantId, ...references, ...references]
);
if (!rows[0]) throw new CleaningPayoutError('WECHAT_TRANSFER_SETTLEMENT_NOT_FOUND');
return rows[0];
@@ -488,8 +524,8 @@ export class CleaningPayoutService {
}
function normalizeOutBillNo(settlementNo: string, settlementId: string) {
const normalized = settlementNo.replace(/[^A-Za-z0-9_-]/g, '');
return (normalized || `CLP${settlementId}`).slice(0, 32);
void settlementNo;
return `CLP${settlementId}`;
}
function stringPayload(payload: Record<string, unknown>, key: string) {
+517 -102
View File
@@ -21,6 +21,7 @@ export interface CleaningActor {
userId: string;
access: AccessProfile;
traceId: string;
actorType?: 'USER' | 'SYSTEM';
}
interface CleaningTaskRow extends RowDataPacket {
@@ -154,9 +155,12 @@ interface SettlementRow extends RowDataPacket {
periodStart: Date | null;
periodEnd: Date | null;
paidBy: string | null;
cancelledBy: string | null;
confirmedAt: Date | null;
paidAt: Date | null;
cancelledAt: Date | null;
payoutChannel: string;
payoutRequestNo: string;
payoutReference: string;
payoutState: string;
payoutPackageInfo: string;
@@ -176,9 +180,31 @@ interface SettlementItemRow extends RowDataPacket {
cleanerUserId: string;
cleanerName: string;
rewardCents: number;
reversedAt: Date | null;
completedAt: Date | null;
createdAt: Date;
}
interface SettlementEventRow extends RowDataPacket {
id: string;
settlementId: string;
fromStatus: CleaningSettlementStatus | null;
toStatus: CleaningSettlementStatus;
action: string;
actorId: string;
actorType: 'USER' | 'SYSTEM';
traceId: string;
note: string;
createdAt: Date;
}
interface SettlementReversalRow extends RowDataPacket {
id: string;
settlementId: string;
reversalNo: string;
amountDeltaCents: number;
reason: string;
reversedBy: string;
createdAt: Date;
}
interface ManagerStatusStatsRow extends RowDataPacket {
status: CleaningTaskStatus;
total: number;
@@ -596,6 +622,7 @@ export class CleaningTaskRepository {
if (!['CLAIMED', 'STARTED', 'SUBMITTED', 'COMPLETED'].includes(task.status)) {
throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT');
}
await this.assertTaskSettlementMutable(connection, input.tenantId, input.taskId);
const [existingMembers] = await connection.execute<CleaningTaskMemberRow[]>(
`SELECT m.id, m.task_id AS taskId, m.user_id AS userId, '' AS nickname,
m.member_role AS memberRole, m.reward_cents AS rewardCents,
@@ -643,6 +670,7 @@ export class CleaningTaskRepository {
if (String(task.cleanerUserId) === input.cleanerUserId) {
throw new CleaningTaskError('CLEANING_MEMBER_LEAD_NOT_REMOVABLE');
}
await this.assertTaskSettlementMutable(connection, input.tenantId, input.taskId);
const [result] = await connection.execute<ResultSetHeader>(
`UPDATE qipai_cleaning_task_members
SET removed_at = UTC_TIMESTAMP(3), reward_cents = 0, updated_at = UTC_TIMESTAMP(3)
@@ -668,6 +696,21 @@ export class CleaningTaskRepository {
't.deleted_at IS NULL',
"t.status = 'COMPLETED'",
't.settled_at IS NULL',
`EXISTS (
SELECT 1 FROM qipai_cleaning_task_members pending_member
WHERE pending_member.tenant_id = t.tenant_id
AND pending_member.task_id = t.id
AND pending_member.removed_at IS NULL
AND pending_member.settled_at IS NULL
AND pending_member.reward_cents > 0
AND NOT EXISTS (
SELECT 1 FROM qipai_cleaning_settlement_items active_item
WHERE active_item.tenant_id = pending_member.tenant_id
AND active_item.task_id = pending_member.task_id
AND active_item.cleaner_user_id = pending_member.user_id
AND active_item.reversed_at IS NULL
)
)`,
storeScopeSql(input.access, 't.store_id')
];
const params: Array<string | number> = [input.tenantId];
@@ -676,15 +719,23 @@ export class CleaningTaskRepository {
params.push(input.storeId);
}
if (input.cleanerUserId) {
where.push(`(t.cleaner_user_id = ? OR EXISTS (
where.push(`EXISTS (
SELECT 1 FROM qipai_cleaning_task_members candidate_member
WHERE candidate_member.tenant_id = t.tenant_id
AND candidate_member.task_id = t.id
AND candidate_member.user_id = ?
AND candidate_member.removed_at IS NULL
AND candidate_member.settled_at IS NULL
))`);
params.push(input.cleanerUserId, input.cleanerUserId);
AND candidate_member.reward_cents > 0
AND NOT EXISTS (
SELECT 1 FROM qipai_cleaning_settlement_items active_item
WHERE active_item.tenant_id = candidate_member.tenant_id
AND active_item.task_id = candidate_member.task_id
AND active_item.cleaner_user_id = candidate_member.user_id
AND active_item.reversed_at IS NULL
)
)`);
params.push(input.cleanerUserId);
}
return this.listByWhere(where, params, input.page, input.pageSize, 't.completed_at ASC, t.id ASC');
}
@@ -694,66 +745,32 @@ export class CleaningTaskRepository {
payoutState?: CleaningSettlementPayoutState; storeId?: string; cleanerUserId?: string;
}) {
this.assertSettlement(input.access, 'read');
const where = [
's.tenant_id = ?',
's.deleted_at IS NULL',
storeScopeSql(input.access, 'COALESCE(s.store_id, 0)')
];
const params: Array<string | number> = [input.tenantId];
if (input.status) {
where.push('s.status = ?');
params.push(input.status);
}
if (input.payoutState) {
if (input.payoutState === 'NONE') {
where.push("s.payout_state = ''");
} else {
where.push('s.payout_state = ?');
params.push(input.payoutState);
}
}
if (input.storeId) {
where.push('s.store_id = ?');
params.push(input.storeId);
}
if (input.cleanerUserId) {
where.push('s.cleaner_user_id = ?');
params.push(input.cleanerUserId);
}
const whereSql = where.join(' AND ');
const pageSize = Math.max(1, Math.min(100, Math.trunc(input.pageSize)));
const offset = Math.max(0, (Math.trunc(input.page) - 1) * pageSize);
const [counts] = await this.pool.execute<CountRow[]>(
`SELECT COUNT(*) AS total FROM qipai_cleaning_settlements s WHERE ${whereSql}`,
params
);
const [rows] = await this.pool.execute<SettlementRow[]>(
`SELECT s.id, s.settlement_no AS settlementNo,
s.cleaner_user_id AS cleanerUserId, u.nickname AS cleanerName,
s.store_id AS storeId, st.name AS storeName, s.status,
s.task_count AS taskCount, s.total_reward_cents AS totalRewardCents,
s.period_start AS periodStart, s.period_end AS periodEnd,
s.paid_by AS paidBy, s.confirmed_at AS confirmedAt, s.paid_at AS paidAt,
s.payout_channel AS payoutChannel, s.payout_reference AS payoutReference,
s.payout_state AS payoutState, s.payout_package_info AS payoutPackageInfo,
s.payout_error AS payoutError,
s.note, s.created_at AS createdAt
FROM qipai_cleaning_settlements s
INNER JOIN qipai_users u ON u.tenant_id = s.tenant_id AND u.id = s.cleaner_user_id
LEFT JOIN qipai_stores st ON st.tenant_id = s.tenant_id AND st.id = s.store_id
WHERE ${whereSql}
ORDER BY s.created_at DESC, s.id DESC
LIMIT ${pageSize} OFFSET ${offset}`,
params
);
const { rows, total } = await this.querySettlementRows(input, pageSize, offset);
return {
items: rows.map(publicSettlement),
total: Number(counts[0]?.total ?? 0),
total,
page: input.page,
pageSize
};
}
async exportSettlements(input: CleaningActor & {
status?: CleaningSettlementStatus; payoutState?: CleaningSettlementPayoutState;
storeId?: string; cleanerUserId?: string;
}) {
this.assertSettlement(input.access, 'read');
const limit = 5000;
const { rows, total } = await this.querySettlementRows(input, limit, 0);
return {
items: rows.map(publicSettlement),
total,
exported: rows.length,
truncated: total > rows.length
};
}
async getSettlementDetail(input: CleaningActor & { settlementId: string }) {
this.assertSettlement(input.access, 'read');
const settlement = await this.getSettlementScoped(input);
@@ -762,7 +779,8 @@ export class CleaningTaskRepository {
t.task_no AS taskNo, o.order_no AS orderNo,
st.name AS storeName, r.name AS roomName, r.room_no AS roomNo,
i.cleaner_user_id AS cleanerUserId, u.nickname AS cleanerName,
i.reward_cents AS rewardCents, t.completed_at AS completedAt,
i.reward_cents AS rewardCents, i.reversed_at AS reversedAt,
t.completed_at AS completedAt,
i.created_at AS createdAt
FROM qipai_cleaning_settlement_items i
INNER JOIN qipai_cleaning_settlements s ON s.tenant_id = i.tenant_id
@@ -776,14 +794,39 @@ export class CleaningTaskRepository {
ORDER BY t.completed_at ASC, i.id ASC`,
[input.tenantId, input.settlementId]
);
return { settlement, items: items.map(publicSettlementItem) };
const [events] = await this.pool.execute<SettlementEventRow[]>(
`SELECT e.id, e.settlement_id AS settlementId, e.from_status AS fromStatus,
e.to_status AS toStatus, e.action, e.actor_id AS actorId,
e.actor_type AS actorType, e.trace_id AS traceId,
e.note, e.created_at AS createdAt
FROM qipai_cleaning_settlement_events e
WHERE e.tenant_id = ? AND e.settlement_id = ?
ORDER BY e.created_at ASC, e.id ASC`,
[input.tenantId, input.settlementId]
);
const [reversals] = await this.pool.execute<SettlementReversalRow[]>(
`SELECT r.id, r.settlement_id AS settlementId, r.reversal_no AS reversalNo,
r.amount_delta_cents AS amountDeltaCents, r.reason,
r.reversed_by AS reversedBy, r.created_at AS createdAt
FROM qipai_cleaning_settlement_reversals r
WHERE r.tenant_id = ? AND r.settlement_id = ?
ORDER BY r.created_at ASC, r.id ASC`,
[input.tenantId, input.settlementId]
);
return {
settlement,
items: items.map(publicSettlementItem),
events: events.map(publicSettlementEvent),
reversals: reversals.map(publicSettlementReversal)
};
}
async generateSettlement(input: CleaningActor & {
cleanerUserId: string; storeId?: string; note?: string;
}) {
this.assertSettlement(input.access, 'write');
return this.transaction(async (connection) => {
try {
return await this.transaction(async (connection) => {
await connection.execute(
`INSERT IGNORE INTO qipai_cleaning_task_members
(tenant_id, task_id, user_id, member_role, reward_cents, joined_at)
@@ -808,6 +851,13 @@ export class CleaningTaskRepository {
AND m.settled_at IS NULL AND m.reward_cents > 0
WHERE t.tenant_id = ?
AND t.status = 'COMPLETED' AND t.deleted_at IS NULL
AND NOT EXISTS (
SELECT 1 FROM qipai_cleaning_settlement_items active_item
WHERE active_item.tenant_id = m.tenant_id
AND active_item.task_id = m.task_id
AND active_item.cleaner_user_id = m.user_id
AND active_item.reversed_at IS NULL
)
${storeFilter} AND ${scopeSql}
ORDER BY t.completed_at ASC, t.id ASC
FOR UPDATE`,
@@ -815,10 +865,15 @@ export class CleaningTaskRepository {
);
if (tasks.length === 0) throw new CleaningTaskError('CLEANING_SETTLEMENT_EMPTY');
const storeIds = Array.from(new Set(tasks.map((task) => String(task.storeId))));
if (storeIds.length > 1
&& !input.access.capabilities.includes('tenant.manage')
&& !input.access.roles.includes('PLATFORM_ADMIN')) {
throw new CleaningTaskError('CLEANING_SETTLEMENT_STORE_REQUIRED');
}
const totalRewardCents = tasks.reduce((sum, task) => sum + Number(task.rewardCents), 0);
const periodStart = minDate(tasks.map((task) => task.completedAt));
const periodEnd = maxDate(tasks.map((task) => task.completedAt));
const settlementNo = `CLS-${Date.now()}-${input.cleanerUserId}`;
const settlementNo = `CLS-${Date.now()}-${input.cleanerUserId}-${input.traceId.slice(-8)}`;
const [created] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_cleaning_settlements
(tenant_id, settlement_no, cleaner_user_id, store_id, status, task_count,
@@ -832,39 +887,41 @@ export class CleaningTaskRepository {
]
);
const settlementId = String(created.insertId);
for (const task of tasks) {
await connection.execute(
`INSERT INTO qipai_cleaning_settlement_items
(tenant_id, settlement_id, task_id, cleaner_user_id, reward_cents)
VALUES (?, ?, ?, ?, ?)`,
[input.tenantId, settlementId, task.id, input.cleanerUserId, Number(task.rewardCents)]
);
try {
for (const task of tasks) {
await connection.execute(
`INSERT INTO qipai_cleaning_settlement_items
(tenant_id, settlement_id, task_id, cleaner_user_id, reward_cents)
VALUES (?, ?, ?, ?, ?)`,
[input.tenantId, settlementId, task.id, input.cleanerUserId, Number(task.rewardCents)]
);
}
} catch (error) {
if (isMySqlDuplicate(error)) {
throw new CleaningTaskError('CLEANING_SETTLEMENT_CONCURRENTLY_CLAIMED');
}
throw error;
}
await connection.execute(
`UPDATE qipai_cleaning_task_members
SET settled_at = UTC_TIMESTAMP(3)
WHERE tenant_id = ? AND user_id = ? AND task_id IN (${tasks.map(() => '?').join(',')})`,
[input.tenantId, input.cleanerUserId, ...tasks.map((task) => task.id)]
);
await connection.execute(
`UPDATE qipai_cleaning_tasks t
SET t.status = 'SETTLED', t.settled_at = UTC_TIMESTAMP(3)
WHERE t.tenant_id = ? AND t.id IN (${tasks.map(() => '?').join(',')})
AND NOT EXISTS (
SELECT 1 FROM qipai_cleaning_task_members pending
WHERE pending.tenant_id = t.tenant_id AND pending.task_id = t.id
AND pending.removed_at IS NULL AND pending.reward_cents > 0
AND pending.settled_at IS NULL
)`,
[input.tenantId, ...tasks.map((task) => task.id)]
await this.recordSettlementEventWithConnection(
connection, input, settlementId, null, 'DRAFT', 'GENERATE', input.note ?? '', {
cleanerUserId: input.cleanerUserId,
taskCount: tasks.length,
totalRewardCents
}
);
for (const task of tasks) {
await this.recordEventWithConnection(
connection, input, String(task.id), 'COMPLETED', 'COMPLETED', 'SETTLE_MEMBER', settlementNo
connection, input, String(task.id), 'COMPLETED', 'COMPLETED', 'ALLOCATE_SETTLEMENT', settlementNo
);
}
return this.getSettlement(connection, input.tenantId, settlementId);
});
return this.getSettlement(connection, input.tenantId, settlementId);
});
} catch (error) {
if (isMySqlSettlementContention(error)) {
throw new CleaningTaskError('CLEANING_SETTLEMENT_CONCURRENTLY_CLAIMED');
}
throw error;
}
}
async confirmSettlement(input: CleaningActor & { settlementId: string; note?: string }) {
@@ -879,12 +936,16 @@ export class CleaningTaskRepository {
[input.userId, input.note ?? '', input.note ?? '', input.tenantId, input.settlementId]
);
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_SETTLEMENT_STATUS_CONFLICT');
await this.recordSettlementEventWithConnection(
connection, input, input.settlementId, 'DRAFT', 'CONFIRMED', 'CONFIRM', input.note ?? ''
);
return this.getSettlement(connection, input.tenantId, input.settlementId);
});
}
async markSettlementPaid(input: CleaningActor & {
settlementId: string; payoutChannel: string; payoutReference: string; note?: string;
providerConfirmed?: boolean; manualOverride?: boolean;
}) {
this.assertSettlement(input.access, 'write');
return this.transaction(async (connection) => {
@@ -895,6 +956,11 @@ export class CleaningTaskRepository {
s.payout_package_info = '', s.payout_error = '',
s.note = CASE WHEN ? = '' THEN s.note ELSE ? END
WHERE s.tenant_id = ? AND s.id = ? AND s.status = 'CONFIRMED' AND s.deleted_at IS NULL
AND ${input.providerConfirmed
? "s.payout_state <> 'SUCCESS'"
: input.manualOverride
? "s.payout_state IN ('', 'FAIL')"
: '1 = 0'}
AND ${storeScopeSql(input.access, 'COALESCE(s.store_id, 0)')}`,
[
input.userId, input.payoutChannel, input.payoutReference,
@@ -902,12 +968,58 @@ export class CleaningTaskRepository {
]
);
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_SETTLEMENT_STATUS_CONFLICT');
await connection.execute(
`UPDATE qipai_cleaning_task_members m
INNER JOIN qipai_cleaning_settlement_items i
ON i.tenant_id = m.tenant_id AND i.task_id = m.task_id
AND i.cleaner_user_id = m.user_id AND i.reversed_at IS NULL
SET m.settled_at = UTC_TIMESTAMP(3), m.updated_at = UTC_TIMESTAMP(3)
WHERE i.tenant_id = ? AND i.settlement_id = ? AND m.removed_at IS NULL`,
[input.tenantId, input.settlementId]
);
const [paidTasks] = await connection.execute<Array<RowDataPacket & { id: string }>>(
`SELECT DISTINCT t.id
FROM qipai_cleaning_tasks t
INNER JOIN qipai_cleaning_settlement_items i
ON i.tenant_id = t.tenant_id AND i.task_id = t.id AND i.reversed_at IS NULL
WHERE i.tenant_id = ? AND i.settlement_id = ?
AND t.status = 'COMPLETED'
AND NOT EXISTS (
SELECT 1 FROM qipai_cleaning_task_members pending
WHERE pending.tenant_id = t.tenant_id AND pending.task_id = t.id
AND pending.removed_at IS NULL AND pending.reward_cents > 0
AND pending.settled_at IS NULL
)
FOR UPDATE`,
[input.tenantId, input.settlementId]
);
if (paidTasks.length > 0) {
await connection.execute(
`UPDATE qipai_cleaning_tasks
SET status = 'SETTLED', settled_at = UTC_TIMESTAMP(3)
WHERE tenant_id = ? AND status = 'COMPLETED'
AND id IN (${paidTasks.map(() => '?').join(',')})`,
[input.tenantId, ...paidTasks.map((task) => task.id)]
);
for (const task of paidTasks) {
await this.recordEventWithConnection(
connection, input, String(task.id), 'COMPLETED', 'SETTLED', 'SETTLEMENT_PAID', input.payoutReference
);
}
}
await this.recordSettlementEventWithConnection(
connection, input, input.settlementId, 'CONFIRMED', 'PAID', 'PAID', input.note ?? '', {
payoutChannel: input.payoutChannel,
payoutReference: input.payoutReference
}
);
return this.getSettlement(connection, input.tenantId, input.settlementId);
});
}
async recordSettlementPayoutFailure(input: CleaningActor & {
settlementId: string; payoutChannel?: string; payoutReference?: string; error: string; note?: string;
settlementId: string; payoutChannel?: string; payoutReference?: string;
error: string; note?: string; providerConfirmed?: boolean;
}) {
this.assertSettlement(input.access, 'write');
return this.transaction(async (connection) => {
@@ -918,6 +1030,9 @@ export class CleaningTaskRepository {
s.payout_state = 'FAIL', s.payout_package_info = '', s.payout_error = ?,
s.note = CASE WHEN ? = '' THEN s.note ELSE ? END
WHERE s.tenant_id = ? AND s.id = ? AND s.status = 'CONFIRMED' AND s.deleted_at IS NULL
AND ${input.providerConfirmed
? "s.payout_state <> 'SUCCESS'"
: "s.payout_state IN ('', 'FAIL')"}
AND ${storeScopeSql(input.access, 'COALESCE(s.store_id, 0)')}`,
[
input.payoutChannel ?? '', input.payoutChannel ?? '',
@@ -927,6 +1042,47 @@ export class CleaningTaskRepository {
]
);
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_SETTLEMENT_STATUS_CONFLICT');
await this.recordSettlementEventWithConnection(
connection, input, input.settlementId, 'CONFIRMED', 'CONFIRMED', 'PAYOUT_FAILURE',
input.note ?? input.error, {
payoutChannel: input.payoutChannel ?? '',
payoutReference: input.payoutReference ?? '',
error: input.error.slice(0, 512)
}
);
return this.getSettlement(connection, input.tenantId, input.settlementId);
});
}
async beginSettlementPayout(input: CleaningActor & {
settlementId: string; payoutChannel: string; payoutReference: string; note?: string;
}) {
this.assertSettlement(input.access, 'write');
return this.transaction(async (connection) => {
const [result] = await connection.execute<ResultSetHeader>(
`UPDATE qipai_cleaning_settlements s
SET s.payout_channel = ?, s.payout_request_no = ?, s.payout_reference = ?,
s.payout_state = 'PROCESSING',
s.payout_package_info = '', s.payout_error = '',
s.note = CASE WHEN ? = '' THEN s.note ELSE ? END
WHERE s.tenant_id = ? AND s.id = ? AND s.status = 'CONFIRMED'
AND s.payout_state IN ('', 'FAIL') AND s.deleted_at IS NULL
AND ${storeScopeSql(input.access, 'COALESCE(s.store_id, 0)')}`,
[input.payoutChannel, input.payoutReference, input.payoutReference,
input.note ?? '', input.note ?? '',
input.tenantId, input.settlementId]
);
if (result.affectedRows !== 1) {
throw new CleaningTaskError('CLEANING_SETTLEMENT_PAYOUT_ALREADY_STARTED');
}
await this.recordSettlementEventWithConnection(
connection, input, input.settlementId, 'CONFIRMED', 'CONFIRMED', 'PAYOUT_BEGIN',
input.note ?? '', {
payoutChannel: input.payoutChannel,
payoutReference: input.payoutReference,
payoutState: 'PROCESSING'
}
);
return this.getSettlement(connection, input.tenantId, input.settlementId);
});
}
@@ -943,6 +1099,7 @@ export class CleaningTaskRepository {
s.payout_package_info = ?, s.payout_error = '',
s.note = CASE WHEN ? = '' THEN s.note ELSE ? END
WHERE s.tenant_id = ? AND s.id = ? AND s.status = 'CONFIRMED' AND s.deleted_at IS NULL
AND s.payout_state IN ('PROCESSING', 'WAIT_USER_CONFIRM')
AND ${storeScopeSql(input.access, 'COALESCE(s.store_id, 0)')}`,
[
input.payoutChannel, input.payoutReference, input.payoutState.slice(0, 32),
@@ -951,6 +1108,105 @@ export class CleaningTaskRepository {
]
);
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_SETTLEMENT_STATUS_CONFLICT');
await this.recordSettlementEventWithConnection(
connection, input, input.settlementId, 'CONFIRMED', 'CONFIRMED', 'PAYOUT_PENDING',
input.note ?? '', {
payoutChannel: input.payoutChannel,
payoutReference: input.payoutReference,
payoutState: input.payoutState.slice(0, 32)
}
);
return this.getSettlement(connection, input.tenantId, input.settlementId);
});
}
async cancelSettlement(input: CleaningActor & { settlementId: string; reason: string }) {
this.assertSettlement(input.access, 'write');
return this.transaction(async (connection) => {
const [rows] = await connection.execute<Array<RowDataPacket & {
status: CleaningSettlementStatus;
payoutState: string;
totalRewardCents: number;
settlementNo: string;
}>>(
`SELECT s.status, s.payout_state AS payoutState,
s.total_reward_cents AS totalRewardCents, s.settlement_no AS settlementNo
FROM qipai_cleaning_settlements s
WHERE s.tenant_id = ? AND s.id = ? AND s.deleted_at IS NULL
AND ${storeScopeSql(input.access, 'COALESCE(s.store_id, 0)')}
FOR UPDATE`,
[input.tenantId, input.settlementId]
);
const settlement = rows[0];
if (!settlement) throw new CleaningTaskError('CLEANING_SETTLEMENT_NOT_FOUND');
if (!['DRAFT', 'CONFIRMED'].includes(settlement.status)) {
throw new CleaningTaskError('CLEANING_SETTLEMENT_STATUS_CONFLICT');
}
if (['PROCESSING', 'WAIT_USER_CONFIRM', 'SUCCESS'].includes(settlement.payoutState)) {
throw new CleaningTaskError('CLEANING_SETTLEMENT_PAYOUT_PENDING');
}
const reversalNo = `CLR-${Date.now()}-${input.settlementId}-${input.traceId.slice(-8)}`;
const [created] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_cleaning_settlement_reversals
(tenant_id, reversal_no, settlement_id, amount_delta_cents, reason, reversed_by)
VALUES (?, ?, ?, ?, ?, ?)`,
[input.tenantId, reversalNo, input.settlementId,
-Number(settlement.totalRewardCents), input.reason, input.userId]
);
const reversalId = String(created.insertId);
const [items] = await connection.execute<Array<RowDataPacket & { taskId: string }>>(
`SELECT i.task_id AS taskId
FROM qipai_cleaning_settlement_items i
WHERE i.tenant_id = ? AND i.settlement_id = ? AND i.reversed_at IS NULL
FOR UPDATE`,
[input.tenantId, input.settlementId]
);
if (items.length === 0) throw new CleaningTaskError('CLEANING_SETTLEMENT_STATUS_CONFLICT');
await connection.execute(
`UPDATE qipai_cleaning_settlement_items
SET reversal_id = ?, reversed_at = UTC_TIMESTAMP(3)
WHERE tenant_id = ? AND settlement_id = ? AND reversed_at IS NULL`,
[reversalId, input.tenantId, input.settlementId]
);
await connection.execute(
`UPDATE qipai_cleaning_task_members m
INNER JOIN qipai_cleaning_settlement_items i
ON i.tenant_id = m.tenant_id AND i.task_id = m.task_id
AND i.cleaner_user_id = m.user_id
SET m.settled_at = NULL, m.updated_at = UTC_TIMESTAMP(3)
WHERE i.tenant_id = ? AND i.settlement_id = ? AND i.reversal_id = ?`,
[input.tenantId, input.settlementId, reversalId]
);
await connection.execute(
`UPDATE qipai_cleaning_tasks t
INNER JOIN qipai_cleaning_settlement_items i
ON i.tenant_id = t.tenant_id AND i.task_id = t.id
SET t.status = 'COMPLETED', t.settled_at = NULL
WHERE i.tenant_id = ? AND i.settlement_id = ? AND i.reversal_id = ?
AND t.status IN ('COMPLETED', 'SETTLED')`,
[input.tenantId, input.settlementId, reversalId]
);
const [updated] = await connection.execute<ResultSetHeader>(
`UPDATE qipai_cleaning_settlements
SET status = 'CANCELLED', cancelled_by = ?, cancelled_at = UTC_TIMESTAMP(3),
note = CASE WHEN ? = '' THEN note ELSE ? END
WHERE tenant_id = ? AND id = ? AND status = ? AND deleted_at IS NULL`,
[input.userId, input.reason, input.reason, input.tenantId,
input.settlementId, settlement.status]
);
if (updated.affectedRows !== 1) throw new CleaningTaskError('CLEANING_SETTLEMENT_STATUS_CONFLICT');
await this.recordSettlementEventWithConnection(
connection, input, input.settlementId, settlement.status, 'CANCELLED', 'CANCEL', input.reason, {
reversalId,
reversalNo,
amountDeltaCents: -Number(settlement.totalRewardCents)
}
);
for (const item of items) {
await this.recordEventWithConnection(
connection, input, String(item.taskId), 'COMPLETED', 'COMPLETED', 'REVERSE_SETTLEMENT', reversalNo
);
}
return this.getSettlement(connection, input.tenantId, input.settlementId);
});
}
@@ -1245,16 +1501,16 @@ export class CleaningTaskRepository {
INNER JOIN qipai_cleaning_tasks t ON t.tenant_id = m.tenant_id AND t.id = m.task_id
WHERE m.tenant_id = ? AND m.user_id = ? AND m.removed_at IS NULL
AND m.settled_at IS NULL AND m.reward_cents > 0
AND t.status IN ('SUBMITTED', 'COMPLETED') AND t.deleted_at IS NULL`,
AND t.status = 'COMPLETED' AND t.deleted_at IS NULL`,
[input.tenantId, input.userId]
);
const [settledRows] = await this.pool.execute<AmountRow[]>(
`SELECT COALESCE(SUM(m.reward_cents), 0) AS amount
FROM qipai_cleaning_task_members m
INNER JOIN qipai_cleaning_tasks t ON t.tenant_id = m.tenant_id AND t.id = m.task_id
WHERE m.tenant_id = ? AND m.user_id = ? AND m.removed_at IS NULL
AND m.settled_at IS NOT NULL AND m.reward_cents > 0
AND t.deleted_at IS NULL`,
`SELECT COALESCE(SUM(i.reward_cents), 0) AS amount
FROM qipai_cleaning_settlement_items i
INNER JOIN qipai_cleaning_settlements s
ON s.tenant_id = i.tenant_id AND s.id = i.settlement_id
WHERE i.tenant_id = ? AND i.cleaner_user_id = ? AND i.reversed_at IS NULL
AND s.status = 'PAID' AND s.deleted_at IS NULL`,
[input.tenantId, input.userId]
);
const byStatus = Object.fromEntries(counts.map((row) => [row.status, Number(row.total)]));
@@ -1368,10 +1624,21 @@ export class CleaningTaskRepository {
SUM(CASE WHEN t.status IN ('COMPLETED', 'SETTLED') THEN 1 ELSE 0 END) AS completedTaskCount,
SUM(CASE WHEN t.status = 'REJECTED' THEN 1 ELSE 0 END) AS rejectedTaskCount,
COALESCE(SUM(CASE
WHEN m.removed_at IS NULL AND m.settled_at IS NULL AND t.status IN ('SUBMITTED', 'COMPLETED')
WHEN m.removed_at IS NULL AND m.settled_at IS NULL AND t.status = 'COMPLETED'
THEN m.reward_cents ELSE 0 END), 0) AS pendingSettlementCents,
COALESCE(SUM(CASE
WHEN m.settled_at IS NOT NULL THEN m.reward_cents ELSE 0 END), 0) AS settledRewardCents
COALESCE(SUM(COALESCE((
SELECT MAX(paid_item.reward_cents)
FROM qipai_cleaning_settlement_items paid_item
INNER JOIN qipai_cleaning_settlements paid_settlement
ON paid_settlement.tenant_id = paid_item.tenant_id
AND paid_settlement.id = paid_item.settlement_id
WHERE paid_item.tenant_id = m.tenant_id
AND paid_item.task_id = m.task_id
AND paid_item.cleaner_user_id = m.user_id
AND paid_item.reversed_at IS NULL
AND paid_settlement.status = 'PAID'
AND paid_settlement.deleted_at IS NULL
), 0)), 0) AS settledRewardCents
FROM qipai_cleaning_task_members m
INNER JOIN qipai_cleaning_tasks t ON t.tenant_id = m.tenant_id AND t.id = m.task_id
INNER JOIN qipai_users u ON u.tenant_id = m.tenant_id AND u.id = m.user_id
@@ -1414,6 +1681,66 @@ export class CleaningTaskRepository {
return publicManagerStatistics(statusRows, storeRows, settlementRows, memberRows, trendRows);
}
private async querySettlementRows(input: CleaningActor & {
status?: CleaningSettlementStatus; payoutState?: CleaningSettlementPayoutState;
storeId?: string; cleanerUserId?: string;
}, limit: number, offset: number) {
const where = [
's.tenant_id = ?',
's.deleted_at IS NULL',
storeScopeSql(input.access, 'COALESCE(s.store_id, 0)')
];
const params: Array<string | number> = [input.tenantId];
if (input.status) {
where.push('s.status = ?');
params.push(input.status);
}
if (input.payoutState) {
if (input.payoutState === 'NONE') where.push("s.payout_state = ''");
else {
where.push('s.payout_state = ?');
params.push(input.payoutState);
}
}
if (input.storeId) {
where.push('s.store_id = ?');
params.push(input.storeId);
}
if (input.cleanerUserId) {
where.push('s.cleaner_user_id = ?');
params.push(input.cleanerUserId);
}
const whereSql = where.join(' AND ');
const safeLimit = Math.max(1, Math.min(5000, Math.trunc(limit)));
const safeOffset = Math.max(0, Math.trunc(offset));
const [counts] = await this.pool.execute<CountRow[]>(
`SELECT COUNT(*) AS total FROM qipai_cleaning_settlements s WHERE ${whereSql}`,
params
);
const [rows] = await this.pool.execute<SettlementRow[]>(
`SELECT s.id, s.settlement_no AS settlementNo,
s.cleaner_user_id AS cleanerUserId, u.nickname AS cleanerName,
s.store_id AS storeId, st.name AS storeName, s.status,
s.task_count AS taskCount, s.total_reward_cents AS totalRewardCents,
s.period_start AS periodStart, s.period_end AS periodEnd,
s.paid_by AS paidBy, s.cancelled_by AS cancelledBy,
s.confirmed_at AS confirmedAt, s.paid_at AS paidAt,
s.cancelled_at AS cancelledAt,
s.payout_channel AS payoutChannel, s.payout_request_no AS payoutRequestNo,
s.payout_reference AS payoutReference,
s.payout_state AS payoutState, s.payout_package_info AS payoutPackageInfo,
s.payout_error AS payoutError, s.note, s.created_at AS createdAt
FROM qipai_cleaning_settlements s
INNER JOIN qipai_users u ON u.tenant_id = s.tenant_id AND u.id = s.cleaner_user_id
LEFT JOIN qipai_stores st ON st.tenant_id = s.tenant_id AND st.id = s.store_id
WHERE ${whereSql}
ORDER BY s.created_at DESC, s.id DESC
LIMIT ${safeLimit} OFFSET ${safeOffset}`,
params
);
return { rows, total: Number(counts[0]?.total ?? 0) };
}
private async listByWhere(
where: string[],
params: Array<string | number>,
@@ -1615,6 +1942,21 @@ export class CleaningTaskRepository {
);
}
private async assertTaskSettlementMutable(
connection: PoolConnection,
tenantId: string,
taskId: string
) {
const [rows] = await connection.execute<Array<RowDataPacket & { id: string }>>(
`SELECT id
FROM qipai_cleaning_settlement_items
WHERE tenant_id = ? AND task_id = ? AND reversed_at IS NULL
LIMIT 1 FOR UPDATE`,
[tenantId, taskId]
);
if (rows[0]) throw new CleaningTaskError('CLEANING_MEMBER_SETTLEMENT_LOCKED');
}
private async getTemplate(
connection: Pick<MySqlPool, 'execute'> | PoolConnection,
tenantId: string,
@@ -1718,9 +2060,31 @@ export class CleaningTaskRepository {
await connection.execute(
`INSERT INTO qipai_cleaning_task_events
(tenant_id, task_id, from_status, to_status, action, actor_id, trace_id, note, metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, JSON_OBJECT())`,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, JSON_OBJECT('actorType', ?))`,
[input.tenantId, taskId, from, to, action, input.userId,
`${input.traceId}-${taskId}`.slice(0, 128), note.slice(0, 512)]
`${input.traceId}-${taskId}`.slice(0, 128), note.slice(0, 512),
input.actorType ?? 'USER']
);
}
private async recordSettlementEventWithConnection(
connection: PoolConnection,
input: CleaningActor,
settlementId: string,
from: CleaningSettlementStatus | null,
to: CleaningSettlementStatus,
action: string,
note: string,
metadata: Record<string, unknown> = {}
) {
await connection.execute(
`INSERT INTO qipai_cleaning_settlement_events
(tenant_id, settlement_id, from_status, to_status, action,
actor_id, actor_type, trace_id, note, metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON))`,
[input.tenantId, settlementId, from, to, action.slice(0, 32), input.userId,
input.actorType ?? 'USER', input.traceId.slice(0, 64), note.slice(0, 512),
JSON.stringify(metadata)]
);
}
@@ -1753,8 +2117,11 @@ export class CleaningTaskRepository {
s.store_id AS storeId, st.name AS storeName, s.status,
s.task_count AS taskCount, s.total_reward_cents AS totalRewardCents,
s.period_start AS periodStart, s.period_end AS periodEnd,
s.paid_by AS paidBy, s.confirmed_at AS confirmedAt, s.paid_at AS paidAt,
s.payout_channel AS payoutChannel, s.payout_reference AS payoutReference,
s.paid_by AS paidBy, s.cancelled_by AS cancelledBy,
s.confirmed_at AS confirmedAt, s.paid_at AS paidAt,
s.cancelled_at AS cancelledAt,
s.payout_channel AS payoutChannel, s.payout_request_no AS payoutRequestNo,
s.payout_reference AS payoutReference,
s.payout_state AS payoutState, s.payout_package_info AS payoutPackageInfo,
s.payout_error AS payoutError,
s.note, s.created_at AS createdAt
@@ -1775,8 +2142,11 @@ export class CleaningTaskRepository {
s.store_id AS storeId, st.name AS storeName, s.status,
s.task_count AS taskCount, s.total_reward_cents AS totalRewardCents,
s.period_start AS periodStart, s.period_end AS periodEnd,
s.paid_by AS paidBy, s.confirmed_at AS confirmedAt, s.paid_at AS paidAt,
s.payout_channel AS payoutChannel, s.payout_reference AS payoutReference,
s.paid_by AS paidBy, s.cancelled_by AS cancelledBy,
s.confirmed_at AS confirmedAt, s.paid_at AS paidAt,
s.cancelled_at AS cancelledAt,
s.payout_channel AS payoutChannel, s.payout_request_no AS payoutRequestNo,
s.payout_reference AS payoutReference,
s.payout_state AS payoutState, s.payout_package_info AS payoutPackageInfo,
s.payout_error AS payoutError,
s.note, s.created_at AS createdAt
@@ -1899,6 +2269,7 @@ function publicTaskEvent(row: CleaningTaskEventRow) {
toStatus: row.toStatus,
action: row.action,
actorId: String(row.actorId),
actorType: row.actorType,
traceId: row.traceId,
note: row.note,
createdAt: row.createdAt
@@ -1935,9 +2306,12 @@ function publicSettlement(row: SettlementRow) {
periodStart: row.periodStart,
periodEnd: row.periodEnd,
paidBy: row.paidBy === null ? null : String(row.paidBy),
cancelledBy: row.cancelledBy === null ? null : String(row.cancelledBy),
confirmedAt: row.confirmedAt,
paidAt: row.paidAt,
cancelledAt: row.cancelledAt,
payoutChannel: row.payoutChannel,
payoutRequestNo: row.payoutRequestNo,
payoutReference: row.payoutReference,
payoutState: row.payoutState,
payoutPackageInfo: row.payoutPackageInfo,
@@ -1960,11 +2334,38 @@ function publicSettlementItem(row: SettlementItemRow) {
cleanerUserId: String(row.cleanerUserId),
cleanerName: row.cleanerName,
rewardCents: Number(row.rewardCents),
reversedAt: row.reversedAt,
completedAt: row.completedAt,
createdAt: row.createdAt
};
}
function publicSettlementEvent(row: SettlementEventRow) {
return {
id: String(row.id),
settlementId: String(row.settlementId),
fromStatus: row.fromStatus,
toStatus: row.toStatus,
action: row.action,
actorId: String(row.actorId),
traceId: row.traceId,
note: row.note,
createdAt: row.createdAt
};
}
function publicSettlementReversal(row: SettlementReversalRow) {
return {
id: String(row.id),
settlementId: String(row.settlementId),
reversalNo: row.reversalNo,
amountDeltaCents: Number(row.amountDeltaCents),
reason: row.reason,
reversedBy: String(row.reversedBy),
createdAt: row.createdAt
};
}
function publicManagerStatistics(
statusRows: ManagerStatusStatsRow[],
storeRows: ManagerStoreStatsRow[],
@@ -1992,6 +2393,9 @@ function publicManagerStatistics(
completed: countStatus(byStatus, 'COMPLETED') + countStatus(byStatus, 'SETTLED'),
rewardCents: byStatus.reduce((sum, row) => sum + row.rewardCents, 0),
pendingSettlementCents: memberRows.reduce((sum, row) => sum + Number(row.pendingSettlementCents ?? 0), 0),
draftSettlementCents: settlementByStatus
.filter((row) => row.status === 'DRAFT')
.reduce((sum, row) => sum + row.rewardCents, 0),
confirmedSettlementCents: settlementByStatus
.filter((row) => row.status === 'CONFIRMED')
.reduce((sum, row) => sum + row.rewardCents, 0),
@@ -2057,6 +2461,17 @@ function maxDate(values: Array<Date | null>) {
return new Date(Math.max(...dates.map((date) => date.getTime())));
}
function isMySqlDuplicate(error: unknown) {
return typeof error === 'object' && error !== null
&& Reflect.get(error, 'code') === 'ER_DUP_ENTRY';
}
function isMySqlSettlementContention(error: unknown) {
if (isMySqlDuplicate(error)) return true;
if (typeof error !== 'object' || error === null) return false;
return ['ER_LOCK_DEADLOCK', 'ER_LOCK_WAIT_TIMEOUT'].includes(String(Reflect.get(error, 'code')));
}
function parseJsonArray(value: string | string[] | null): string[] {
if (Array.isArray(value)) return value.map(String);
if (!value) return [];
+8 -3
View File
@@ -54,7 +54,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026081002_m08d_content_asset_scope.up.sql',
'database/migrations/2026081003_m08d_franchise_leads.up.sql',
'database/migrations/2026081004_m08d_admin_password_auth.up.sql',
'database/migrations/2026081005_m09b_cleaning_rules.up.sql'
'database/migrations/2026081005_m09b_cleaning_rules.up.sql',
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.up.sql'
],
verify: [
'database/migrations/2026061601_m01b_core_schema.verify.sql',
@@ -90,9 +91,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026081002_m08d_content_asset_scope.verify.sql',
'database/migrations/2026081003_m08d_franchise_leads.verify.sql',
'database/migrations/2026081004_m08d_admin_password_auth.verify.sql',
'database/migrations/2026081005_m09b_cleaning_rules.verify.sql'
'database/migrations/2026081005_m09b_cleaning_rules.verify.sql',
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.verify.sql'
],
down: [
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.down.sql',
'database/migrations/2026081005_m09b_cleaning_rules.down.sql',
'database/migrations/2026081004_m08d_admin_password_auth.down.sql',
'database/migrations/2026081003_m08d_franchise_leads.down.sql',
@@ -274,7 +277,9 @@ export async function executeMigrationPlan(
2, 1, 1,
1, 1, 1,
1, 2, 3, 1,
1, 2, 3, 1
1, 2, 3, 1,
3, 7, 3, 1,
2, 8, 4, 1
][index] ?? 1;
if (!Array.isArray(result) || result.length < minimumRows) {
throw new Error(
+36 -2
View File
@@ -84,6 +84,7 @@ const settlementListSchema = z.object({
storeId: z.string().regex(/^[1-9]\d{0,19}$/).optional(),
cleanerUserId: z.string().regex(/^[1-9]\d{0,19}$/).optional()
}).strict();
const settlementExportSchema = settlementListSchema.omit({ page: true, pageSize: true });
const settlementParamsSchema = z.object({ settlementId: z.string().regex(/^[1-9]\d{0,19}$/) });
const settlementGenerateSchema = z.object({
cleanerUserId: z.string().regex(/^[1-9]\d{0,19}$/),
@@ -93,6 +94,9 @@ const settlementGenerateSchema = z.object({
const settlementConfirmSchema = z.object({
note: z.string().trim().max(512).optional()
}).strict();
const settlementCancelSchema = z.object({
reason: z.string().trim().min(1).max(512)
}).strict();
const settlementPaidSchema = z.object({
payoutChannel: z.string().trim().min(1).max(32),
payoutReference: z.string().trim().min(1).max(128),
@@ -129,7 +133,7 @@ export interface CleaningRouteOptions {
repository: Pick<CleaningTaskRepository,
'listHall' | 'listMine' | 'listManage' | 'claim' | 'start' | 'rework' | 'submit'
| 'assign' | 'complete' | 'reject' | 'exempt' | 'listMembers' | 'listEvents' | 'listSubmissions' | 'addMember' | 'removeMember' | 'settlementCandidates'
| 'listSettlements' | 'getSettlementDetail' | 'generateSettlement' | 'confirmSettlement' | 'markSettlementPaid'
| 'listSettlements' | 'exportSettlements' | 'getSettlementDetail' | 'generateSettlement' | 'confirmSettlement' | 'cancelSettlement' | 'markSettlementPaid'
| 'recordSettlementPayoutFailure' | 'reclaimTimeouts'
| 'assertCanUploadPhoto' | 'recordPhotoUpload' | 'claimExpiredPhotoUploads' | 'markPhotoUploadsDeleted'
| 'listTemplates' | 'upsertTemplate'
@@ -269,6 +273,18 @@ export async function registerCleaningRoutes(
}));
});
app.get('/admin-api/cleaning/settlements/export', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
if (!actor) return;
const query = settlementExportSchema.safeParse(request.query);
if (!query.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.exportSettlements({ ...actor, ...query.data }),
traceId: request.traceId
}));
});
app.get('/admin-api/cleaning/settlements/:settlementId', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
if (!actor) return;
@@ -607,6 +623,23 @@ export async function registerCleaningRoutes(
}));
});
app.post('/admin-api/cleaning/settlements/:settlementId/cancel', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
const params = settlementParamsSchema.safeParse(request.params);
const body = settlementCancelSchema.safeParse(request.body ?? {});
if (!params.success || !body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.cancelSettlement({
...actor,
settlementId: params.data.settlementId,
reason: body.data.reason
}),
traceId: request.traceId
}));
});
app.post('/admin-api/cleaning/settlements/:settlementId/paid', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
@@ -620,7 +653,8 @@ export async function registerCleaningRoutes(
settlementId: params.data.settlementId,
payoutChannel: body.data.payoutChannel,
payoutReference: body.data.payoutReference,
note: body.data.note
note: body.data.note,
manualOverride: true
}),
traceId: request.traceId
}));
+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 {