feat(cleaning): close settlement integrity and reconciliation
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}));
|
||||
|
||||
@@ -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