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
+48
View File
@@ -826,6 +826,28 @@ export function listCleaningSettlements(
return request<PageResult<CleaningSettlement>>(session, `/cleaning/settlements?${params}`);
}
export function exportCleaningSettlements(
session: ApiSession,
input: {
status?: SettlementStatus;
payoutState?: PayoutStateFilter;
storeId?: string;
cleanerUserId?: string;
}
) {
const params = new URLSearchParams();
if (input.status) params.set('status', input.status);
if (input.payoutState) params.set('payoutState', input.payoutState);
if (input.storeId) params.set('storeId', input.storeId);
if (input.cleanerUserId) params.set('cleanerUserId', input.cleanerUserId);
return request<{
items: CleaningSettlement[];
total: number;
exported: number;
truncated: boolean;
}>(session, `/cleaning/settlements/export?${params}`);
}
export function listCleaningSettlementCandidates(
session: ApiSession,
input: { page: number; pageSize: number; storeId?: string; cleanerUserId?: string }
@@ -867,6 +889,32 @@ export function confirmCleaningSettlement(session: ApiSession, settlementId: str
);
}
export function cancelCleaningSettlement(session: ApiSession, settlementId: string, reason: string) {
return request<CleaningSettlement>(
session,
`/cleaning/settlements/${encodeURIComponent(settlementId)}/cancel`,
{
method: 'POST',
body: JSON.stringify({ reason })
}
);
}
export function markCleaningSettlementPaid(
session: ApiSession,
settlementId: string,
input: { payoutChannel: string; payoutReference: string; note?: string }
) {
return request<CleaningSettlement>(
session,
`/cleaning/settlements/${encodeURIComponent(settlementId)}/paid`,
{
method: 'POST',
body: JSON.stringify(input)
}
);
}
export function executeWechatTransfer(
session: ApiSession,
settlementId: string,
+176 -14
View File
@@ -56,7 +56,12 @@
>
批量同步
</el-button>
<el-button :icon="Download" :disabled="items.length === 0" @click="exportSettlements">
<el-button
:icon="Download"
:disabled="total === 0"
:loading="exporting"
@click="exportSettlements"
>
导出
</el-button>
<el-button :icon="ListChecks" @click="openCandidates">
@@ -108,7 +113,7 @@
<el-table-column prop="createdAt" label="创建" width="130">
<template #default="{ row }">{{ shortDate(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="320" fixed="right">
<el-table-column label="操作" width="470" fixed="right">
<template #default="{ row }">
<div class="row-actions">
<el-button size="small" :icon="ListChecks" @click="openDetail(row)">详情</el-button>
@@ -137,6 +142,14 @@
>
转账
</el-button>
<el-button
size="small"
:icon="BadgeCheck"
:disabled="!canMarkPaid(row)"
@click="openManualPaid(row)"
>
人工已付
</el-button>
<el-button
size="small"
:icon="RefreshCw"
@@ -149,11 +162,21 @@
size="small"
type="danger"
:icon="CircleAlert"
:disabled="row.status !== 'CONFIRMED'"
:disabled="!canRecordFailure(row)"
@click="openFailure(row)"
>
失败
</el-button>
<el-button
size="small"
type="danger"
plain
:icon="CircleX"
:disabled="!canCancelSettlement(row)"
@click="openCancel(row)"
>
冲正
</el-button>
</div>
</template>
</el-table-column>
@@ -390,7 +413,7 @@
</template>
</el-dialog>
<el-dialog v-model="detailDialog.open" title="结算单详情" width="720px">
<el-dialog v-model="detailDialog.open" title="结算单详情" width="880px">
<div v-if="detailDialog.detail" class="dialog-stack">
<div class="detail-grid">
<span>单号<strong>{{ detailDialog.detail.settlement.settlementNo }}</strong></span>
@@ -416,6 +439,9 @@
<el-table-column prop="rewardCents" label="金额" width="110">
<template #default="{ row }">{{ money(row.rewardCents) }}</template>
</el-table-column>
<el-table-column label="份额状态" width="100">
<template #default="{ row }">{{ row.reversedAt ? '已冲正' : '有效' }}</template>
</el-table-column>
<el-table-column prop="completedAt" label="完成" width="130">
<template #default="{ row }">{{ shortDate(row.completedAt) }}</template>
</el-table-column>
@@ -427,6 +453,9 @@
<el-descriptions-item label="外部流水">
{{ compactText(detailDialog.detail.settlement.payoutReference) }}
</el-descriptions-item>
<el-descriptions-item label="付款请求号">
{{ compactText(detailDialog.detail.settlement.payoutRequestNo) }}
</el-descriptions-item>
<el-descriptions-item label="微信确认参数" :span="2">
{{ compactText(detailDialog.detail.settlement.payoutPackageInfo) }}
</el-descriptions-item>
@@ -436,7 +465,45 @@
<el-descriptions-item label="备注" :span="2">
{{ compactText(detailDialog.detail.settlement.note) }}
</el-descriptions-item>
<el-descriptions-item label="冲正操作人">
{{ compactText(detailDialog.detail.settlement.cancelledBy) }}
</el-descriptions-item>
<el-descriptions-item label="冲正时间">
{{ shortDate(detailDialog.detail.settlement.cancelledAt) }}
</el-descriptions-item>
</el-descriptions>
<section v-if="detailDialog.detail.reversals.length" class="detail-section">
<h4>反向记录</h4>
<el-table :data="detailDialog.detail.reversals" size="small">
<el-table-column prop="reversalNo" label="反向单号" min-width="190" />
<el-table-column label="金额(分)" width="120">
<template #default="{ row }">{{ row.amountDeltaCents }}</template>
</el-table-column>
<el-table-column prop="reason" label="原因" min-width="180" />
<el-table-column prop="reversedBy" label="操作人" width="100" />
<el-table-column prop="createdAt" label="时间" width="140">
<template #default="{ row }">{{ shortDate(row.createdAt) }}</template>
</el-table-column>
</el-table>
</section>
<section class="detail-section">
<h4>状态历史</h4>
<el-table :data="detailDialog.detail.events" size="small">
<el-table-column prop="action" label="动作" width="150" />
<el-table-column label="状态" min-width="150">
<template #default="{ row }">
{{ compactText(row.fromStatus) }} {{ row.toStatus }}
</template>
</el-table-column>
<el-table-column label="操作人" width="130">
<template #default="{ row }">{{ row.actorType }} · {{ row.actorId }}</template>
</el-table-column>
<el-table-column prop="note" label="备注" min-width="170" />
<el-table-column prop="createdAt" label="时间" width="140">
<template #default="{ row }">{{ shortDate(row.createdAt) }}</template>
</el-table-column>
</el-table>
</section>
</div>
<template #footer>
<el-button :icon="Download" :disabled="!detailDialog.detail" @click="exportSettlementDetail">
@@ -452,6 +519,7 @@ import { computed, reactive, ref, watch } from 'vue';
import {
BadgeCheck,
CircleAlert,
CircleX,
ClipboardCheck,
Download,
FilePlus2,
@@ -460,8 +528,9 @@ import {
Search,
Send
} from '@lucide/vue';
import { ElMessage } from 'element-plus';
import { ElMessage, ElMessageBox } from 'element-plus';
import {
exportCleaningSettlements,
getCleaningSettlementDetail,
listCleaningSettlementCandidates,
preflightWechatTransfer
@@ -502,6 +571,13 @@ const emit = defineEmits<{
'sync-transfer': [{ settlementId: string; note?: string }];
'sync-transfer-many': [{ settlementIds: string[]; note?: string }];
failure: [{ settlementId: string; error: string; payoutReference?: string; note?: string }];
cancel: [{ settlementId: string; reason: string }];
paid: [{
settlementId: string;
payoutChannel: string;
payoutReference: string;
note?: string;
}];
}>();
const statusOptions = [
@@ -524,6 +600,7 @@ const filterDraft = reactive({
payoutState: props.filters.payoutState
});
const selectedSettlements = ref<CleaningSettlement[]>([]);
const exporting = ref(false);
const generateDialog = reactive({ open: false, cleanerUserId: '', storeId: '', note: '' });
const transferDialog = reactive({
open: false,
@@ -616,6 +693,19 @@ function canSelectSettlement(row: CleaningSettlement) {
return row.status === 'CONFIRMED';
}
function canCancelSettlement(row: CleaningSettlement) {
return (row.status === 'DRAFT' || row.status === 'CONFIRMED')
&& !['PROCESSING', 'WAIT_USER_CONFIRM', 'SUCCESS'].includes(row.payoutState);
}
function canMarkPaid(row: CleaningSettlement) {
return row.status === 'CONFIRMED' && ['', 'FAIL'].includes(row.payoutState);
}
function canRecordFailure(row: CleaningSettlement) {
return row.status === 'CONFIRMED' && ['', 'FAIL'].includes(row.payoutState);
}
function handleSelectionChange(rows: CleaningSettlement[]) {
selectedSettlements.value = rows;
}
@@ -712,6 +802,51 @@ function submitFailure() {
failureDialog.open = false;
}
async function openCancel(row: CleaningSettlement) {
try {
const result = await ElMessageBox.prompt(
'结算单 ' + row.settlementNo + ' 将生成反向记录并释放任务份额,原单不会删除。',
'冲正结算单',
{
confirmButtonText: '确认冲正',
cancelButtonText: '返回',
inputPlaceholder: '请输入冲正原因',
inputPattern: /\S+/,
inputErrorMessage: '冲正原因不能为空',
type: 'warning'
}
);
emit('cancel', { settlementId: row.id, reason: result.value.trim() });
} catch {
// Dialog cancellation is not an operation failure.
}
}
async function openManualPaid(row: CleaningSettlement) {
try {
const result = await ElMessageBox.prompt(
'结算单 ' + row.settlementNo + ' 将由人工渠道完成,请填写可对账的付款流水。',
'标记人工已付',
{
confirmButtonText: '确认已付',
cancelButtonText: '返回',
inputPlaceholder: '付款流水号',
inputPattern: /\S+/,
inputErrorMessage: '付款流水不能为空',
type: 'warning'
}
);
emit('paid', {
settlementId: row.id,
payoutChannel: 'MANUAL',
payoutReference: result.value.trim(),
note: '后台人工确认付款完成'
});
} catch {
// Dialog cancellation is not an operation failure.
}
}
async function openDetail(row: CleaningSettlement) {
detailDialog.open = true;
detailDialog.loading = true;
@@ -760,16 +895,24 @@ function useCandidateFilters() {
candidateDialog.open = false;
}
function exportSettlements() {
async function exportSettlements() {
exporting.value = true;
try {
const result = await exportCleaningSettlements(props.session, {
status: props.status || undefined,
payoutState: props.filters.payoutState || undefined,
storeId: props.filters.storeId || undefined,
cleanerUserId: props.filters.cleanerUserId || undefined
});
downloadCsv(`cleaning-settlements-${Date.now()}.csv`, [
['结算单', '状态', '保洁员', '门店', '任务数', '金额', '转账状态', '外部流水', '微信确认参数', '失败原因', '创建时间'],
...props.items.map((item) => [
['结算单', '状态', '保洁员', '门店', '任务数', '金额(分)', '转账状态', '外部流水', '微信确认参数', '失败原因', '创建时间'],
...result.items.map((item) => [
item.settlementNo,
item.status,
item.cleanerName,
item.storeName || '',
String(item.taskCount),
money(item.totalRewardCents),
String(item.totalRewardCents),
item.payoutState,
item.payoutReference,
item.payoutPackageInfo,
@@ -777,18 +920,26 @@ function exportSettlements() {
shortDate(item.createdAt)
])
]);
if (result.truncated) {
ElMessage.warning(`导出达到上限:${result.exported}/${result.total}`);
}
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '结算导出失败');
} finally {
exporting.value = false;
}
}
function exportCandidates() {
downloadCsv(`cleaning-settlement-candidates-${Date.now()}.csv`, [
['任务号', '订单号', '门店', '房间', '保洁员ID', '金额', '完成时间'],
['任务号', '订单号', '门店', '房间', '保洁员ID', '金额(分)', '完成时间'],
...candidateDialog.items.map((item) => [
item.taskNo,
item.orderNo || '',
item.storeName,
item.roomName || item.roomNo,
item.cleanerUserId || '',
money(item.rewardCents),
String(item.rewardCents),
shortDate(item.completedAt)
])
]);
@@ -805,8 +956,9 @@ function exportSettlementDetail() {
['settlement', '保洁员', settlement.cleanerName],
['settlement', '门店', settlement.storeName || ''],
['settlement', '任务数', String(settlement.taskCount)],
['settlement', '金额', money(settlement.totalRewardCents)],
['settlement', '金额(分)', String(settlement.totalRewardCents)],
['settlement', '转账状态', settlement.payoutState],
['settlement', '付款请求号', settlement.payoutRequestNo],
['settlement', '外部流水', settlement.payoutReference],
['settlement', '微信确认参数', settlement.payoutPackageInfo],
['settlement', '失败原因', settlement.payoutError],
@@ -815,7 +967,17 @@ function exportSettlementDetail() {
...detail.items.map((item) => [
'item',
item.taskNo,
`${item.storeName} ${item.roomName || item.roomNo} ${item.cleanerName} ${money(item.rewardCents)} ${shortDate(item.completedAt)}`
`${item.storeName} ${item.roomName || item.roomNo} ${item.cleanerName} ${item.rewardCents}${item.reversedAt ? '已冲正' : '有效'} ${shortDate(item.completedAt)}`
]),
...detail.reversals.map((reversal) => [
'reversal',
reversal.reversalNo,
`${reversal.amountDeltaCents}${reversal.reason} ${reversal.reversedBy} ${shortDate(reversal.createdAt)}`
]),
...detail.events.map((event) => [
'event',
event.action,
`${event.fromStatus || ''}->${event.toStatus} ${event.actorType}:${event.actorId} ${event.note} ${shortDate(event.createdAt)}`
])
]);
}
@@ -828,7 +990,7 @@ function exportPreflight() {
['summary', 'ready', result.ready ? 'PASS' : 'BLOCKED'],
['settlement', 'settlementNo', result.settlement.settlementNo],
['settlement', 'status', result.settlement.status],
['settlement', 'amount', money(result.settlement.totalRewardCents)],
['settlement', 'amountCents', String(result.settlement.totalRewardCents)],
['settlement', 'cleanerUserId', result.settlement.cleanerUserId],
['settlement', 'storeId', result.settlement.storeId || ''],
['account', 'merchantId', compactText(result.account.merchantIdMasked)],
@@ -288,7 +288,7 @@ function filterByCleaner(cleanerUserId: string) {
function exportCleanerPerformance() {
downloadCsv(`cleaning-cleaner-performance-${Date.now()}.csv`, [
['排名', '保洁员', '保洁员ID', '任务', '完成', '驳回', '完成率', '待结算', '已结算'],
['排名', '保洁员', '保洁员ID', '任务', '完成', '驳回', '完成率', '待结算(分)', '已结算(分)'],
...cleanerPerformanceRows.value.map((row) => [
String(row.rank),
row.cleanerName || '',
@@ -297,8 +297,8 @@ function exportCleanerPerformance() {
String(row.completedTaskCount),
String(row.rejectedTaskCount),
`${row.completionRate}%`,
money(row.pendingSettlementCents),
money(row.settledRewardCents)
String(row.pendingSettlementCents),
String(row.settledRewardCents)
])
]);
}
@@ -316,14 +316,14 @@ function exportStatistics() {
['summary', '\u9a73\u56de', String(props.statistics.summary.rejected), '', '', '', ''],
['summary', '\u514d\u6e05\u6d01', String(props.statistics.summary.exempted), '', '', '', ''],
['summary', '\u5df2\u5b8c\u6210', String(props.statistics.summary.completed), '', '', '', ''],
['summary', '\u5956\u52b1\u5408\u8ba1', money(props.statistics.summary.rewardCents), '', '', '', ''],
['summary', '\u5f85\u7ed3\u7b97', money(props.statistics.summary.pendingSettlementCents), '', '', '', ''],
['summary', '\u5f85\u53d1\u653e', money(props.statistics.summary.confirmedSettlementCents), '', '', '', ''],
['summary', '\u5df2\u53d1\u653e', money(props.statistics.summary.paidSettlementCents), '', '', '', ''],
['\u4efb\u52a1\u72b6\u6001', '\u72b6\u6001', '\u6570\u91cf', '\u5956\u52b1', '', '', ''],
...props.statistics.byStatus.map((row) => ['\u4efb\u52a1\u72b6\u6001', row.status, String(row.total), money(row.rewardCents), '', '', '']),
['\u7ed3\u7b97\u53e3\u5f84', '\u72b6\u6001', '\u5355\u6570', '\u91d1\u989d', '', '', ''],
...props.statistics.settlements.map((row) => ['\u7ed3\u7b97\u53e3\u5f84', row.status, String(row.total), money(row.rewardCents), '', '', '']),
['summary', '\u5956\u52b1\u5408\u8ba1(\u5206)', String(props.statistics.summary.rewardCents), '', '', '', ''],
['summary', '\u5f85\u7ed3\u7b97(\u5206)', String(props.statistics.summary.pendingSettlementCents), '', '', '', ''],
['summary', '\u5f85\u53d1\u653e(\u5206)', String(props.statistics.summary.confirmedSettlementCents), '', '', '', ''],
['summary', '\u5df2\u53d1\u653e(\u5206)', String(props.statistics.summary.paidSettlementCents), '', '', '', ''],
['\u4efb\u52a1\u72b6\u6001', '\u72b6\u6001', '\u6570\u91cf', '\u5956\u52b1(\u5206)', '', '', ''],
...props.statistics.byStatus.map((row) => ['\u4efb\u52a1\u72b6\u6001', row.status, String(row.total), String(row.rewardCents), '', '', '']),
['\u7ed3\u7b97\u53e3\u5f84', '\u72b6\u6001', '\u5355\u6570', '\u91d1\u989d(\u5206)', '', '', ''],
...props.statistics.settlements.map((row) => ['\u7ed3\u7b97\u53e3\u5f84', row.status, String(row.total), String(row.rewardCents), '', '', '']),
['\u6bcf\u65e5\u8d8b\u52bf', '\u65e5\u671f', '\u4efb\u52a1', '\u5f85\u9a8c', '\u5b8c\u6210', '\u9a73\u56de', '\u514d\u6e05\u6d01'],
...props.statistics.trend.map((row) => [
'\u6bcf\u65e5\u8d8b\u52bf',
@@ -334,8 +334,8 @@ function exportStatistics() {
String(row.rejected),
String(row.exempted)
]),
['\u6bcf\u65e5\u53d1\u653e', '\u65e5\u671f', '\u5df2\u53d1\u653e', '', '', '', ''],
...props.statistics.trend.map((row) => ['\u6bcf\u65e5\u53d1\u653e', row.date, money(row.paidSettlementCents), '', '', '', '']),
['\u6bcf\u65e5\u53d1\u653e', '\u65e5\u671f', '\u5df2\u53d1\u653e(\u5206)', '', '', '', ''],
...props.statistics.trend.map((row) => ['\u6bcf\u65e5\u53d1\u653e', row.date, String(row.paidSettlementCents), '', '', '', '']),
['\u95e8\u5e97\u660e\u7ec6', '\u95e8\u5e97', '\u4efb\u52a1', '\u5f85\u9a8c', '\u5b8c\u6210', '\u9a73\u56de', '\u514d\u6e05\u6d01'],
...props.statistics.byStore.map((row) => [
'\u95e8\u5e97\u660e\u7ec6',
@@ -346,19 +346,19 @@ function exportStatistics() {
String(row.rejected),
String(row.exempted)
]),
['\u95e8\u5e97\u5956\u52b1', '\u95e8\u5e97', '\u5956\u52b1', '', '', '', ''],
...props.statistics.byStore.map((row) => ['\u95e8\u5e97\u5956\u52b1', `${row.storeName || ''}(${row.storeId})`, money(row.rewardCents), '', '', '', '']),
['\u6210\u5458\u5206\u8d26', '\u4fdd\u6d01\u5458', '\u4efb\u52a1', '\u5b8c\u6210', '\u9a73\u56de', '\u5f85\u7ed3\u7b97', '\u5df2\u7ed3\u7b97'],
['\u95e8\u5e97\u5956\u52b1', '\u95e8\u5e97', '\u5956\u52b1(\u5206)', '', '', '', ''],
...props.statistics.byStore.map((row) => ['\u95e8\u5e97\u5956\u52b1', `${row.storeName || ''}(${row.storeId})`, String(row.rewardCents), '', '', '', '']),
['\u6210\u5458\u5206\u8d26', '\u4fdd\u6d01\u5458', '\u4efb\u52a1', '\u5b8c\u6210', '\u9a73\u56de', '\u5f85\u7ed3\u7b97(\u5206)', '\u5df2\u7ed3\u7b97(\u5206)'],
...props.statistics.members.map((row) => [
'\u6210\u5458\u5206\u8d26',
`${row.cleanerName || ''}(${row.cleanerUserId})`,
String(row.taskCount),
String(row.completedTaskCount),
String(row.rejectedTaskCount),
money(row.pendingSettlementCents),
money(row.settledRewardCents)
String(row.pendingSettlementCents),
String(row.settledRewardCents)
]),
['\u7ee9\u6548\u6392\u884c', '\u6392\u540d', '\u4fdd\u6d01\u5458', '\u4efb\u52a1', '\u5b8c\u6210\u7387', '\u9a73\u56de', '\u7ed3\u7b97\u5408\u8ba1'],
['\u7ee9\u6548\u6392\u884c', '\u6392\u540d', '\u4fdd\u6d01\u5458', '\u4efb\u52a1', '\u5b8c\u6210\u7387', '\u9a73\u56de', '\u7ed3\u7b97\u5408\u8ba1(\u5206)'],
...cleanerPerformanceRows.value.map((row) => [
'\u7ee9\u6548\u6392\u884c',
String(row.rank),
@@ -366,7 +366,7 @@ function exportStatistics() {
String(row.taskCount),
`${row.completionRate}%`,
String(row.rejectedTaskCount),
money(row.settlementTotalCents)
String(row.settlementTotalCents)
])
]);
}
+6 -6
View File
@@ -791,7 +791,7 @@ async function removeMember(cleanerUserId: string) {
function exportTasks() {
downloadCsv(`cleaning-tasks-${Date.now()}.csv`, [
['任务号', '状态', '门店', '房间', '订单号', '保洁员ID', '协作人数', '奖励金额', '照片数', '提交时间', '完成时间', '驳回原因'],
['任务号', '状态', '门店', '房间', '订单号', '保洁员ID', '协作人数', '奖励金额(分)', '照片数', '提交时间', '完成时间', '驳回原因'],
...props.items.map((item) => [
item.taskNo,
item.status,
@@ -800,7 +800,7 @@ function exportTasks() {
item.orderNo || '',
item.cleanerUserId || '',
String(item.memberCount || 0),
money(item.rewardCents),
String(item.rewardCents),
String(item.photoUrls?.length || 0),
shortDate(item.submittedAt),
shortDate(item.completedAt),
@@ -820,7 +820,7 @@ function exportTaskDetail() {
['task', '房间', task.roomName || task.roomNo],
['task', '订单号', task.orderNo || ''],
['task', '保洁员ID', task.cleanerUserId || ''],
['task', '奖励金额', money(task.rewardCents)],
['task', '奖励金额(分)', String(task.rewardCents)],
['task', '照片数', String(task.photoUrls?.length || 0)],
['task', '领取时间', shortDate(task.claimedAt)],
['task', '开始时间', shortDate(task.startedAt)],
@@ -831,7 +831,7 @@ function exportTaskDetail() {
...detailDialog.members.map((member) => [
'member',
`${member.memberRole}:${member.userId}`,
`${member.nickname || ''} ${money(member.rewardCents)} ${member.settledAt ? shortDate(member.settledAt) : '未结算'}`
`${member.nickname || ''} ${member.rewardCents} ${member.settledAt ? shortDate(member.settledAt) : '未结算'}`
]),
...detailDialog.events.map((event) => [
'event',
@@ -873,13 +873,13 @@ function exportTaskEvents() {
function exportTaskMembers() {
downloadCsv(`cleaning-task-members-${memberDialog.taskNo}-${Date.now()}.csv`, [
['任务号', '成员ID', '昵称', '角色', '分账金额', '结算时间'],
['任务号', '成员ID', '昵称', '角色', '分账金额(分)', '结算时间'],
...memberDialog.members.map((member) => [
memberDialog.taskNo,
member.userId,
member.nickname || '',
member.memberRole,
money(member.rewardCents),
String(member.rewardCents),
shortDate(member.settledAt)
])
]);
+31 -3
View File
@@ -113,6 +113,8 @@
@sync-transfer="handleSyncTransfer"
@sync-transfer-many="handleSyncTransferMany"
@failure="handleFailure"
@cancel="handleCancelSettlement"
@paid="handleManualPaid"
/>
</el-tab-pane>
<el-tab-pane label="统计" name="statistics">
@@ -167,6 +169,7 @@ import CleaningFieldHandoffPanel from './CleaningFieldHandoffPanel.vue';
import {
ApiError,
assignCleaningTask,
cancelCleaningSettlement,
completeCleaningTask,
confirmCleaningSettlement,
createStaffUser,
@@ -177,6 +180,7 @@ import {
listCleaningSettlements,
listCleaningTasks,
listStaffUsers,
markCleaningSettlementPaid,
reclaimCleaningTimeouts,
recordPayoutFailure,
rejectCleaningTask,
@@ -234,6 +238,7 @@ const statistics = reactive<CleaningStatistics>({
completed: 0,
rewardCents: 0,
pendingSettlementCents: 0,
draftSettlementCents: 0,
confirmedSettlementCents: 0,
paidSettlementCents: 0
},
@@ -372,12 +377,12 @@ function jumpToSettlementStatus(status: SettlementStatus) {
function exportOperationalQueue() {
downloadCsv(`cleaning-operational-queue-${Date.now()}.csv`, [
['队列', '数量', '金额', '入口'],
['队列', '数量', '金额(分)', '入口'],
['待验收任务', String(statistics.summary.pendingReview), '', '任务/SUBMITTED'],
['驳回补做', String(statistics.summary.rejected), '', '任务/REJECTED'],
['待确认结算', String(draftSettlementTotal.value), '', '结算/DRAFT'],
['待发放金额', '', money(statistics.summary.confirmedSettlementCents), '结算/CONFIRMED'],
['待结算金额', '', money(statistics.summary.pendingSettlementCents), '统计/待结算'],
['待发放金额', '', String(statistics.summary.confirmedSettlementCents), '结算/CONFIRMED'],
['待结算金额', '', String(statistics.summary.pendingSettlementCents), '统计/待结算'],
['已完成任务', String(statistics.summary.completed), '', '统计/已完成']
]);
}
@@ -519,6 +524,29 @@ async function handleConfirmSettlement(input: { settlementId: string; note?: str
}, '已确认结算单');
}
async function handleCancelSettlement(input: { settlementId: string; reason: string }) {
await runAction(async () => {
await cancelCleaningSettlement(session.value, input.settlementId, input.reason);
await Promise.all([loadTasks(), loadSettlements(), loadStatistics()]);
}, '结算单已冲正');
}
async function handleManualPaid(input: {
settlementId: string;
payoutChannel: string;
payoutReference: string;
note?: string;
}) {
await runAction(async () => {
await markCleaningSettlementPaid(session.value, input.settlementId, {
payoutChannel: input.payoutChannel,
payoutReference: input.payoutReference,
note: input.note
});
await Promise.all([loadTasks(), loadSettlements(), loadStatistics()]);
}, '人工付款已完成');
}
async function handleTransfer(input: { settlementId: string; mode: TransferMode; note?: string }) {
await runAction(async () => {
await executeWechatTransfer(session.value, input.settlementId, {
+31
View File
@@ -673,6 +673,7 @@ export interface CleaningTaskEvent {
toStatus: TaskStatus;
action: string;
actorId: string;
actorType: 'USER' | 'SYSTEM';
traceId: string;
note: string;
createdAt: string;
@@ -705,9 +706,12 @@ export interface CleaningSettlement {
periodStart: string | null;
periodEnd: string | null;
paidBy: string | null;
cancelledBy: string | null;
confirmedAt: string | null;
paidAt: string | null;
cancelledAt: string | null;
payoutChannel: string;
payoutRequestNo: string;
payoutReference: string;
payoutState: string;
payoutPackageInfo: string;
@@ -728,13 +732,39 @@ export interface CleaningSettlementItem {
cleanerUserId: string;
cleanerName: string;
rewardCents: number;
reversedAt: string | null;
completedAt: string | null;
createdAt: string;
}
export interface CleaningSettlementEvent {
id: string;
settlementId: string;
fromStatus: SettlementStatus | null;
toStatus: SettlementStatus;
action: string;
actorType: 'USER' | 'SYSTEM';
actorId: string;
traceId: string;
note: string;
createdAt: string;
}
export interface CleaningSettlementReversal {
id: string;
settlementId: string;
reversalNo: string;
amountDeltaCents: number;
reason: string;
reversedBy: string;
createdAt: string;
}
export interface CleaningSettlementDetail {
settlement: CleaningSettlement;
items: CleaningSettlementItem[];
events: CleaningSettlementEvent[];
reversals: CleaningSettlementReversal[];
}
export interface CleaningStatistics {
@@ -747,6 +777,7 @@ export interface CleaningStatistics {
completed: number;
rewardCents: number;
pendingSettlementCents: number;
draftSettlementCents: number;
confirmedSettlementCents: number;
paidSettlementCents: number;
};
+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) {
+508 -93
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,6 +887,7 @@ export class CleaningTaskRepository {
]
);
const settlementId = String(created.insertId);
try {
for (const task of tasks) {
await connection.execute(
`INSERT INTO qipai_cleaning_settlement_items
@@ -840,31 +896,32 @@ export class CleaningTaskRepository {
[input.tenantId, settlementId, task.id, input.cleanerUserId, Number(task.rewardCents)]
);
}
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)]
} catch (error) {
if (isMySqlDuplicate(error)) {
throw new CleaningTaskError('CLEANING_SETTLEMENT_CONCURRENTLY_CLAIMED');
}
throw error;
}
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);
});
} catch (error) {
if (isMySqlSettlementContention(error)) {
throw new CleaningTaskError('CLEANING_SETTLEMENT_CONCURRENTLY_CLAIMED');
}
throw error;
}
}
async confirmSettlement(input: CleaningActor & { settlementId: string; note?: string }) {
@@ -879,12 +936,16 @@ export class CleaningTaskRepository {
[input.userId, input.note ?? '', input.note ?? '', input.tenantId, input.settlementId]
);
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_SETTLEMENT_STATUS_CONFLICT');
await this.recordSettlementEventWithConnection(
connection, input, input.settlementId, 'DRAFT', 'CONFIRMED', 'CONFIRM', input.note ?? ''
);
return this.getSettlement(connection, input.tenantId, input.settlementId);
});
}
async markSettlementPaid(input: CleaningActor & {
settlementId: string; payoutChannel: string; payoutReference: string; note?: string;
providerConfirmed?: boolean; manualOverride?: boolean;
}) {
this.assertSettlement(input.access, 'write');
return this.transaction(async (connection) => {
@@ -895,6 +956,11 @@ export class CleaningTaskRepository {
s.payout_package_info = '', s.payout_error = '',
s.note = CASE WHEN ? = '' THEN s.note ELSE ? END
WHERE s.tenant_id = ? AND s.id = ? AND s.status = 'CONFIRMED' AND s.deleted_at IS NULL
AND ${input.providerConfirmed
? "s.payout_state <> 'SUCCESS'"
: input.manualOverride
? "s.payout_state IN ('', 'FAIL')"
: '1 = 0'}
AND ${storeScopeSql(input.access, 'COALESCE(s.store_id, 0)')}`,
[
input.userId, input.payoutChannel, input.payoutReference,
@@ -902,12 +968,58 @@ export class CleaningTaskRepository {
]
);
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_SETTLEMENT_STATUS_CONFLICT');
await connection.execute(
`UPDATE qipai_cleaning_task_members m
INNER JOIN qipai_cleaning_settlement_items i
ON i.tenant_id = m.tenant_id AND i.task_id = m.task_id
AND i.cleaner_user_id = m.user_id AND i.reversed_at IS NULL
SET m.settled_at = UTC_TIMESTAMP(3), m.updated_at = UTC_TIMESTAMP(3)
WHERE i.tenant_id = ? AND i.settlement_id = ? AND m.removed_at IS NULL`,
[input.tenantId, input.settlementId]
);
const [paidTasks] = await connection.execute<Array<RowDataPacket & { id: string }>>(
`SELECT DISTINCT t.id
FROM qipai_cleaning_tasks t
INNER JOIN qipai_cleaning_settlement_items i
ON i.tenant_id = t.tenant_id AND i.task_id = t.id AND i.reversed_at IS NULL
WHERE i.tenant_id = ? AND i.settlement_id = ?
AND t.status = 'COMPLETED'
AND NOT EXISTS (
SELECT 1 FROM qipai_cleaning_task_members pending
WHERE pending.tenant_id = t.tenant_id AND pending.task_id = t.id
AND pending.removed_at IS NULL AND pending.reward_cents > 0
AND pending.settled_at IS NULL
)
FOR UPDATE`,
[input.tenantId, input.settlementId]
);
if (paidTasks.length > 0) {
await connection.execute(
`UPDATE qipai_cleaning_tasks
SET status = 'SETTLED', settled_at = UTC_TIMESTAMP(3)
WHERE tenant_id = ? AND status = 'COMPLETED'
AND id IN (${paidTasks.map(() => '?').join(',')})`,
[input.tenantId, ...paidTasks.map((task) => task.id)]
);
for (const task of paidTasks) {
await this.recordEventWithConnection(
connection, input, String(task.id), 'COMPLETED', 'SETTLED', 'SETTLEMENT_PAID', input.payoutReference
);
}
}
await this.recordSettlementEventWithConnection(
connection, input, input.settlementId, 'CONFIRMED', 'PAID', 'PAID', input.note ?? '', {
payoutChannel: input.payoutChannel,
payoutReference: input.payoutReference
}
);
return this.getSettlement(connection, input.tenantId, input.settlementId);
});
}
async recordSettlementPayoutFailure(input: CleaningActor & {
settlementId: string; payoutChannel?: string; payoutReference?: string; error: string; note?: string;
settlementId: string; payoutChannel?: string; payoutReference?: string;
error: string; note?: string; providerConfirmed?: boolean;
}) {
this.assertSettlement(input.access, 'write');
return this.transaction(async (connection) => {
@@ -918,6 +1030,9 @@ export class CleaningTaskRepository {
s.payout_state = 'FAIL', s.payout_package_info = '', s.payout_error = ?,
s.note = CASE WHEN ? = '' THEN s.note ELSE ? END
WHERE s.tenant_id = ? AND s.id = ? AND s.status = 'CONFIRMED' AND s.deleted_at IS NULL
AND ${input.providerConfirmed
? "s.payout_state <> 'SUCCESS'"
: "s.payout_state IN ('', 'FAIL')"}
AND ${storeScopeSql(input.access, 'COALESCE(s.store_id, 0)')}`,
[
input.payoutChannel ?? '', input.payoutChannel ?? '',
@@ -927,6 +1042,47 @@ export class CleaningTaskRepository {
]
);
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_SETTLEMENT_STATUS_CONFLICT');
await this.recordSettlementEventWithConnection(
connection, input, input.settlementId, 'CONFIRMED', 'CONFIRMED', 'PAYOUT_FAILURE',
input.note ?? input.error, {
payoutChannel: input.payoutChannel ?? '',
payoutReference: input.payoutReference ?? '',
error: input.error.slice(0, 512)
}
);
return this.getSettlement(connection, input.tenantId, input.settlementId);
});
}
async beginSettlementPayout(input: CleaningActor & {
settlementId: string; payoutChannel: string; payoutReference: string; note?: string;
}) {
this.assertSettlement(input.access, 'write');
return this.transaction(async (connection) => {
const [result] = await connection.execute<ResultSetHeader>(
`UPDATE qipai_cleaning_settlements s
SET s.payout_channel = ?, s.payout_request_no = ?, s.payout_reference = ?,
s.payout_state = 'PROCESSING',
s.payout_package_info = '', s.payout_error = '',
s.note = CASE WHEN ? = '' THEN s.note ELSE ? END
WHERE s.tenant_id = ? AND s.id = ? AND s.status = 'CONFIRMED'
AND s.payout_state IN ('', 'FAIL') AND s.deleted_at IS NULL
AND ${storeScopeSql(input.access, 'COALESCE(s.store_id, 0)')}`,
[input.payoutChannel, input.payoutReference, input.payoutReference,
input.note ?? '', input.note ?? '',
input.tenantId, input.settlementId]
);
if (result.affectedRows !== 1) {
throw new CleaningTaskError('CLEANING_SETTLEMENT_PAYOUT_ALREADY_STARTED');
}
await this.recordSettlementEventWithConnection(
connection, input, input.settlementId, 'CONFIRMED', 'CONFIRMED', 'PAYOUT_BEGIN',
input.note ?? '', {
payoutChannel: input.payoutChannel,
payoutReference: input.payoutReference,
payoutState: 'PROCESSING'
}
);
return this.getSettlement(connection, input.tenantId, input.settlementId);
});
}
@@ -943,6 +1099,7 @@ export class CleaningTaskRepository {
s.payout_package_info = ?, s.payout_error = '',
s.note = CASE WHEN ? = '' THEN s.note ELSE ? END
WHERE s.tenant_id = ? AND s.id = ? AND s.status = 'CONFIRMED' AND s.deleted_at IS NULL
AND s.payout_state IN ('PROCESSING', 'WAIT_USER_CONFIRM')
AND ${storeScopeSql(input.access, 'COALESCE(s.store_id, 0)')}`,
[
input.payoutChannel, input.payoutReference, input.payoutState.slice(0, 32),
@@ -951,6 +1108,105 @@ export class CleaningTaskRepository {
]
);
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_SETTLEMENT_STATUS_CONFLICT');
await this.recordSettlementEventWithConnection(
connection, input, input.settlementId, 'CONFIRMED', 'CONFIRMED', 'PAYOUT_PENDING',
input.note ?? '', {
payoutChannel: input.payoutChannel,
payoutReference: input.payoutReference,
payoutState: input.payoutState.slice(0, 32)
}
);
return this.getSettlement(connection, input.tenantId, input.settlementId);
});
}
async cancelSettlement(input: CleaningActor & { settlementId: string; reason: string }) {
this.assertSettlement(input.access, 'write');
return this.transaction(async (connection) => {
const [rows] = await connection.execute<Array<RowDataPacket & {
status: CleaningSettlementStatus;
payoutState: string;
totalRewardCents: number;
settlementNo: string;
}>>(
`SELECT s.status, s.payout_state AS payoutState,
s.total_reward_cents AS totalRewardCents, s.settlement_no AS settlementNo
FROM qipai_cleaning_settlements s
WHERE s.tenant_id = ? AND s.id = ? AND s.deleted_at IS NULL
AND ${storeScopeSql(input.access, 'COALESCE(s.store_id, 0)')}
FOR UPDATE`,
[input.tenantId, input.settlementId]
);
const settlement = rows[0];
if (!settlement) throw new CleaningTaskError('CLEANING_SETTLEMENT_NOT_FOUND');
if (!['DRAFT', 'CONFIRMED'].includes(settlement.status)) {
throw new CleaningTaskError('CLEANING_SETTLEMENT_STATUS_CONFLICT');
}
if (['PROCESSING', 'WAIT_USER_CONFIRM', 'SUCCESS'].includes(settlement.payoutState)) {
throw new CleaningTaskError('CLEANING_SETTLEMENT_PAYOUT_PENDING');
}
const reversalNo = `CLR-${Date.now()}-${input.settlementId}-${input.traceId.slice(-8)}`;
const [created] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_cleaning_settlement_reversals
(tenant_id, reversal_no, settlement_id, amount_delta_cents, reason, reversed_by)
VALUES (?, ?, ?, ?, ?, ?)`,
[input.tenantId, reversalNo, input.settlementId,
-Number(settlement.totalRewardCents), input.reason, input.userId]
);
const reversalId = String(created.insertId);
const [items] = await connection.execute<Array<RowDataPacket & { taskId: string }>>(
`SELECT i.task_id AS taskId
FROM qipai_cleaning_settlement_items i
WHERE i.tenant_id = ? AND i.settlement_id = ? AND i.reversed_at IS NULL
FOR UPDATE`,
[input.tenantId, input.settlementId]
);
if (items.length === 0) throw new CleaningTaskError('CLEANING_SETTLEMENT_STATUS_CONFLICT');
await connection.execute(
`UPDATE qipai_cleaning_settlement_items
SET reversal_id = ?, reversed_at = UTC_TIMESTAMP(3)
WHERE tenant_id = ? AND settlement_id = ? AND reversed_at IS NULL`,
[reversalId, input.tenantId, input.settlementId]
);
await connection.execute(
`UPDATE qipai_cleaning_task_members m
INNER JOIN qipai_cleaning_settlement_items i
ON i.tenant_id = m.tenant_id AND i.task_id = m.task_id
AND i.cleaner_user_id = m.user_id
SET m.settled_at = NULL, m.updated_at = UTC_TIMESTAMP(3)
WHERE i.tenant_id = ? AND i.settlement_id = ? AND i.reversal_id = ?`,
[input.tenantId, input.settlementId, reversalId]
);
await connection.execute(
`UPDATE qipai_cleaning_tasks t
INNER JOIN qipai_cleaning_settlement_items i
ON i.tenant_id = t.tenant_id AND i.task_id = t.id
SET t.status = 'COMPLETED', t.settled_at = NULL
WHERE i.tenant_id = ? AND i.settlement_id = ? AND i.reversal_id = ?
AND t.status IN ('COMPLETED', 'SETTLED')`,
[input.tenantId, input.settlementId, reversalId]
);
const [updated] = await connection.execute<ResultSetHeader>(
`UPDATE qipai_cleaning_settlements
SET status = 'CANCELLED', cancelled_by = ?, cancelled_at = UTC_TIMESTAMP(3),
note = CASE WHEN ? = '' THEN note ELSE ? END
WHERE tenant_id = ? AND id = ? AND status = ? AND deleted_at IS NULL`,
[input.userId, input.reason, input.reason, input.tenantId,
input.settlementId, settlement.status]
);
if (updated.affectedRows !== 1) throw new CleaningTaskError('CLEANING_SETTLEMENT_STATUS_CONFLICT');
await this.recordSettlementEventWithConnection(
connection, input, input.settlementId, settlement.status, 'CANCELLED', 'CANCEL', input.reason, {
reversalId,
reversalNo,
amountDeltaCents: -Number(settlement.totalRewardCents)
}
);
for (const item of items) {
await this.recordEventWithConnection(
connection, input, String(item.taskId), 'COMPLETED', 'COMPLETED', 'REVERSE_SETTLEMENT', reversalNo
);
}
return this.getSettlement(connection, input.tenantId, input.settlementId);
});
}
@@ -1245,16 +1501,16 @@ export class CleaningTaskRepository {
INNER JOIN qipai_cleaning_tasks t ON t.tenant_id = m.tenant_id AND t.id = m.task_id
WHERE m.tenant_id = ? AND m.user_id = ? AND m.removed_at IS NULL
AND m.settled_at IS NULL AND m.reward_cents > 0
AND t.status IN ('SUBMITTED', 'COMPLETED') AND t.deleted_at IS NULL`,
AND t.status = 'COMPLETED' AND t.deleted_at IS NULL`,
[input.tenantId, input.userId]
);
const [settledRows] = await this.pool.execute<AmountRow[]>(
`SELECT COALESCE(SUM(m.reward_cents), 0) AS amount
FROM qipai_cleaning_task_members m
INNER JOIN qipai_cleaning_tasks t ON t.tenant_id = m.tenant_id AND t.id = m.task_id
WHERE m.tenant_id = ? AND m.user_id = ? AND m.removed_at IS NULL
AND m.settled_at IS NOT NULL AND m.reward_cents > 0
AND t.deleted_at IS NULL`,
`SELECT COALESCE(SUM(i.reward_cents), 0) AS amount
FROM qipai_cleaning_settlement_items i
INNER JOIN qipai_cleaning_settlements s
ON s.tenant_id = i.tenant_id AND s.id = i.settlement_id
WHERE i.tenant_id = ? AND i.cleaner_user_id = ? AND i.reversed_at IS NULL
AND s.status = 'PAID' AND s.deleted_at IS NULL`,
[input.tenantId, input.userId]
);
const byStatus = Object.fromEntries(counts.map((row) => [row.status, Number(row.total)]));
@@ -1368,10 +1624,21 @@ export class CleaningTaskRepository {
SUM(CASE WHEN t.status IN ('COMPLETED', 'SETTLED') THEN 1 ELSE 0 END) AS completedTaskCount,
SUM(CASE WHEN t.status = 'REJECTED' THEN 1 ELSE 0 END) AS rejectedTaskCount,
COALESCE(SUM(CASE
WHEN m.removed_at IS NULL AND m.settled_at IS NULL AND t.status IN ('SUBMITTED', 'COMPLETED')
WHEN m.removed_at IS NULL AND m.settled_at IS NULL AND t.status = 'COMPLETED'
THEN m.reward_cents ELSE 0 END), 0) AS pendingSettlementCents,
COALESCE(SUM(CASE
WHEN m.settled_at IS NOT NULL THEN m.reward_cents ELSE 0 END), 0) AS settledRewardCents
COALESCE(SUM(COALESCE((
SELECT MAX(paid_item.reward_cents)
FROM qipai_cleaning_settlement_items paid_item
INNER JOIN qipai_cleaning_settlements paid_settlement
ON paid_settlement.tenant_id = paid_item.tenant_id
AND paid_settlement.id = paid_item.settlement_id
WHERE paid_item.tenant_id = m.tenant_id
AND paid_item.task_id = m.task_id
AND paid_item.cleaner_user_id = m.user_id
AND paid_item.reversed_at IS NULL
AND paid_settlement.status = 'PAID'
AND paid_settlement.deleted_at IS NULL
), 0)), 0) AS settledRewardCents
FROM qipai_cleaning_task_members m
INNER JOIN qipai_cleaning_tasks t ON t.tenant_id = m.tenant_id AND t.id = m.task_id
INNER JOIN qipai_users u ON u.tenant_id = m.tenant_id AND u.id = m.user_id
@@ -1414,6 +1681,66 @@ export class CleaningTaskRepository {
return publicManagerStatistics(statusRows, storeRows, settlementRows, memberRows, trendRows);
}
private async querySettlementRows(input: CleaningActor & {
status?: CleaningSettlementStatus; payoutState?: CleaningSettlementPayoutState;
storeId?: string; cleanerUserId?: string;
}, limit: number, offset: number) {
const where = [
's.tenant_id = ?',
's.deleted_at IS NULL',
storeScopeSql(input.access, 'COALESCE(s.store_id, 0)')
];
const params: Array<string | number> = [input.tenantId];
if (input.status) {
where.push('s.status = ?');
params.push(input.status);
}
if (input.payoutState) {
if (input.payoutState === 'NONE') where.push("s.payout_state = ''");
else {
where.push('s.payout_state = ?');
params.push(input.payoutState);
}
}
if (input.storeId) {
where.push('s.store_id = ?');
params.push(input.storeId);
}
if (input.cleanerUserId) {
where.push('s.cleaner_user_id = ?');
params.push(input.cleanerUserId);
}
const whereSql = where.join(' AND ');
const safeLimit = Math.max(1, Math.min(5000, Math.trunc(limit)));
const safeOffset = Math.max(0, Math.trunc(offset));
const [counts] = await this.pool.execute<CountRow[]>(
`SELECT COUNT(*) AS total FROM qipai_cleaning_settlements s WHERE ${whereSql}`,
params
);
const [rows] = await this.pool.execute<SettlementRow[]>(
`SELECT s.id, s.settlement_no AS settlementNo,
s.cleaner_user_id AS cleanerUserId, u.nickname AS cleanerName,
s.store_id AS storeId, st.name AS storeName, s.status,
s.task_count AS taskCount, s.total_reward_cents AS totalRewardCents,
s.period_start AS periodStart, s.period_end AS periodEnd,
s.paid_by AS paidBy, s.cancelled_by AS cancelledBy,
s.confirmed_at AS confirmedAt, s.paid_at AS paidAt,
s.cancelled_at AS cancelledAt,
s.payout_channel AS payoutChannel, s.payout_request_no AS payoutRequestNo,
s.payout_reference AS payoutReference,
s.payout_state AS payoutState, s.payout_package_info AS payoutPackageInfo,
s.payout_error AS payoutError, s.note, s.created_at AS createdAt
FROM qipai_cleaning_settlements s
INNER JOIN qipai_users u ON u.tenant_id = s.tenant_id AND u.id = s.cleaner_user_id
LEFT JOIN qipai_stores st ON st.tenant_id = s.tenant_id AND st.id = s.store_id
WHERE ${whereSql}
ORDER BY s.created_at DESC, s.id DESC
LIMIT ${safeLimit} OFFSET ${safeOffset}`,
params
);
return { rows, total: Number(counts[0]?.total ?? 0) };
}
private async listByWhere(
where: string[],
params: Array<string | number>,
@@ -1615,6 +1942,21 @@ export class CleaningTaskRepository {
);
}
private async assertTaskSettlementMutable(
connection: PoolConnection,
tenantId: string,
taskId: string
) {
const [rows] = await connection.execute<Array<RowDataPacket & { id: string }>>(
`SELECT id
FROM qipai_cleaning_settlement_items
WHERE tenant_id = ? AND task_id = ? AND reversed_at IS NULL
LIMIT 1 FOR UPDATE`,
[tenantId, taskId]
);
if (rows[0]) throw new CleaningTaskError('CLEANING_MEMBER_SETTLEMENT_LOCKED');
}
private async getTemplate(
connection: Pick<MySqlPool, 'execute'> | PoolConnection,
tenantId: string,
@@ -1718,9 +2060,31 @@ export class CleaningTaskRepository {
await connection.execute(
`INSERT INTO qipai_cleaning_task_events
(tenant_id, task_id, from_status, to_status, action, actor_id, trace_id, note, metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, JSON_OBJECT())`,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, JSON_OBJECT('actorType', ?))`,
[input.tenantId, taskId, from, to, action, input.userId,
`${input.traceId}-${taskId}`.slice(0, 128), note.slice(0, 512)]
`${input.traceId}-${taskId}`.slice(0, 128), note.slice(0, 512),
input.actorType ?? 'USER']
);
}
private async recordSettlementEventWithConnection(
connection: PoolConnection,
input: CleaningActor,
settlementId: string,
from: CleaningSettlementStatus | null,
to: CleaningSettlementStatus,
action: string,
note: string,
metadata: Record<string, unknown> = {}
) {
await connection.execute(
`INSERT INTO qipai_cleaning_settlement_events
(tenant_id, settlement_id, from_status, to_status, action,
actor_id, actor_type, trace_id, note, metadata)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON))`,
[input.tenantId, settlementId, from, to, action.slice(0, 32), input.userId,
input.actorType ?? 'USER', input.traceId.slice(0, 64), note.slice(0, 512),
JSON.stringify(metadata)]
);
}
@@ -1753,8 +2117,11 @@ export class CleaningTaskRepository {
s.store_id AS storeId, st.name AS storeName, s.status,
s.task_count AS taskCount, s.total_reward_cents AS totalRewardCents,
s.period_start AS periodStart, s.period_end AS periodEnd,
s.paid_by AS paidBy, s.confirmed_at AS confirmedAt, s.paid_at AS paidAt,
s.payout_channel AS payoutChannel, s.payout_reference AS payoutReference,
s.paid_by AS paidBy, s.cancelled_by AS cancelledBy,
s.confirmed_at AS confirmedAt, s.paid_at AS paidAt,
s.cancelled_at AS cancelledAt,
s.payout_channel AS payoutChannel, s.payout_request_no AS payoutRequestNo,
s.payout_reference AS payoutReference,
s.payout_state AS payoutState, s.payout_package_info AS payoutPackageInfo,
s.payout_error AS payoutError,
s.note, s.created_at AS createdAt
@@ -1775,8 +2142,11 @@ export class CleaningTaskRepository {
s.store_id AS storeId, st.name AS storeName, s.status,
s.task_count AS taskCount, s.total_reward_cents AS totalRewardCents,
s.period_start AS periodStart, s.period_end AS periodEnd,
s.paid_by AS paidBy, s.confirmed_at AS confirmedAt, s.paid_at AS paidAt,
s.payout_channel AS payoutChannel, s.payout_reference AS payoutReference,
s.paid_by AS paidBy, s.cancelled_by AS cancelledBy,
s.confirmed_at AS confirmedAt, s.paid_at AS paidAt,
s.cancelled_at AS cancelledAt,
s.payout_channel AS payoutChannel, s.payout_request_no AS payoutRequestNo,
s.payout_reference AS payoutReference,
s.payout_state AS payoutState, s.payout_package_info AS payoutPackageInfo,
s.payout_error AS payoutError,
s.note, s.created_at AS createdAt
@@ -1899,6 +2269,7 @@ function publicTaskEvent(row: CleaningTaskEventRow) {
toStatus: row.toStatus,
action: row.action,
actorId: String(row.actorId),
actorType: row.actorType,
traceId: row.traceId,
note: row.note,
createdAt: row.createdAt
@@ -1935,9 +2306,12 @@ function publicSettlement(row: SettlementRow) {
periodStart: row.periodStart,
periodEnd: row.periodEnd,
paidBy: row.paidBy === null ? null : String(row.paidBy),
cancelledBy: row.cancelledBy === null ? null : String(row.cancelledBy),
confirmedAt: row.confirmedAt,
paidAt: row.paidAt,
cancelledAt: row.cancelledAt,
payoutChannel: row.payoutChannel,
payoutRequestNo: row.payoutRequestNo,
payoutReference: row.payoutReference,
payoutState: row.payoutState,
payoutPackageInfo: row.payoutPackageInfo,
@@ -1960,11 +2334,38 @@ function publicSettlementItem(row: SettlementItemRow) {
cleanerUserId: String(row.cleanerUserId),
cleanerName: row.cleanerName,
rewardCents: Number(row.rewardCents),
reversedAt: row.reversedAt,
completedAt: row.completedAt,
createdAt: row.createdAt
};
}
function publicSettlementEvent(row: SettlementEventRow) {
return {
id: String(row.id),
settlementId: String(row.settlementId),
fromStatus: row.fromStatus,
toStatus: row.toStatus,
action: row.action,
actorId: String(row.actorId),
traceId: row.traceId,
note: row.note,
createdAt: row.createdAt
};
}
function publicSettlementReversal(row: SettlementReversalRow) {
return {
id: String(row.id),
settlementId: String(row.settlementId),
reversalNo: row.reversalNo,
amountDeltaCents: Number(row.amountDeltaCents),
reason: row.reason,
reversedBy: String(row.reversedBy),
createdAt: row.createdAt
};
}
function publicManagerStatistics(
statusRows: ManagerStatusStatsRow[],
storeRows: ManagerStoreStatsRow[],
@@ -1992,6 +2393,9 @@ function publicManagerStatistics(
completed: countStatus(byStatus, 'COMPLETED') + countStatus(byStatus, 'SETTLED'),
rewardCents: byStatus.reduce((sum, row) => sum + row.rewardCents, 0),
pendingSettlementCents: memberRows.reduce((sum, row) => sum + Number(row.pendingSettlementCents ?? 0), 0),
draftSettlementCents: settlementByStatus
.filter((row) => row.status === 'DRAFT')
.reduce((sum, row) => sum + row.rewardCents, 0),
confirmedSettlementCents: settlementByStatus
.filter((row) => row.status === 'CONFIRMED')
.reduce((sum, row) => sum + row.rewardCents, 0),
@@ -2057,6 +2461,17 @@ function maxDate(values: Array<Date | null>) {
return new Date(Math.max(...dates.map((date) => date.getTime())));
}
function isMySqlDuplicate(error: unknown) {
return typeof error === 'object' && error !== null
&& Reflect.get(error, 'code') === 'ER_DUP_ENTRY';
}
function isMySqlSettlementContention(error: unknown) {
if (isMySqlDuplicate(error)) return true;
if (typeof error !== 'object' || error === null) return false;
return ['ER_LOCK_DEADLOCK', 'ER_LOCK_WAIT_TIMEOUT'].includes(String(Reflect.get(error, 'code')));
}
function parseJsonArray(value: string | string[] | null): string[] {
if (Array.isArray(value)) return value.map(String);
if (!value) return [];
+8 -3
View File
@@ -54,7 +54,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026081002_m08d_content_asset_scope.up.sql',
'database/migrations/2026081003_m08d_franchise_leads.up.sql',
'database/migrations/2026081004_m08d_admin_password_auth.up.sql',
'database/migrations/2026081005_m09b_cleaning_rules.up.sql'
'database/migrations/2026081005_m09b_cleaning_rules.up.sql',
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.up.sql'
],
verify: [
'database/migrations/2026061601_m01b_core_schema.verify.sql',
@@ -90,9 +91,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026081002_m08d_content_asset_scope.verify.sql',
'database/migrations/2026081003_m08d_franchise_leads.verify.sql',
'database/migrations/2026081004_m08d_admin_password_auth.verify.sql',
'database/migrations/2026081005_m09b_cleaning_rules.verify.sql'
'database/migrations/2026081005_m09b_cleaning_rules.verify.sql',
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.verify.sql'
],
down: [
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.down.sql',
'database/migrations/2026081005_m09b_cleaning_rules.down.sql',
'database/migrations/2026081004_m08d_admin_password_auth.down.sql',
'database/migrations/2026081003_m08d_franchise_leads.down.sql',
@@ -274,7 +277,9 @@ export async function executeMigrationPlan(
2, 1, 1,
1, 1, 1,
1, 2, 3, 1,
1, 2, 3, 1
1, 2, 3, 1,
3, 7, 3, 1,
2, 8, 4, 1
][index] ?? 1;
if (!Array.isArray(result) || result.length < minimumRows) {
throw new Error(
+36 -2
View File
@@ -84,6 +84,7 @@ const settlementListSchema = z.object({
storeId: z.string().regex(/^[1-9]\d{0,19}$/).optional(),
cleanerUserId: z.string().regex(/^[1-9]\d{0,19}$/).optional()
}).strict();
const settlementExportSchema = settlementListSchema.omit({ page: true, pageSize: true });
const settlementParamsSchema = z.object({ settlementId: z.string().regex(/^[1-9]\d{0,19}$/) });
const settlementGenerateSchema = z.object({
cleanerUserId: z.string().regex(/^[1-9]\d{0,19}$/),
@@ -93,6 +94,9 @@ const settlementGenerateSchema = z.object({
const settlementConfirmSchema = z.object({
note: z.string().trim().max(512).optional()
}).strict();
const settlementCancelSchema = z.object({
reason: z.string().trim().min(1).max(512)
}).strict();
const settlementPaidSchema = z.object({
payoutChannel: z.string().trim().min(1).max(32),
payoutReference: z.string().trim().min(1).max(128),
@@ -129,7 +133,7 @@ export interface CleaningRouteOptions {
repository: Pick<CleaningTaskRepository,
'listHall' | 'listMine' | 'listManage' | 'claim' | 'start' | 'rework' | 'submit'
| 'assign' | 'complete' | 'reject' | 'exempt' | 'listMembers' | 'listEvents' | 'listSubmissions' | 'addMember' | 'removeMember' | 'settlementCandidates'
| 'listSettlements' | 'getSettlementDetail' | 'generateSettlement' | 'confirmSettlement' | 'markSettlementPaid'
| 'listSettlements' | 'exportSettlements' | 'getSettlementDetail' | 'generateSettlement' | 'confirmSettlement' | 'cancelSettlement' | 'markSettlementPaid'
| 'recordSettlementPayoutFailure' | 'reclaimTimeouts'
| 'assertCanUploadPhoto' | 'recordPhotoUpload' | 'claimExpiredPhotoUploads' | 'markPhotoUploadsDeleted'
| 'listTemplates' | 'upsertTemplate'
@@ -269,6 +273,18 @@ export async function registerCleaningRoutes(
}));
});
app.get('/admin-api/cleaning/settlements/export', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
if (!actor) return;
const query = settlementExportSchema.safeParse(request.query);
if (!query.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.exportSettlements({ ...actor, ...query.data }),
traceId: request.traceId
}));
});
app.get('/admin-api/cleaning/settlements/:settlementId', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'read');
if (!actor) return;
@@ -607,6 +623,23 @@ export async function registerCleaningRoutes(
}));
});
app.post('/admin-api/cleaning/settlements/:settlementId/cancel', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
const params = settlementParamsSchema.safeParse(request.params);
const body = settlementCancelSchema.safeParse(request.body ?? {});
if (!params.success || !body.success) return invalid(reply, request.traceId);
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.cancelSettlement({
...actor,
settlementId: params.data.settlementId,
reason: body.data.reason
}),
traceId: request.traceId
}));
});
app.post('/admin-api/cleaning/settlements/:settlementId/paid', async (request, reply) => {
const actor = await requireActor(request, reply, options, 'write');
if (!actor) return;
@@ -620,7 +653,8 @@ export async function registerCleaningRoutes(
settlementId: params.data.settlementId,
payoutChannel: body.data.payoutChannel,
payoutReference: body.data.payoutReference,
note: body.data.note
note: body.data.note,
manualOverride: true
}),
traceId: request.traceId
}));
+51 -4
View File
@@ -60,6 +60,8 @@ const actor = {
mode: 'API'
});
assert.equal(result.transferState, 'SUCCESS');
assert.equal(harness.repository.begun.length, 1);
assert.equal(harness.repository.begun[0].payoutReference, 'CLP501');
assert.equal(harness.repository.paid[0].payoutChannel, 'WECHAT_TRANSFER');
assert.equal(harness.state.transferInput.amountCents, 1200);
assert.equal(harness.state.transferInput.openid, 'openid-cleaner');
@@ -79,6 +81,20 @@ const actor = {
assert.equal(harness.repository.paid.length, 0);
}
{
const harness = createHarness({ payoutState: 'PROCESSING' });
await assert.rejects(
() => harness.service.executeWechatTransfer({
...actor,
settlementId: '501',
mode: 'API'
}),
(error) => error instanceof CleaningPayoutError
&& error.code === 'CLEANING_PAYOUT_ALREADY_STARTED'
);
assert.equal(harness.repository.begun.length, 0);
}
{
const harness = createHarness({ queryState: 'SUCCESS' });
const result = await harness.service.syncWechatTransfer({
@@ -87,7 +103,7 @@ const actor = {
note: 'poll success'
});
assert.equal(result.transferState, 'SUCCESS');
assert.equal(harness.state.queryOutBillNo, 'CLS-20260627-501');
assert.equal(harness.state.queryOutBillNo, 'CLP501');
assert.equal(harness.repository.paid[0].payoutReference, 'wx-transfer-query-501');
}
@@ -115,7 +131,7 @@ const actor = {
const harness = createHarness({
notificationPayload: {
mch_id: '1900000109',
out_bill_no: 'CLS-20260627-501',
out_bill_no: 'CLP501',
transfer_bill_no: 'wx-notify-501',
state: 'SUCCESS'
}
@@ -132,6 +148,27 @@ const actor = {
assert.equal(harness.repository.paid[0].payoutReference, 'wx-notify-501');
}
{
const harness = createHarness({
status: 'PAID',
payoutState: 'SUCCESS',
notificationPayload: {
mch_id: '1900000109',
out_bill_no: 'CLP501',
transfer_bill_no: 'wx-notify-501',
state: 'SUCCESS'
}
});
const result = await harness.service.processWechatTransferNotification({
timestamp: '1782700000',
nonce: 'notify-nonce',
serial: 'PLATFORM-SERIAL',
signature: 'signature'
}, '{"resource":"encrypted"}', 'notify-replay-trace');
assert.equal(result.idempotent, true);
assert.equal(harness.repository.paid.length, 0);
}
{
const harness = createHarness({ transferState: 'FAIL', failReason: 'REAL_NAME_CHECK_FAILED' });
const result = await harness.service.executeWechatTransfer({
@@ -175,12 +212,14 @@ function createHarness(options = {}) {
id: '501',
settlementNo: 'CLS-20260627-501',
cleanerUserId: '31',
generatedBy: '21',
storeId: '11',
status: options.status ?? 'CONFIRMED',
totalRewardCents: 1200,
payoutChannel: options.payoutChannel ?? 'WECHAT_TRANSFER',
payoutReference: options.payoutReference ?? 'CLS-20260627-501',
payoutState: options.payoutState ?? 'WAIT_USER_CONFIRM',
payoutRequestNo: options.payoutRequestNo ?? 'CLP501',
payoutReference: options.payoutReference ?? 'CLP501',
payoutState: options.payoutState ?? '',
payoutPackageInfo: options.payoutPackageInfo ?? 'package-info'
},
account: {
@@ -210,9 +249,17 @@ function createHarness(options = {}) {
}
};
const repository = {
begun: [],
paid: [],
failures: [],
pending: [],
async beginSettlementPayout(input) {
this.begun.push(input);
state.settlement.payoutChannel = input.payoutChannel;
state.settlement.payoutReference = input.payoutReference;
state.settlement.payoutState = 'PROCESSING';
return { ...state.settlement };
},
async markSettlementPaid(input) {
this.paid.push(input);
return { ...state.settlement, status: 'PAID', payoutReference: input.payoutReference };
+41 -1
View File
@@ -160,11 +160,21 @@ const app = await buildApp({
calls.push(['listSettlements', input]);
return { items: [settlementResponse(input.status || 'DRAFT')], total: 1, page: input.page, pageSize: input.pageSize };
},
async exportSettlements(input) {
calls.push(['exportSettlements', input]);
return { items: [settlementResponse(input.status || 'DRAFT')], total: 1, exported: 1, truncated: false };
},
async getSettlementDetail(input) {
calls.push(['getSettlementDetail', input]);
return {
settlement: settlementResponse('DRAFT'),
items: [settlementItemResponse()]
items: [settlementItemResponse()],
events: [{
id: '701', settlementId: input.settlementId, fromStatus: null, toStatus: 'DRAFT',
action: 'GENERATE', actorId: input.userId, traceId: input.traceId,
note: '', createdAt: new Date()
}],
reversals: []
};
},
async generateSettlement(input) {
@@ -175,6 +185,15 @@ const app = await buildApp({
calls.push(['confirmSettlement', input]);
return settlementResponse('CONFIRMED');
},
async cancelSettlement(input) {
calls.push(['cancelSettlement', input]);
return {
...settlementResponse('CANCELLED'),
cancelledBy: input.userId,
cancelledAt: new Date(),
note: input.reason
};
},
async markSettlementPaid(input) {
calls.push(['markSettlementPaid', input]);
return {
@@ -385,6 +404,16 @@ assert.equal(calls.at(-1)[1].status, 'SUBMITTED');
assert.equal(calls.at(-1)[1].storeId, '11');
assert.equal(calls.at(-1)[1].cleanerUserId, '31');
const settlementExport = await app.inject({
method: 'GET',
url: '/admin-api/cleaning/settlements/export?status=DRAFT&payoutState=FAIL&storeId=11&cleanerUserId=31',
headers: { authorization: `Bearer ${token}` }
});
assert.equal(settlementExport.statusCode, 200);
assert.equal(calls.at(-1)[0], 'exportSettlements');
assert.equal(calls.at(-1)[1].status, 'DRAFT');
assert.equal(settlementExport.json().data.exported, 1);
const appManageList = await app.inject({
method: 'GET',
url: '/app-api/management/cleaning/tasks?status=SUBMITTED&storeId=11',
@@ -643,6 +672,17 @@ assert.equal(confirmedSettlement.statusCode, 200);
assert.equal(calls.at(-1)[0], 'confirmSettlement');
assert.equal(calls.at(-1)[1].settlementId, '501');
const cancelledSettlement = await app.inject({
method: 'POST',
url: '/admin-api/cleaning/settlements/501/cancel',
headers: { authorization: `Bearer ${token}` },
payload: { reason: 'duplicate weekly batch' }
});
assert.equal(cancelledSettlement.statusCode, 200);
assert.equal(cancelledSettlement.json().data.status, 'CANCELLED');
assert.equal(calls.at(-1)[0], 'cancelSettlement');
assert.equal(calls.at(-1)[1].reason, 'duplicate weekly batch');
const failedPayout = await app.inject({
method: 'POST',
url: '/admin-api/cleaning/settlements/501/payout-failure',
+27 -1
View File
@@ -111,6 +111,15 @@ const adminAuthVerifySql = read('database/migrations/2026081004_m08d_admin_passw
const cleaningRulesUpSql = read('database/migrations/2026081005_m09b_cleaning_rules.up.sql');
const cleaningRulesDownSql = read('database/migrations/2026081005_m09b_cleaning_rules.down.sql');
const cleaningRulesVerifySql = read('database/migrations/2026081005_m09b_cleaning_rules.verify.sql');
const cleaningSettlementIntegrityUpSql = read(
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.up.sql'
);
const cleaningSettlementIntegrityDownSql = read(
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.down.sql'
);
const cleaningSettlementIntegrityVerifySql = read(
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.verify.sql'
);
const coreTables = [
'qipai_schema_migrations',
@@ -507,7 +516,24 @@ assert.match(cleaningRulesUpSql, /uq_qipai_cleaning_submission_revision/);
assert.match(cleaningRulesUpSql, /retention_until DATETIME\(3\) NOT NULL/);
assert.match(cleaningRulesDownSql, /DROP FOREIGN KEY fk_qipai_cleaning_tasks_template/);
assert.match(cleaningRulesVerifySql, /'2026081005'/);
for (const table of [
'qipai_cleaning_settlement_events', 'qipai_cleaning_settlement_reversals'
]) {
assert.match(cleaningSettlementIntegrityUpSql, new RegExp('CREATE TABLE IF NOT EXISTS ' + table));
assert.match(cleaningSettlementIntegrityDownSql, new RegExp('DROP TABLE IF EXISTS ' + table));
assert.match(cleaningSettlementIntegrityVerifySql, new RegExp("'" + table + "'"));
}
assert.match(cleaningSettlementIntegrityUpSql, /amount_delta_cents BIGINT NOT NULL/);
assert.match(cleaningSettlementIntegrityUpSql, /ADD COLUMN cancelled_by/);
assert.match(cleaningSettlementIntegrityUpSql, /ADD COLUMN cancelled_at/);
assert.match(cleaningSettlementIntegrityUpSql, /ADD COLUMN payout_request_no/);
assert.match(cleaningSettlementIntegrityUpSql, /GENERATED ALWAYS AS/);
assert.match(cleaningSettlementIntegrityUpSql, /uq_qipai_cleaning_settlement_active_share/);
assert.match(cleaningSettlementIntegrityUpSql, /uq_qipai_cleaning_settlement_payout_request/);
assert.match(cleaningSettlementIntegrityDownSql, /DROP FOREIGN KEY fk_qipai_cleaning_settlement_items_reversal/);
assert.match(cleaningSettlementIntegrityDownSql, /ADD UNIQUE KEY uq_qipai_cleaning_settlement_item_task_user/);
assert.match(cleaningSettlementIntegrityVerifySql, /'2026081006'/);
assert.match(franchiseDownSql, /DROP TABLE IF EXISTS qipai_franchise_follow_ups/);
assert.match(franchiseVerifySql, /idx_qipai_franchise_follow_up_history/);
console.log('PASS: M01-B through M09-B migration contracts are present.');
console.log('PASS: M01-B through M09-C migration contracts are present.');
+11 -2
View File
@@ -44,7 +44,8 @@ assert.match(plan.file, /2026081001_m08c_staff_management_access\.up\.sql/);
assert.match(plan.file, /2026081002_m08d_content_asset_scope\.up\.sql/);
assert.match(plan.file, /2026081003_m08d_franchise_leads\.up\.sql/);
assert.match(plan.file, /2026081004_m08d_admin_password_auth\.up\.sql/);
assert.match(plan.file, /2026081005_m09b_cleaning_rules\.up\.sql$/);
assert.match(plan.file, /2026081005_m09b_cleaning_rules\.up\.sql/);
assert.match(plan.file, /2026081006_m09c_cleaning_settlement_integrity\.up\.sql$/);
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
assert.ok(plan.statements.length >= 11);
@@ -53,7 +54,15 @@ assert.match(verifyPlan.statements[90], /^SELECT column_name/);
assert.match(verifyPlan.statements[91], /^SELECT index_name/);
assert.match(verifyPlan.file, /2026081003_m08d_franchise_leads\.verify\.sql/);
assert.match(verifyPlan.file, /2026081004_m08d_admin_password_auth\.verify\.sql/);
assert.match(verifyPlan.file, /2026081005_m09b_cleaning_rules\.verify\.sql$/);
assert.match(verifyPlan.file, /2026081005_m09b_cleaning_rules\.verify\.sql/);
assert.match(verifyPlan.file, /2026081006_m09c_cleaning_settlement_integrity\.verify\.sql$/);
const downPlan = await loadMigrationPlan('down');
assert.match(downPlan.file, /2026081006_m09c_cleaning_settlement_integrity\.down\.sql/);
assert.ok(
downPlan.file.indexOf('2026081006_m09c_cleaning_settlement_integrity.down.sql')
< downPlan.file.indexOf('2026081005_m09b_cleaning_rules.down.sql')
);
const calls = [];
const fakePool = {
@@ -60,6 +60,8 @@ const expectedTables = [
'qipai_async_tasks',
'qipai_audit_logs',
'qipai_auth_sessions',
'qipai_cleaning_settlement_events',
'qipai_cleaning_settlement_reversals',
'qipai_cleaning_task_photos',
'qipai_cleaning_task_submissions',
'qipai_cleaning_templates',
@@ -142,13 +144,14 @@ async function readMigrationVersions(pool) {
const [rows] = await pool.query(
`SELECT version, name
FROM qipai_schema_migrations
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ORDER BY version`,
['2026061601', '2026061802', '2026061803', '2026061804',
'2026061805', '2026061806', '2026061807', '2026061808', '2026061809',
'2026061810', '2026061811', '2026062012', '2026062013', '2026062014',
'2026062015', '2026062216', '2026062217', '2026062218', '2026062219',
'2026062220', '2026081002', '2026081003', '2026081004', '2026081005']
'2026062220', '2026081002', '2026081003', '2026081004', '2026081005',
'2026081006']
);
return rows;
}
@@ -1836,7 +1839,7 @@ async function assertSystemOperations(pool, context) {
assert.equal(logs.items[0].metadata.nested.safe, 'visible');
const overview = await repository.getSystemOverview(context.tenantId);
assert.equal(overview.tenant.id, context.tenantId);
assert.equal(overview.latestMigration.version, '2026081005');
assert.equal(overview.latestMigration.version, '2026081006');
assert.ok(overview.counts.userCount > 0);
await repository.updateTenant(actor, context.tenantId, {
name: overview.tenant.name, timezone: overview.tenant.timezone
@@ -2957,8 +2960,163 @@ async function assertCleaningTaskTransactions(pool, context) {
exemptPolicy: snapshotRows[0].exemptPolicy
}, { version: 1, minPhotoCount: 2, exemptPolicy: 'BEFORE_START' });
const collaborativeTaskId = await insertTask({
status: 'COMPLETED', cleanerUserId: firstCleanerId, rewardCents: 100,
claimedAt: new Date(), completedAt: new Date()
});
await insertMember({
taskId: collaborativeTaskId, userId: firstCleanerId, memberRole: 'LEAD', rewardCents: 70
});
await insertMember({
taskId: collaborativeTaskId, userId: secondCleanerId, memberRole: 'ASSIST', rewardCents: 30
});
const concurrentSettlementResults = await Promise.allSettled([
repository.generateSettlement({
...managerActor('m09c-generate-one'), cleanerUserId: firstCleanerId, storeId
}),
repository.generateSettlement({
...managerActor('m09c-generate-two'), cleanerUserId: firstCleanerId, storeId
})
]);
assert.equal(
concurrentSettlementResults.filter((result) => result.status === 'fulfilled').length,
1
);
const rejectedSettlement = concurrentSettlementResults.find((result) => result.status === 'rejected');
assert.ok(rejectedSettlement?.reason instanceof CleaningTaskError);
assert.ok([
'CLEANING_SETTLEMENT_EMPTY', 'CLEANING_SETTLEMENT_CONCURRENTLY_CLAIMED'
].includes(rejectedSettlement.reason.code));
const leadDraft = concurrentSettlementResults.find((result) => result.status === 'fulfilled').value;
const assistDraft = await repository.generateSettlement({
...managerActor('m09c-generate-assist'), cleanerUserId: secondCleanerId, storeId
});
const [activeShareRows] = await pool.query(
`SELECT task_id AS taskId, cleaner_user_id AS cleanerUserId, COUNT(*) AS total
FROM qipai_cleaning_settlement_items
WHERE tenant_id = ? AND reversed_at IS NULL
AND task_id IN (?, ?)
GROUP BY task_id, cleaner_user_id`,
[context.tenantId, lifecycleTaskId, collaborativeTaskId]
);
assert.ok(activeShareRows.length >= 3);
assert.ok(activeShareRows.every((row) => Number(row.total) === 1));
const [draftTaskRows] = await pool.query(
`SELECT id, status, settled_at AS settledAt
FROM qipai_cleaning_tasks WHERE tenant_id = ? AND id IN (?, ?) ORDER BY id`,
[context.tenantId, lifecycleTaskId, collaborativeTaskId]
);
assert.ok(draftTaskRows.every((row) => row.status === 'COMPLETED' && row.settledAt === null));
const firstStatsInDraft = await repository.stats({
...cleanerActor(firstCleanerId, 'm09c-stats-draft')
});
assert.ok(firstStatsInDraft.pendingSettlementCents >= 170);
assert.equal(firstStatsInDraft.settledRewardCents, 0);
await assert.rejects(
() => repository.removeMember({
...managerActor('m09c-member-locked'), taskId: collaborativeTaskId,
cleanerUserId: secondCleanerId
}),
(error) => error instanceof CleaningTaskError
&& error.code === 'CLEANING_MEMBER_SETTLEMENT_LOCKED'
);
const cancelledLead = await repository.cancelSettlement({
...managerActor('m09c-cancel-draft'), settlementId: leadDraft.id,
reason: 'M09-C cancellation roundtrip'
});
assert.equal(cancelledLead.status, 'CANCELLED');
const cancelledDetail = await repository.getSettlementDetail({
...managerActor('m09c-cancel-detail'), settlementId: leadDraft.id
});
assert.equal(cancelledDetail.reversals.length, 1);
assert.equal(cancelledDetail.reversals[0].amountDeltaCents, -leadDraft.totalRewardCents);
assert.ok(cancelledDetail.items.every((item) => item.reversedAt instanceof Date));
assert.ok(cancelledDetail.events.some((event) => event.action === 'CANCEL'));
const retryLead = await repository.generateSettlement({
...managerActor('m09c-generate-retry'), cleanerUserId: firstCleanerId, storeId
});
await repository.confirmSettlement({
...managerActor('m09c-confirm-retry'), settlementId: retryLead.id
});
await repository.beginSettlementPayout({
...managerActor('m09c-payout-begin'), settlementId: retryLead.id,
payoutChannel: 'WECHAT_TRANSFER', payoutReference: `CLP${retryLead.id}`
});
await assert.rejects(
() => repository.cancelSettlement({
...managerActor('m09c-cancel-processing'), settlementId: retryLead.id,
reason: 'must not cancel uncertain transfer'
}),
(error) => error instanceof CleaningTaskError
&& error.code === 'CLEANING_SETTLEMENT_PAYOUT_PENDING'
);
await repository.recordSettlementPayoutFailure({
...managerActor('m09c-payout-explicit-fail'), settlementId: retryLead.id,
payoutChannel: 'WECHAT_TRANSFER', payoutReference: `CLP${retryLead.id}`,
error: 'PROVIDER_CONFIRMED_FAIL', providerConfirmed: true
});
await repository.cancelSettlement({
...managerActor('m09c-cancel-failed'), settlementId: retryLead.id,
reason: 'provider confirmed failure'
});
const paidLead = await repository.generateSettlement({
...managerActor('m09c-generate-paid'), cleanerUserId: firstCleanerId, storeId
});
await repository.confirmSettlement({
...managerActor('m09c-confirm-paid'), settlementId: paidLead.id
});
await repository.markSettlementPaid({
...managerActor('m09c-manual-paid-lead'), settlementId: paidLead.id,
payoutChannel: 'MANUAL', payoutReference: `MANUAL-${paidLead.id}`,
manualOverride: true
});
const [partiallyPaidRows] = await pool.query(
`SELECT status FROM qipai_cleaning_tasks WHERE tenant_id = ? AND id = ?`,
[context.tenantId, collaborativeTaskId]
);
assert.equal(partiallyPaidRows[0].status, 'COMPLETED');
await repository.confirmSettlement({
...managerActor('m09c-confirm-assist'), settlementId: assistDraft.id
});
await repository.markSettlementPaid({
...managerActor('m09c-manual-paid-assist'), settlementId: assistDraft.id,
payoutChannel: 'MANUAL', payoutReference: `MANUAL-${assistDraft.id}`,
manualOverride: true
});
const [fullyPaidRows] = await pool.query(
`SELECT status, settled_at IS NOT NULL AS settled
FROM qipai_cleaning_tasks WHERE tenant_id = ? AND id = ?`,
[context.tenantId, collaborativeTaskId]
);
assert.deepEqual(
{ status: fullyPaidRows[0].status, settled: Number(fullyPaidRows[0].settled) },
{ status: 'SETTLED', settled: 1 }
);
const firstStatsPaid = await repository.stats({
...cleanerActor(firstCleanerId, 'm09c-stats-paid')
});
assert.ok(firstStatsPaid.settledRewardCents >= 170);
const paidPage = await repository.listSettlements({
...managerActor('m09c-list-paid'), page: 1, pageSize: 50, status: 'PAID', storeId
});
const paidExport = await repository.exportSettlements({
...managerActor('m09c-export-paid'), status: 'PAID', storeId
});
assert.deepEqual(
paidExport.items.slice(0, paidPage.items.length).map((item) => item.id),
paidPage.items.map((item) => item.id)
);
assert.equal(paidExport.total, paidPage.total);
console.log(
'PASS: M09-A/M09-B cleaning transactions, template snapshots, photo revisions and exemption rules are consistent.'
'PASS: M09-A/M09-B/M09-C cleaning transactions, rules and settlement integrity are consistent.'
);
}
@@ -3009,7 +3167,8 @@ try {
{ version: '2026081002', name: 'm08d_content_asset_scope' },
{ version: '2026081003', name: 'm08d_franchise_leads' },
{ version: '2026081004', name: 'm08d_admin_password_auth' },
{ version: '2026081005', name: 'm09b_cleaning_rules' }
{ version: '2026081005', name: 'm09b_cleaning_rules' },
{ version: '2026081006', name: 'm09c_cleaning_settlement_integrity' }
]);
await assertTaskDurability(pool);
const loginContext = await assertPlatformTenantIsolation(pool);
@@ -3038,7 +3197,7 @@ try {
await executeMigrationPlan(pool, plans.down);
assert.deepEqual(await readCoreTables(pool), []);
await assertLegacyCompatibility(pool);
console.log('PASS: down removed all M01-B through M09-B migration tables.');
console.log('PASS: down removed all M01-B through M09-C migration tables.');
await executeMigrationPlan(pool, plans.up);
await executeMigrationPlan(pool, plans.verify);
@@ -3067,7 +3226,8 @@ try {
{ version: '2026081002', name: 'm08d_content_asset_scope' },
{ version: '2026081003', name: 'm08d_franchise_leads' },
{ version: '2026081004', name: 'm08d_admin_password_auth' },
{ version: '2026081005', name: 'm09b_cleaning_rules' }
{ version: '2026081005', name: 'm09b_cleaning_rules' },
{ version: '2026081006', name: 'm09c_cleaning_settlement_integrity' }
]);
await assertLegacyCompatibility(pool);
console.log('PASS: second up and verify restored the schema.');
@@ -3208,7 +3368,14 @@ try {
'cleaning template snapshot remains stable after config version change',
'rejected and accepted cleaning photo revisions remain distinguishable',
'expired unattached cleaning photo retention cleanup',
'task-level cleaning exemption policy denial'
'task-level cleaning exemption policy denial',
'concurrent cleaning settlement generation has one winner per member share',
'draft and confirmed rewards remain pending until paid',
'active settlement freezes member reward allocation',
'settlement reversal preserves history and releases the share',
'processing payout blocks cancellation until provider-confirmed failure',
'collaborative task settles only after every member share is paid',
'settlement page and CSV export reuse identical query ordering'
]
}, null, 2));
} finally {
@@ -1,6 +1,13 @@
DELETE FROM qipai_schema_migrations
WHERE version = '2026062627';
DELETE duplicate_item
FROM qipai_cleaning_settlement_items duplicate_item
INNER JOIN qipai_cleaning_settlement_items retained_item
ON retained_item.tenant_id = duplicate_item.tenant_id
AND retained_item.task_id = duplicate_item.task_id
AND retained_item.id < duplicate_item.id;
ALTER TABLE qipai_cleaning_settlement_items
DROP FOREIGN KEY fk_qipai_cleaning_settlement_items_cleaner,
DROP INDEX uq_qipai_cleaning_settlement_item_task_user,
@@ -22,6 +22,7 @@ WHERE table_schema = DATABASE()
'idx_qipai_cleaning_task_member_user',
'idx_qipai_cleaning_task_member_task',
'uq_qipai_cleaning_settlement_item_task_user',
'uq_qipai_cleaning_settlement_active_share',
'idx_qipai_cleaning_settlement_items_cleaner'
)
GROUP BY table_name, index_name;
@@ -0,0 +1,43 @@
DELETE FROM qipai_schema_migrations WHERE version = '2026081006';
ALTER TABLE qipai_cleaning_settlement_items
DROP FOREIGN KEY fk_qipai_cleaning_settlement_items_reversal;
DELETE reversed
FROM qipai_cleaning_settlement_items reversed
INNER JOIN qipai_cleaning_settlement_items active
ON active.tenant_id = reversed.tenant_id
AND active.task_id = reversed.task_id
AND active.cleaner_user_id = reversed.cleaner_user_id
AND active.reversed_at IS NULL
WHERE reversed.reversed_at IS NOT NULL;
DELETE older
FROM qipai_cleaning_settlement_items older
INNER JOIN qipai_cleaning_settlement_items newer
ON newer.tenant_id = older.tenant_id
AND newer.task_id = older.task_id
AND newer.cleaner_user_id = older.cleaner_user_id
AND newer.id > older.id
WHERE older.reversed_at IS NOT NULL AND newer.reversed_at IS NOT NULL;
ALTER TABLE qipai_cleaning_settlement_items
DROP INDEX uq_qipai_cleaning_settlement_active_share,
DROP INDEX idx_qipai_cleaning_settlement_item_reversal,
DROP COLUMN active_share_key,
DROP COLUMN reversed_at,
DROP COLUMN reversal_id,
ADD UNIQUE KEY uq_qipai_cleaning_settlement_item_task_user
(tenant_id, task_id, cleaner_user_id);
ALTER TABLE qipai_cleaning_settlements
DROP FOREIGN KEY fk_qipai_cleaning_settlements_cancelled_by,
DROP INDEX idx_qipai_cleaning_settlement_cancelled,
DROP INDEX uq_qipai_cleaning_settlement_payout_request,
DROP COLUMN payout_request_key,
DROP COLUMN payout_request_no,
DROP COLUMN cancelled_at,
DROP COLUMN cancelled_by;
DROP TABLE IF EXISTS qipai_cleaning_settlement_events;
DROP TABLE IF EXISTS qipai_cleaning_settlement_reversals;
@@ -0,0 +1,136 @@
CREATE TABLE IF NOT EXISTS qipai_cleaning_settlement_reversals (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
reversal_no VARCHAR(64) NOT NULL,
settlement_id BIGINT UNSIGNED NOT NULL,
amount_delta_cents BIGINT NOT NULL,
reason VARCHAR(512) NOT NULL,
reversed_by BIGINT UNSIGNED NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
CONSTRAINT fk_qipai_cleaning_settlement_reversal_tenant FOREIGN KEY (tenant_id)
REFERENCES qipai_tenants(id),
CONSTRAINT fk_qipai_cleaning_settlement_reversal_settlement FOREIGN KEY (settlement_id)
REFERENCES qipai_cleaning_settlements(id),
CONSTRAINT fk_qipai_cleaning_settlement_reversal_actor FOREIGN KEY (reversed_by)
REFERENCES qipai_users(id),
CONSTRAINT chk_qipai_cleaning_settlement_reversal_amount CHECK (amount_delta_cents <= 0),
UNIQUE KEY uq_qipai_cleaning_settlement_reversal_no (tenant_id, reversal_no),
UNIQUE KEY uq_qipai_cleaning_settlement_reversal_parent (tenant_id, settlement_id),
KEY idx_qipai_cleaning_settlement_reversal_created (tenant_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS qipai_cleaning_settlement_events (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id BIGINT UNSIGNED NOT NULL,
settlement_id BIGINT UNSIGNED NOT NULL,
from_status VARCHAR(32) NULL,
to_status VARCHAR(32) NOT NULL,
action VARCHAR(32) NOT NULL,
actor_id BIGINT UNSIGNED NOT NULL,
actor_type VARCHAR(16) NOT NULL DEFAULT 'USER',
trace_id VARCHAR(64) NOT NULL,
note VARCHAR(512) NOT NULL DEFAULT '',
metadata JSON NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
CONSTRAINT fk_qipai_cleaning_settlement_event_tenant FOREIGN KEY (tenant_id)
REFERENCES qipai_tenants(id),
CONSTRAINT fk_qipai_cleaning_settlement_event_parent FOREIGN KEY (settlement_id)
REFERENCES qipai_cleaning_settlements(id),
CONSTRAINT fk_qipai_cleaning_settlement_event_actor FOREIGN KEY (actor_id)
REFERENCES qipai_users(id),
CONSTRAINT chk_qipai_cleaning_settlement_event_actor_type CHECK (
actor_type IN ('USER', 'SYSTEM')
),
UNIQUE KEY uq_qipai_cleaning_settlement_event_trace
(tenant_id, settlement_id, action, trace_id),
KEY idx_qipai_cleaning_settlement_event_history
(tenant_id, settlement_id, created_at, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
ALTER TABLE qipai_cleaning_settlements
ADD COLUMN cancelled_by BIGINT UNSIGNED NULL AFTER paid_by,
ADD COLUMN cancelled_at DATETIME(3) NULL AFTER paid_at,
ADD COLUMN payout_request_no VARCHAR(64) NOT NULL DEFAULT '' AFTER payout_channel,
ADD COLUMN payout_request_key VARCHAR(128)
GENERATED ALWAYS AS (
CASE WHEN payout_request_no <> ''
THEN CONCAT(payout_channel, ':', payout_request_no)
ELSE NULL
END
) STORED AFTER payout_request_no,
ADD CONSTRAINT fk_qipai_cleaning_settlements_cancelled_by FOREIGN KEY (cancelled_by)
REFERENCES qipai_users(id),
ADD KEY idx_qipai_cleaning_settlement_cancelled (tenant_id, status, cancelled_at),
ADD UNIQUE KEY uq_qipai_cleaning_settlement_payout_request
(tenant_id, payout_request_key);
ALTER TABLE qipai_cleaning_settlement_items
DROP INDEX uq_qipai_cleaning_settlement_item_task_user,
ADD COLUMN reversal_id BIGINT UNSIGNED NULL AFTER reward_cents,
ADD COLUMN reversed_at DATETIME(3) NULL AFTER reversal_id,
ADD COLUMN active_share_key VARCHAR(128)
GENERATED ALWAYS AS (
CASE WHEN reversed_at IS NULL
THEN CONCAT(CAST(task_id AS CHAR), ':', CAST(cleaner_user_id AS CHAR))
ELSE NULL
END
) STORED AFTER reversed_at,
ADD CONSTRAINT fk_qipai_cleaning_settlement_items_reversal FOREIGN KEY (reversal_id)
REFERENCES qipai_cleaning_settlement_reversals(id),
ADD UNIQUE KEY uq_qipai_cleaning_settlement_active_share (tenant_id, active_share_key),
ADD KEY idx_qipai_cleaning_settlement_item_reversal (tenant_id, reversal_id);
UPDATE qipai_cleaning_settlements
SET cancelled_by = generated_by,
cancelled_at = COALESCE(cancelled_at, updated_at)
WHERE status = 'CANCELLED' AND cancelled_by IS NULL;
INSERT IGNORE INTO qipai_cleaning_settlement_reversals
(tenant_id, reversal_no, settlement_id, amount_delta_cents, reason, reversed_by, created_at)
SELECT s.tenant_id, CONCAT('CLR-M09C-', s.id), s.id, -CAST(s.total_reward_cents AS SIGNED),
'M09-C historical cancellation baseline', s.cancelled_by,
COALESCE(s.cancelled_at, s.updated_at)
FROM qipai_cleaning_settlements s
WHERE s.status = 'CANCELLED' AND s.deleted_at IS NULL;
UPDATE qipai_cleaning_settlement_items i
INNER JOIN qipai_cleaning_settlement_reversals r
ON r.tenant_id = i.tenant_id AND r.settlement_id = i.settlement_id
SET i.reversal_id = r.id,
i.reversed_at = r.created_at
WHERE i.reversed_at IS NULL;
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
INNER JOIN qipai_cleaning_settlements s
ON s.tenant_id = i.tenant_id AND s.id = i.settlement_id AND s.deleted_at IS NULL
SET m.settled_at = CASE
WHEN s.status = 'PAID' AND i.reversed_at IS NULL
THEN COALESCE(s.paid_at, m.settled_at, UTC_TIMESTAMP(3))
ELSE NULL
END
WHERE i.id IS NOT NULL;
UPDATE qipai_cleaning_tasks t
SET t.status = 'COMPLETED', t.settled_at = NULL
WHERE t.status = 'SETTLED'
AND 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
);
INSERT IGNORE INTO qipai_cleaning_settlement_events
(tenant_id, settlement_id, from_status, to_status, action, actor_id,
actor_type, trace_id, note, metadata, created_at)
SELECT s.tenant_id, s.id, NULL, s.status, 'MIGRATE', s.generated_by, 'SYSTEM',
CONCAT('m09c-', s.id), 'M09-C settlement history baseline',
JSON_OBJECT('source', '2026081006'), s.created_at
FROM qipai_cleaning_settlements s
WHERE s.deleted_at IS NULL;
INSERT IGNORE INTO qipai_schema_migrations (version, name)
VALUES ('2026081006', 'm09c_cleaning_settlement_integrity');
@@ -0,0 +1,39 @@
SELECT table_name
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name IN (
'qipai_cleaning_settlement_events',
'qipai_cleaning_settlement_reversals'
)
ORDER BY table_name;
SELECT table_name, column_name, generation_expression
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND ((table_name = 'qipai_cleaning_settlement_items'
AND column_name IN ('reversal_id', 'reversed_at', 'active_share_key'))
OR (table_name = 'qipai_cleaning_settlements'
AND column_name IN (
'cancelled_by', 'cancelled_at', 'payout_request_no', 'payout_request_key'
))
OR (table_name = 'qipai_cleaning_settlement_events'
AND column_name = 'actor_type'))
ORDER BY table_name, column_name;
SELECT table_name, index_name
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND ((table_name = 'qipai_cleaning_settlement_items'
AND index_name = 'uq_qipai_cleaning_settlement_active_share')
OR (table_name = 'qipai_cleaning_settlement_events'
AND index_name = 'uq_qipai_cleaning_settlement_event_trace')
OR (table_name = 'qipai_cleaning_settlement_reversals'
AND index_name = 'uq_qipai_cleaning_settlement_reversal_parent')
OR (table_name = 'qipai_cleaning_settlements'
AND index_name = 'uq_qipai_cleaning_settlement_payout_request'))
GROUP BY table_name, index_name
ORDER BY table_name, index_name;
SELECT version, name
FROM qipai_schema_migrations
WHERE version = '2026081006';
@@ -0,0 +1,78 @@
import assert from 'node:assert/strict';
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
const root = process.cwd();
const read = (path) => readFileSync(join(root, path), 'utf8');
for (const suffix of ['up', 'down', 'verify']) {
assert.ok(
existsSync(join(root, 'database/migrations/2026081006_m09c_cleaning_settlement_integrity.' + suffix + '.sql')),
'M09-C ' + suffix + ' migration is missing'
);
}
const migration = read('database/migrations/2026081006_m09c_cleaning_settlement_integrity.up.sql');
for (const pattern of [
'qipai_cleaning_settlement_reversals',
'qipai_cleaning_settlement_events',
'amount_delta_cents BIGINT',
'active_share_key',
'uq_qipai_cleaning_settlement_active_share',
'payout_request_no',
'uq_qipai_cleaning_settlement_payout_request',
"actor_type IN ('USER', 'SYSTEM')"
]) assert.ok(migration.includes(pattern), 'migration is missing ' + pattern);
const repository = read('backend/src/cleaning/cleaning-task-repository.ts');
for (const pattern of [
'CLEANING_SETTLEMENT_CONCURRENTLY_CLAIMED',
'assertTaskSettlementMutable',
'beginSettlementPayout',
'cancelSettlement',
'recordSettlementEventWithConnection',
"status = 'SETTLED'",
'exportSettlements',
'querySettlementRows'
]) assert.ok(repository.includes(pattern), 'repository is missing ' + pattern);
assert.match(repository, /payout_state IN \('PROCESSING', 'WAIT_USER_CONFIRM'\)/);
assert.match(repository, /reversed_at IS NULL/);
const payout = read('backend/src/cleaning/cleaning-payout-service.ts');
assert.match(payout, /await this\.repository\.beginSettlementPayout/);
assert.match(payout, /actorType: 'SYSTEM'/);
assert.match(payout, /payout_request_no AS payoutRequestNo/);
assert.ok(payout.includes('CLP$' + '{settlementId}'), 'stable payout request id is missing');
const routes = read('backend/src/routes/cleaning.ts');
assert.match(routes, /settlements\/export/);
assert.match(routes, /settlements\/:settlementId\/cancel/);
assert.match(routes, /manualOverride: true/);
const admin = read('admin/src/components/CleaningSettlementsPanel.vue');
for (const pattern of [
'exportCleaningSettlements',
'canCancelSettlement',
'openManualPaid',
'detailDialog.detail.reversals',
'detailDialog.detail.events',
'payoutState: props.filters.payoutState',
"['', 'FAIL'].includes(row.payoutState)",
'金额(分)'
]) assert.ok(admin.includes(pattern), 'admin settlement UI is missing ' + pattern);
for (const [file, pattern] of [
['admin/src/components/CleaningStatisticsPanel.vue', '待结算(分)'],
['admin/src/components/CleaningTasksPanel.vue', '金额(分)'],
['admin/src/components/CleaningWorkspace.vue', '金额(分)']
]) assert.ok(read(file).includes(pattern), file + ' must export monetary values as integer cents');
const mysqlTest = read('backend/tests/mysql-migration-roundtrip.test.mjs');
for (const pattern of [
'm09c-generate-one',
'CLEANING_MEMBER_SETTLEMENT_LOCKED',
'm09c-cancel-processing',
'collaborative task settles only after every member share is paid'
]) assert.ok(mysqlTest.includes(pattern), 'live MySQL test is missing ' + pattern);
console.log('PASS: M09-C settlement integrity, reversal, payout and shared-query gates are present.');
+2
View File
@@ -34,6 +34,8 @@ node scripts/check-miniapp-m08-c.mjs
Assert-NativeSuccess "check-miniapp-m08-c"
node scripts/check-m09-b-cleaning-rules.mjs
Assert-NativeSuccess "check-m09-b-cleaning-rules"
node scripts/check-m09-c-settlement-integrity.mjs
Assert-NativeSuccess "check-m09-c-settlement-integrity"
if (Test-Path "admin/package.json") {
Push-Location admin
+2 -2
View File
@@ -93,6 +93,6 @@ export QIPAI_MYSQL_USER="${username}"
export QIPAI_MYSQL_PASSWORD="${password}"
export QIPAI_MYSQL_CONNECTION_LIMIT=2
echo "INFO: MySQL ${mysql_version}; running M01-B through M06-D integration roundtrip in a temporary database."
echo "INFO: MySQL ${mysql_version}; running M01-B through M09-C integration roundtrip in a temporary database."
npm --prefix backend run test:mysql:migration
echo "PASS: M01-B through M06-D live MySQL integration roundtrip completed."
echo "PASS: M01-B through M09-C live MySQL integration roundtrip completed."