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
}));