feat(cleaning): close settlement integrity and reconciliation
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,35 +895,51 @@ function useCandidateFilters() {
|
||||
candidateDialog.open = false;
|
||||
}
|
||||
|
||||
function exportSettlements() {
|
||||
downloadCsv(`cleaning-settlements-${Date.now()}.csv`, [
|
||||
['结算单', '状态', '保洁员', '门店', '任务数', '金额', '转账状态', '外部流水', '微信确认参数', '失败原因', '创建时间'],
|
||||
...props.items.map((item) => [
|
||||
item.settlementNo,
|
||||
item.status,
|
||||
item.cleanerName,
|
||||
item.storeName || '',
|
||||
String(item.taskCount),
|
||||
money(item.totalRewardCents),
|
||||
item.payoutState,
|
||||
item.payoutReference,
|
||||
item.payoutPackageInfo,
|
||||
item.payoutError,
|
||||
shortDate(item.createdAt)
|
||||
])
|
||||
]);
|
||||
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`, [
|
||||
['结算单', '状态', '保洁员', '门店', '任务数', '金额(分)', '转账状态', '外部流水', '微信确认参数', '失败原因', '创建时间'],
|
||||
...result.items.map((item) => [
|
||||
item.settlementNo,
|
||||
item.status,
|
||||
item.cleanerName,
|
||||
item.storeName || '',
|
||||
String(item.taskCount),
|
||||
String(item.totalRewardCents),
|
||||
item.payoutState,
|
||||
item.payoutReference,
|
||||
item.payoutPackageInfo,
|
||||
item.payoutError,
|
||||
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)
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
])
|
||||
]);
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -16,10 +16,12 @@ interface SettlementPayoutRow extends RowDataPacket {
|
||||
id: string;
|
||||
settlementNo: string;
|
||||
cleanerUserId: string;
|
||||
generatedBy: string;
|
||||
storeId: string | null;
|
||||
status: 'DRAFT' | 'CONFIRMED' | 'PAID' | 'CANCELLED';
|
||||
totalRewardCents: number;
|
||||
payoutChannel: string;
|
||||
payoutRequestNo: string;
|
||||
payoutReference: string;
|
||||
payoutState: string;
|
||||
payoutPackageInfo: string;
|
||||
@@ -56,7 +58,8 @@ export class CleaningPayoutService {
|
||||
constructor(
|
||||
private readonly pool: MySqlPool,
|
||||
private readonly repository: Pick<CleaningTaskRepository,
|
||||
'markSettlementPaid' | 'recordSettlementPayoutFailure' | 'recordSettlementPayoutPending'>,
|
||||
'beginSettlementPayout' | 'markSettlementPaid'
|
||||
| 'recordSettlementPayoutFailure' | 'recordSettlementPayoutPending'>,
|
||||
private readonly client: WechatPayClient,
|
||||
private readonly credentials: ReadonlyMap<string, WechatPayCredential>,
|
||||
private readonly mockEnabled: boolean
|
||||
@@ -213,6 +216,9 @@ export class CleaningPayoutService {
|
||||
if (settlement.status !== 'CONFIRMED') {
|
||||
throw new CleaningPayoutError('CLEANING_PAYOUT_SETTLEMENT_NOT_CONFIRMED');
|
||||
}
|
||||
if (['PROCESSING', 'WAIT_USER_CONFIRM', 'SUCCESS'].includes(settlement.payoutState)) {
|
||||
throw new CleaningPayoutError('CLEANING_PAYOUT_ALREADY_STARTED');
|
||||
}
|
||||
if (Number(settlement.totalRewardCents) <= 0) {
|
||||
throw new CleaningPayoutError('CLEANING_PAYOUT_AMOUNT_INVALID');
|
||||
}
|
||||
@@ -234,6 +240,7 @@ export class CleaningPayoutService {
|
||||
...input,
|
||||
payoutChannel: 'WECHAT_TRANSFER_MOCK',
|
||||
payoutReference: outBillNo,
|
||||
providerConfirmed: true,
|
||||
note: input.note
|
||||
});
|
||||
return { settlement: paid, idempotent: false, transferState: 'SUCCESS' };
|
||||
@@ -245,6 +252,12 @@ export class CleaningPayoutService {
|
||||
);
|
||||
const sceneId = credential.transferSceneId;
|
||||
if (!sceneId) throw new CleaningPayoutError('WECHAT_TRANSFER_SCENE_NOT_CONFIGURED');
|
||||
await this.repository.beginSettlementPayout({
|
||||
...input,
|
||||
payoutChannel: 'WECHAT_TRANSFER',
|
||||
payoutReference: outBillNo,
|
||||
note: input.note
|
||||
});
|
||||
try {
|
||||
const result = await this.client.createMerchantTransfer(credential, {
|
||||
outBillNo,
|
||||
@@ -260,6 +273,7 @@ export class CleaningPayoutService {
|
||||
...input,
|
||||
payoutChannel: 'WECHAT_TRANSFER',
|
||||
payoutReference: result.transferBillNo || result.outBillNo,
|
||||
providerConfirmed: true,
|
||||
note: input.note
|
||||
});
|
||||
return { settlement: paid, idempotent: false, transferState: result.state };
|
||||
@@ -270,6 +284,7 @@ export class CleaningPayoutService {
|
||||
payoutChannel: 'WECHAT_TRANSFER',
|
||||
payoutReference: result.transferBillNo || result.outBillNo,
|
||||
error: result.failReason || 'WECHAT_TRANSFER_FAILED',
|
||||
providerConfirmed: true,
|
||||
note: input.note
|
||||
});
|
||||
return { settlement: failed, idempotent: false, transferState: result.state };
|
||||
@@ -284,15 +299,8 @@ export class CleaningPayoutService {
|
||||
});
|
||||
return { settlement: pending, idempotent: false, transferState: result.state };
|
||||
} catch (error) {
|
||||
if (error instanceof WechatPayError) {
|
||||
await this.repository.recordSettlementPayoutFailure({
|
||||
...input,
|
||||
payoutChannel: 'WECHAT_TRANSFER',
|
||||
payoutReference: outBillNo,
|
||||
error: error.code,
|
||||
note: input.note
|
||||
});
|
||||
}
|
||||
// A transport/API exception is not proof that WeChat rejected the transfer.
|
||||
// Keep PROCESSING so cancellation and duplicate payout are blocked until sync/reconciliation.
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -327,6 +335,7 @@ export class CleaningPayoutService {
|
||||
...input,
|
||||
payoutChannel: 'WECHAT_TRANSFER',
|
||||
payoutReference: result.transferBillNo || result.outBillNo,
|
||||
providerConfirmed: true,
|
||||
note: input.note
|
||||
});
|
||||
return { settlement: paid, idempotent: false, transferState: result.state };
|
||||
@@ -337,6 +346,7 @@ export class CleaningPayoutService {
|
||||
payoutChannel: 'WECHAT_TRANSFER',
|
||||
payoutReference: result.transferBillNo || result.outBillNo,
|
||||
error: result.failReason || 'WECHAT_TRANSFER_FAILED',
|
||||
providerConfirmed: true,
|
||||
note: input.note
|
||||
});
|
||||
return { settlement: failed, idempotent: false, transferState: result.state };
|
||||
@@ -364,20 +374,29 @@ export class CleaningPayoutService {
|
||||
}
|
||||
const outBillNo = stringPayload(payload, 'out_bill_no');
|
||||
const transferBillNo = optionalStringPayload(payload, 'transfer_bill_no');
|
||||
const settlement = await this.findSettlementByTransferReference(outBillNo, transferBillNo);
|
||||
const settlement = await this.findSettlementByTransferReference(
|
||||
credential.merchantId,
|
||||
outBillNo,
|
||||
transferBillNo
|
||||
);
|
||||
const actor: CleaningActor = {
|
||||
tenantId: settlement.tenantId,
|
||||
userId: '0',
|
||||
userId: String(settlement.generatedBy),
|
||||
access: { roles: ['PLATFORM_ADMIN'], capabilities: ['tenant.manage'], storeIds: [] },
|
||||
traceId
|
||||
traceId,
|
||||
actorType: 'SYSTEM'
|
||||
};
|
||||
const state = stringPayload(payload, 'state');
|
||||
if (settlement.status === 'PAID' && state === 'SUCCESS') {
|
||||
return { settlement, transferState: state, idempotent: true };
|
||||
}
|
||||
if (state === 'SUCCESS') {
|
||||
const paid = await this.repository.markSettlementPaid({
|
||||
...actor,
|
||||
settlementId: settlement.id,
|
||||
payoutChannel: 'WECHAT_TRANSFER',
|
||||
payoutReference: transferBillNo || outBillNo,
|
||||
providerConfirmed: true,
|
||||
note: '微信转账回调确认成功'
|
||||
});
|
||||
return { settlement: paid, transferState: state, idempotent: false };
|
||||
@@ -389,6 +408,7 @@ export class CleaningPayoutService {
|
||||
payoutChannel: 'WECHAT_TRANSFER',
|
||||
payoutReference: transferBillNo || outBillNo,
|
||||
error: optionalStringPayload(payload, 'fail_reason') || 'WECHAT_TRANSFER_FAILED',
|
||||
providerConfirmed: true,
|
||||
note: '微信转账回调确认失败'
|
||||
});
|
||||
return { settlement: failed, transferState: state, idempotent: false };
|
||||
@@ -408,8 +428,10 @@ export class CleaningPayoutService {
|
||||
private async loadSettlement(tenantId: string, settlementId: string) {
|
||||
const [rows] = await this.pool.execute<SettlementPayoutRow[]>(
|
||||
`SELECT id, settlement_no AS settlementNo, cleaner_user_id AS cleanerUserId,
|
||||
generated_by AS generatedBy,
|
||||
store_id AS storeId, status, total_reward_cents AS totalRewardCents,
|
||||
payout_channel AS payoutChannel, payout_reference AS payoutReference,
|
||||
payout_channel AS payoutChannel, payout_request_no AS payoutRequestNo,
|
||||
payout_reference AS payoutReference,
|
||||
payout_state AS payoutState, payout_package_info AS payoutPackageInfo
|
||||
FROM qipai_cleaning_settlements
|
||||
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL LIMIT 1`,
|
||||
@@ -468,19 +490,33 @@ export class CleaningPayoutService {
|
||||
throw new WechatPayError('WECHAT_CERTIFICATE_NOT_FOUND');
|
||||
}
|
||||
|
||||
private async findSettlementByTransferReference(outBillNo: string, transferBillNo: string) {
|
||||
private async findSettlementByTransferReference(
|
||||
merchantId: string,
|
||||
outBillNo: string,
|
||||
transferBillNo: string
|
||||
) {
|
||||
const references = transferBillNo ? [outBillNo, transferBillNo] : [outBillNo];
|
||||
const [rows] = await this.pool.execute<Array<SettlementPayoutRow & { tenantId: string }>>(
|
||||
`SELECT tenant_id AS tenantId, id, settlement_no AS settlementNo,
|
||||
cleaner_user_id AS cleanerUserId, store_id AS storeId, status,
|
||||
total_reward_cents AS totalRewardCents, payout_channel AS payoutChannel,
|
||||
payout_reference AS payoutReference, payout_state AS payoutState,
|
||||
payout_package_info AS payoutPackageInfo
|
||||
FROM qipai_cleaning_settlements
|
||||
WHERE payout_channel = 'WECHAT_TRANSFER' AND deleted_at IS NULL
|
||||
AND payout_reference IN (${references.map(() => '?').join(',')})
|
||||
ORDER BY updated_at DESC, id DESC LIMIT 1`,
|
||||
references
|
||||
`SELECT s.tenant_id AS tenantId, s.id, s.settlement_no AS settlementNo,
|
||||
s.cleaner_user_id AS cleanerUserId, s.generated_by AS generatedBy,
|
||||
s.store_id AS storeId, s.status,
|
||||
s.total_reward_cents AS totalRewardCents, s.payout_channel AS payoutChannel,
|
||||
s.payout_request_no AS payoutRequestNo,
|
||||
s.payout_reference AS payoutReference, s.payout_state AS payoutState,
|
||||
s.payout_package_info AS payoutPackageInfo
|
||||
FROM qipai_cleaning_settlements s
|
||||
INNER JOIN qipai_collection_accounts account
|
||||
ON account.tenant_id = s.tenant_id
|
||||
AND account.provider = 'WECHAT' AND account.merchant_id = ?
|
||||
AND account.enabled = 1
|
||||
AND (account.store_id IS NULL OR account.store_id <=> s.store_id)
|
||||
WHERE s.payout_channel = 'WECHAT_TRANSFER'
|
||||
AND s.deleted_at IS NULL
|
||||
AND (s.payout_request_no IN (${references.map(() => '?').join(',')})
|
||||
OR s.payout_reference IN (${references.map(() => '?').join(',')}))
|
||||
ORDER BY (account.store_id IS NOT NULL) DESC,
|
||||
s.updated_at DESC, s.id DESC LIMIT 1`,
|
||||
[merchantId, ...references, ...references]
|
||||
);
|
||||
if (!rows[0]) throw new CleaningPayoutError('WECHAT_TRANSFER_SETTLEMENT_NOT_FOUND');
|
||||
return rows[0];
|
||||
@@ -488,8 +524,8 @@ export class CleaningPayoutService {
|
||||
}
|
||||
|
||||
function normalizeOutBillNo(settlementNo: string, settlementId: string) {
|
||||
const normalized = settlementNo.replace(/[^A-Za-z0-9_-]/g, '');
|
||||
return (normalized || `CLP${settlementId}`).slice(0, 32);
|
||||
void settlementNo;
|
||||
return `CLP${settlementId}`;
|
||||
}
|
||||
|
||||
function stringPayload(payload: Record<string, unknown>, key: string) {
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface CleaningActor {
|
||||
userId: string;
|
||||
access: AccessProfile;
|
||||
traceId: string;
|
||||
actorType?: 'USER' | 'SYSTEM';
|
||||
}
|
||||
|
||||
interface CleaningTaskRow extends RowDataPacket {
|
||||
@@ -154,9 +155,12 @@ interface SettlementRow extends RowDataPacket {
|
||||
periodStart: Date | null;
|
||||
periodEnd: Date | null;
|
||||
paidBy: string | null;
|
||||
cancelledBy: string | null;
|
||||
confirmedAt: Date | null;
|
||||
paidAt: Date | null;
|
||||
cancelledAt: Date | null;
|
||||
payoutChannel: string;
|
||||
payoutRequestNo: string;
|
||||
payoutReference: string;
|
||||
payoutState: string;
|
||||
payoutPackageInfo: string;
|
||||
@@ -176,9 +180,31 @@ interface SettlementItemRow extends RowDataPacket {
|
||||
cleanerUserId: string;
|
||||
cleanerName: string;
|
||||
rewardCents: number;
|
||||
reversedAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
interface SettlementEventRow extends RowDataPacket {
|
||||
id: string;
|
||||
settlementId: string;
|
||||
fromStatus: CleaningSettlementStatus | null;
|
||||
toStatus: CleaningSettlementStatus;
|
||||
action: string;
|
||||
actorId: string;
|
||||
actorType: 'USER' | 'SYSTEM';
|
||||
traceId: string;
|
||||
note: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
interface SettlementReversalRow extends RowDataPacket {
|
||||
id: string;
|
||||
settlementId: string;
|
||||
reversalNo: string;
|
||||
amountDeltaCents: number;
|
||||
reason: string;
|
||||
reversedBy: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
interface ManagerStatusStatsRow extends RowDataPacket {
|
||||
status: CleaningTaskStatus;
|
||||
total: number;
|
||||
@@ -596,6 +622,7 @@ export class CleaningTaskRepository {
|
||||
if (!['CLAIMED', 'STARTED', 'SUBMITTED', 'COMPLETED'].includes(task.status)) {
|
||||
throw new CleaningTaskError('CLEANING_TASK_STATUS_CONFLICT');
|
||||
}
|
||||
await this.assertTaskSettlementMutable(connection, input.tenantId, input.taskId);
|
||||
const [existingMembers] = await connection.execute<CleaningTaskMemberRow[]>(
|
||||
`SELECT m.id, m.task_id AS taskId, m.user_id AS userId, '' AS nickname,
|
||||
m.member_role AS memberRole, m.reward_cents AS rewardCents,
|
||||
@@ -643,6 +670,7 @@ export class CleaningTaskRepository {
|
||||
if (String(task.cleanerUserId) === input.cleanerUserId) {
|
||||
throw new CleaningTaskError('CLEANING_MEMBER_LEAD_NOT_REMOVABLE');
|
||||
}
|
||||
await this.assertTaskSettlementMutable(connection, input.tenantId, input.taskId);
|
||||
const [result] = await connection.execute<ResultSetHeader>(
|
||||
`UPDATE qipai_cleaning_task_members
|
||||
SET removed_at = UTC_TIMESTAMP(3), reward_cents = 0, updated_at = UTC_TIMESTAMP(3)
|
||||
@@ -668,6 +696,21 @@ export class CleaningTaskRepository {
|
||||
't.deleted_at IS NULL',
|
||||
"t.status = 'COMPLETED'",
|
||||
't.settled_at IS NULL',
|
||||
`EXISTS (
|
||||
SELECT 1 FROM qipai_cleaning_task_members pending_member
|
||||
WHERE pending_member.tenant_id = t.tenant_id
|
||||
AND pending_member.task_id = t.id
|
||||
AND pending_member.removed_at IS NULL
|
||||
AND pending_member.settled_at IS NULL
|
||||
AND pending_member.reward_cents > 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM qipai_cleaning_settlement_items active_item
|
||||
WHERE active_item.tenant_id = pending_member.tenant_id
|
||||
AND active_item.task_id = pending_member.task_id
|
||||
AND active_item.cleaner_user_id = pending_member.user_id
|
||||
AND active_item.reversed_at IS NULL
|
||||
)
|
||||
)`,
|
||||
storeScopeSql(input.access, 't.store_id')
|
||||
];
|
||||
const params: Array<string | number> = [input.tenantId];
|
||||
@@ -676,15 +719,23 @@ export class CleaningTaskRepository {
|
||||
params.push(input.storeId);
|
||||
}
|
||||
if (input.cleanerUserId) {
|
||||
where.push(`(t.cleaner_user_id = ? OR EXISTS (
|
||||
where.push(`EXISTS (
|
||||
SELECT 1 FROM qipai_cleaning_task_members candidate_member
|
||||
WHERE candidate_member.tenant_id = t.tenant_id
|
||||
AND candidate_member.task_id = t.id
|
||||
AND candidate_member.user_id = ?
|
||||
AND candidate_member.removed_at IS NULL
|
||||
AND candidate_member.settled_at IS NULL
|
||||
))`);
|
||||
params.push(input.cleanerUserId, input.cleanerUserId);
|
||||
AND candidate_member.reward_cents > 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM qipai_cleaning_settlement_items active_item
|
||||
WHERE active_item.tenant_id = candidate_member.tenant_id
|
||||
AND active_item.task_id = candidate_member.task_id
|
||||
AND active_item.cleaner_user_id = candidate_member.user_id
|
||||
AND active_item.reversed_at IS NULL
|
||||
)
|
||||
)`);
|
||||
params.push(input.cleanerUserId);
|
||||
}
|
||||
return this.listByWhere(where, params, input.page, input.pageSize, 't.completed_at ASC, t.id ASC');
|
||||
}
|
||||
@@ -694,66 +745,32 @@ export class CleaningTaskRepository {
|
||||
payoutState?: CleaningSettlementPayoutState; storeId?: string; cleanerUserId?: string;
|
||||
}) {
|
||||
this.assertSettlement(input.access, 'read');
|
||||
const where = [
|
||||
's.tenant_id = ?',
|
||||
's.deleted_at IS NULL',
|
||||
storeScopeSql(input.access, 'COALESCE(s.store_id, 0)')
|
||||
];
|
||||
const params: Array<string | number> = [input.tenantId];
|
||||
if (input.status) {
|
||||
where.push('s.status = ?');
|
||||
params.push(input.status);
|
||||
}
|
||||
if (input.payoutState) {
|
||||
if (input.payoutState === 'NONE') {
|
||||
where.push("s.payout_state = ''");
|
||||
} else {
|
||||
where.push('s.payout_state = ?');
|
||||
params.push(input.payoutState);
|
||||
}
|
||||
}
|
||||
if (input.storeId) {
|
||||
where.push('s.store_id = ?');
|
||||
params.push(input.storeId);
|
||||
}
|
||||
if (input.cleanerUserId) {
|
||||
where.push('s.cleaner_user_id = ?');
|
||||
params.push(input.cleanerUserId);
|
||||
}
|
||||
const whereSql = where.join(' AND ');
|
||||
const pageSize = Math.max(1, Math.min(100, Math.trunc(input.pageSize)));
|
||||
const offset = Math.max(0, (Math.trunc(input.page) - 1) * pageSize);
|
||||
const [counts] = await this.pool.execute<CountRow[]>(
|
||||
`SELECT COUNT(*) AS total FROM qipai_cleaning_settlements s WHERE ${whereSql}`,
|
||||
params
|
||||
);
|
||||
const [rows] = await this.pool.execute<SettlementRow[]>(
|
||||
`SELECT s.id, s.settlement_no AS settlementNo,
|
||||
s.cleaner_user_id AS cleanerUserId, u.nickname AS cleanerName,
|
||||
s.store_id AS storeId, st.name AS storeName, s.status,
|
||||
s.task_count AS taskCount, s.total_reward_cents AS totalRewardCents,
|
||||
s.period_start AS periodStart, s.period_end AS periodEnd,
|
||||
s.paid_by AS paidBy, s.confirmed_at AS confirmedAt, s.paid_at AS paidAt,
|
||||
s.payout_channel AS payoutChannel, s.payout_reference AS payoutReference,
|
||||
s.payout_state AS payoutState, s.payout_package_info AS payoutPackageInfo,
|
||||
s.payout_error AS payoutError,
|
||||
s.note, s.created_at AS createdAt
|
||||
FROM qipai_cleaning_settlements s
|
||||
INNER JOIN qipai_users u ON u.tenant_id = s.tenant_id AND u.id = s.cleaner_user_id
|
||||
LEFT JOIN qipai_stores st ON st.tenant_id = s.tenant_id AND st.id = s.store_id
|
||||
WHERE ${whereSql}
|
||||
ORDER BY s.created_at DESC, s.id DESC
|
||||
LIMIT ${pageSize} OFFSET ${offset}`,
|
||||
params
|
||||
);
|
||||
const { rows, total } = await this.querySettlementRows(input, pageSize, offset);
|
||||
return {
|
||||
items: rows.map(publicSettlement),
|
||||
total: Number(counts[0]?.total ?? 0),
|
||||
total,
|
||||
page: input.page,
|
||||
pageSize
|
||||
};
|
||||
}
|
||||
|
||||
async exportSettlements(input: CleaningActor & {
|
||||
status?: CleaningSettlementStatus; payoutState?: CleaningSettlementPayoutState;
|
||||
storeId?: string; cleanerUserId?: string;
|
||||
}) {
|
||||
this.assertSettlement(input.access, 'read');
|
||||
const limit = 5000;
|
||||
const { rows, total } = await this.querySettlementRows(input, limit, 0);
|
||||
return {
|
||||
items: rows.map(publicSettlement),
|
||||
total,
|
||||
exported: rows.length,
|
||||
truncated: total > rows.length
|
||||
};
|
||||
}
|
||||
|
||||
async getSettlementDetail(input: CleaningActor & { settlementId: string }) {
|
||||
this.assertSettlement(input.access, 'read');
|
||||
const settlement = await this.getSettlementScoped(input);
|
||||
@@ -762,7 +779,8 @@ export class CleaningTaskRepository {
|
||||
t.task_no AS taskNo, o.order_no AS orderNo,
|
||||
st.name AS storeName, r.name AS roomName, r.room_no AS roomNo,
|
||||
i.cleaner_user_id AS cleanerUserId, u.nickname AS cleanerName,
|
||||
i.reward_cents AS rewardCents, t.completed_at AS completedAt,
|
||||
i.reward_cents AS rewardCents, i.reversed_at AS reversedAt,
|
||||
t.completed_at AS completedAt,
|
||||
i.created_at AS createdAt
|
||||
FROM qipai_cleaning_settlement_items i
|
||||
INNER JOIN qipai_cleaning_settlements s ON s.tenant_id = i.tenant_id
|
||||
@@ -776,14 +794,39 @@ export class CleaningTaskRepository {
|
||||
ORDER BY t.completed_at ASC, i.id ASC`,
|
||||
[input.tenantId, input.settlementId]
|
||||
);
|
||||
return { settlement, items: items.map(publicSettlementItem) };
|
||||
const [events] = await this.pool.execute<SettlementEventRow[]>(
|
||||
`SELECT e.id, e.settlement_id AS settlementId, e.from_status AS fromStatus,
|
||||
e.to_status AS toStatus, e.action, e.actor_id AS actorId,
|
||||
e.actor_type AS actorType, e.trace_id AS traceId,
|
||||
e.note, e.created_at AS createdAt
|
||||
FROM qipai_cleaning_settlement_events e
|
||||
WHERE e.tenant_id = ? AND e.settlement_id = ?
|
||||
ORDER BY e.created_at ASC, e.id ASC`,
|
||||
[input.tenantId, input.settlementId]
|
||||
);
|
||||
const [reversals] = await this.pool.execute<SettlementReversalRow[]>(
|
||||
`SELECT r.id, r.settlement_id AS settlementId, r.reversal_no AS reversalNo,
|
||||
r.amount_delta_cents AS amountDeltaCents, r.reason,
|
||||
r.reversed_by AS reversedBy, r.created_at AS createdAt
|
||||
FROM qipai_cleaning_settlement_reversals r
|
||||
WHERE r.tenant_id = ? AND r.settlement_id = ?
|
||||
ORDER BY r.created_at ASC, r.id ASC`,
|
||||
[input.tenantId, input.settlementId]
|
||||
);
|
||||
return {
|
||||
settlement,
|
||||
items: items.map(publicSettlementItem),
|
||||
events: events.map(publicSettlementEvent),
|
||||
reversals: reversals.map(publicSettlementReversal)
|
||||
};
|
||||
}
|
||||
|
||||
async generateSettlement(input: CleaningActor & {
|
||||
cleanerUserId: string; storeId?: string; note?: string;
|
||||
}) {
|
||||
this.assertSettlement(input.access, 'write');
|
||||
return this.transaction(async (connection) => {
|
||||
try {
|
||||
return await this.transaction(async (connection) => {
|
||||
await connection.execute(
|
||||
`INSERT IGNORE INTO qipai_cleaning_task_members
|
||||
(tenant_id, task_id, user_id, member_role, reward_cents, joined_at)
|
||||
@@ -808,6 +851,13 @@ export class CleaningTaskRepository {
|
||||
AND m.settled_at IS NULL AND m.reward_cents > 0
|
||||
WHERE t.tenant_id = ?
|
||||
AND t.status = 'COMPLETED' AND t.deleted_at IS NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM qipai_cleaning_settlement_items active_item
|
||||
WHERE active_item.tenant_id = m.tenant_id
|
||||
AND active_item.task_id = m.task_id
|
||||
AND active_item.cleaner_user_id = m.user_id
|
||||
AND active_item.reversed_at IS NULL
|
||||
)
|
||||
${storeFilter} AND ${scopeSql}
|
||||
ORDER BY t.completed_at ASC, t.id ASC
|
||||
FOR UPDATE`,
|
||||
@@ -815,10 +865,15 @@ export class CleaningTaskRepository {
|
||||
);
|
||||
if (tasks.length === 0) throw new CleaningTaskError('CLEANING_SETTLEMENT_EMPTY');
|
||||
const storeIds = Array.from(new Set(tasks.map((task) => String(task.storeId))));
|
||||
if (storeIds.length > 1
|
||||
&& !input.access.capabilities.includes('tenant.manage')
|
||||
&& !input.access.roles.includes('PLATFORM_ADMIN')) {
|
||||
throw new CleaningTaskError('CLEANING_SETTLEMENT_STORE_REQUIRED');
|
||||
}
|
||||
const totalRewardCents = tasks.reduce((sum, task) => sum + Number(task.rewardCents), 0);
|
||||
const periodStart = minDate(tasks.map((task) => task.completedAt));
|
||||
const periodEnd = maxDate(tasks.map((task) => task.completedAt));
|
||||
const settlementNo = `CLS-${Date.now()}-${input.cleanerUserId}`;
|
||||
const settlementNo = `CLS-${Date.now()}-${input.cleanerUserId}-${input.traceId.slice(-8)}`;
|
||||
const [created] = await connection.execute<ResultSetHeader>(
|
||||
`INSERT INTO qipai_cleaning_settlements
|
||||
(tenant_id, settlement_no, cleaner_user_id, store_id, status, task_count,
|
||||
@@ -832,39 +887,41 @@ export class CleaningTaskRepository {
|
||||
]
|
||||
);
|
||||
const settlementId = String(created.insertId);
|
||||
for (const task of tasks) {
|
||||
await connection.execute(
|
||||
`INSERT INTO qipai_cleaning_settlement_items
|
||||
(tenant_id, settlement_id, task_id, cleaner_user_id, reward_cents)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[input.tenantId, settlementId, task.id, input.cleanerUserId, Number(task.rewardCents)]
|
||||
);
|
||||
try {
|
||||
for (const task of tasks) {
|
||||
await connection.execute(
|
||||
`INSERT INTO qipai_cleaning_settlement_items
|
||||
(tenant_id, settlement_id, task_id, cleaner_user_id, reward_cents)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[input.tenantId, settlementId, task.id, input.cleanerUserId, Number(task.rewardCents)]
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isMySqlDuplicate(error)) {
|
||||
throw new CleaningTaskError('CLEANING_SETTLEMENT_CONCURRENTLY_CLAIMED');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
await connection.execute(
|
||||
`UPDATE qipai_cleaning_task_members
|
||||
SET settled_at = UTC_TIMESTAMP(3)
|
||||
WHERE tenant_id = ? AND user_id = ? AND task_id IN (${tasks.map(() => '?').join(',')})`,
|
||||
[input.tenantId, input.cleanerUserId, ...tasks.map((task) => task.id)]
|
||||
);
|
||||
await connection.execute(
|
||||
`UPDATE qipai_cleaning_tasks t
|
||||
SET t.status = 'SETTLED', t.settled_at = UTC_TIMESTAMP(3)
|
||||
WHERE t.tenant_id = ? AND t.id IN (${tasks.map(() => '?').join(',')})
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM qipai_cleaning_task_members pending
|
||||
WHERE pending.tenant_id = t.tenant_id AND pending.task_id = t.id
|
||||
AND pending.removed_at IS NULL AND pending.reward_cents > 0
|
||||
AND pending.settled_at IS NULL
|
||||
)`,
|
||||
[input.tenantId, ...tasks.map((task) => task.id)]
|
||||
await this.recordSettlementEventWithConnection(
|
||||
connection, input, settlementId, null, 'DRAFT', 'GENERATE', input.note ?? '', {
|
||||
cleanerUserId: input.cleanerUserId,
|
||||
taskCount: tasks.length,
|
||||
totalRewardCents
|
||||
}
|
||||
);
|
||||
for (const task of tasks) {
|
||||
await this.recordEventWithConnection(
|
||||
connection, input, String(task.id), 'COMPLETED', 'COMPLETED', 'SETTLE_MEMBER', settlementNo
|
||||
connection, input, String(task.id), 'COMPLETED', 'COMPLETED', 'ALLOCATE_SETTLEMENT', settlementNo
|
||||
);
|
||||
}
|
||||
return this.getSettlement(connection, input.tenantId, settlementId);
|
||||
});
|
||||
return this.getSettlement(connection, input.tenantId, settlementId);
|
||||
});
|
||||
} catch (error) {
|
||||
if (isMySqlSettlementContention(error)) {
|
||||
throw new CleaningTaskError('CLEANING_SETTLEMENT_CONCURRENTLY_CLAIMED');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async confirmSettlement(input: CleaningActor & { settlementId: string; note?: string }) {
|
||||
@@ -879,12 +936,16 @@ export class CleaningTaskRepository {
|
||||
[input.userId, input.note ?? '', input.note ?? '', input.tenantId, input.settlementId]
|
||||
);
|
||||
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_SETTLEMENT_STATUS_CONFLICT');
|
||||
await this.recordSettlementEventWithConnection(
|
||||
connection, input, input.settlementId, 'DRAFT', 'CONFIRMED', 'CONFIRM', input.note ?? ''
|
||||
);
|
||||
return this.getSettlement(connection, input.tenantId, input.settlementId);
|
||||
});
|
||||
}
|
||||
|
||||
async markSettlementPaid(input: CleaningActor & {
|
||||
settlementId: string; payoutChannel: string; payoutReference: string; note?: string;
|
||||
providerConfirmed?: boolean; manualOverride?: boolean;
|
||||
}) {
|
||||
this.assertSettlement(input.access, 'write');
|
||||
return this.transaction(async (connection) => {
|
||||
@@ -895,6 +956,11 @@ export class CleaningTaskRepository {
|
||||
s.payout_package_info = '', s.payout_error = '',
|
||||
s.note = CASE WHEN ? = '' THEN s.note ELSE ? END
|
||||
WHERE s.tenant_id = ? AND s.id = ? AND s.status = 'CONFIRMED' AND s.deleted_at IS NULL
|
||||
AND ${input.providerConfirmed
|
||||
? "s.payout_state <> 'SUCCESS'"
|
||||
: input.manualOverride
|
||||
? "s.payout_state IN ('', 'FAIL')"
|
||||
: '1 = 0'}
|
||||
AND ${storeScopeSql(input.access, 'COALESCE(s.store_id, 0)')}`,
|
||||
[
|
||||
input.userId, input.payoutChannel, input.payoutReference,
|
||||
@@ -902,12 +968,58 @@ export class CleaningTaskRepository {
|
||||
]
|
||||
);
|
||||
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_SETTLEMENT_STATUS_CONFLICT');
|
||||
await connection.execute(
|
||||
`UPDATE qipai_cleaning_task_members m
|
||||
INNER JOIN qipai_cleaning_settlement_items i
|
||||
ON i.tenant_id = m.tenant_id AND i.task_id = m.task_id
|
||||
AND i.cleaner_user_id = m.user_id AND i.reversed_at IS NULL
|
||||
SET m.settled_at = UTC_TIMESTAMP(3), m.updated_at = UTC_TIMESTAMP(3)
|
||||
WHERE i.tenant_id = ? AND i.settlement_id = ? AND m.removed_at IS NULL`,
|
||||
[input.tenantId, input.settlementId]
|
||||
);
|
||||
const [paidTasks] = await connection.execute<Array<RowDataPacket & { id: string }>>(
|
||||
`SELECT DISTINCT t.id
|
||||
FROM qipai_cleaning_tasks t
|
||||
INNER JOIN qipai_cleaning_settlement_items i
|
||||
ON i.tenant_id = t.tenant_id AND i.task_id = t.id AND i.reversed_at IS NULL
|
||||
WHERE i.tenant_id = ? AND i.settlement_id = ?
|
||||
AND t.status = 'COMPLETED'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM qipai_cleaning_task_members pending
|
||||
WHERE pending.tenant_id = t.tenant_id AND pending.task_id = t.id
|
||||
AND pending.removed_at IS NULL AND pending.reward_cents > 0
|
||||
AND pending.settled_at IS NULL
|
||||
)
|
||||
FOR UPDATE`,
|
||||
[input.tenantId, input.settlementId]
|
||||
);
|
||||
if (paidTasks.length > 0) {
|
||||
await connection.execute(
|
||||
`UPDATE qipai_cleaning_tasks
|
||||
SET status = 'SETTLED', settled_at = UTC_TIMESTAMP(3)
|
||||
WHERE tenant_id = ? AND status = 'COMPLETED'
|
||||
AND id IN (${paidTasks.map(() => '?').join(',')})`,
|
||||
[input.tenantId, ...paidTasks.map((task) => task.id)]
|
||||
);
|
||||
for (const task of paidTasks) {
|
||||
await this.recordEventWithConnection(
|
||||
connection, input, String(task.id), 'COMPLETED', 'SETTLED', 'SETTLEMENT_PAID', input.payoutReference
|
||||
);
|
||||
}
|
||||
}
|
||||
await this.recordSettlementEventWithConnection(
|
||||
connection, input, input.settlementId, 'CONFIRMED', 'PAID', 'PAID', input.note ?? '', {
|
||||
payoutChannel: input.payoutChannel,
|
||||
payoutReference: input.payoutReference
|
||||
}
|
||||
);
|
||||
return this.getSettlement(connection, input.tenantId, input.settlementId);
|
||||
});
|
||||
}
|
||||
|
||||
async recordSettlementPayoutFailure(input: CleaningActor & {
|
||||
settlementId: string; payoutChannel?: string; payoutReference?: string; error: string; note?: string;
|
||||
settlementId: string; payoutChannel?: string; payoutReference?: string;
|
||||
error: string; note?: string; providerConfirmed?: boolean;
|
||||
}) {
|
||||
this.assertSettlement(input.access, 'write');
|
||||
return this.transaction(async (connection) => {
|
||||
@@ -918,6 +1030,9 @@ export class CleaningTaskRepository {
|
||||
s.payout_state = 'FAIL', s.payout_package_info = '', s.payout_error = ?,
|
||||
s.note = CASE WHEN ? = '' THEN s.note ELSE ? END
|
||||
WHERE s.tenant_id = ? AND s.id = ? AND s.status = 'CONFIRMED' AND s.deleted_at IS NULL
|
||||
AND ${input.providerConfirmed
|
||||
? "s.payout_state <> 'SUCCESS'"
|
||||
: "s.payout_state IN ('', 'FAIL')"}
|
||||
AND ${storeScopeSql(input.access, 'COALESCE(s.store_id, 0)')}`,
|
||||
[
|
||||
input.payoutChannel ?? '', input.payoutChannel ?? '',
|
||||
@@ -927,6 +1042,47 @@ export class CleaningTaskRepository {
|
||||
]
|
||||
);
|
||||
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_SETTLEMENT_STATUS_CONFLICT');
|
||||
await this.recordSettlementEventWithConnection(
|
||||
connection, input, input.settlementId, 'CONFIRMED', 'CONFIRMED', 'PAYOUT_FAILURE',
|
||||
input.note ?? input.error, {
|
||||
payoutChannel: input.payoutChannel ?? '',
|
||||
payoutReference: input.payoutReference ?? '',
|
||||
error: input.error.slice(0, 512)
|
||||
}
|
||||
);
|
||||
return this.getSettlement(connection, input.tenantId, input.settlementId);
|
||||
});
|
||||
}
|
||||
|
||||
async beginSettlementPayout(input: CleaningActor & {
|
||||
settlementId: string; payoutChannel: string; payoutReference: string; note?: string;
|
||||
}) {
|
||||
this.assertSettlement(input.access, 'write');
|
||||
return this.transaction(async (connection) => {
|
||||
const [result] = await connection.execute<ResultSetHeader>(
|
||||
`UPDATE qipai_cleaning_settlements s
|
||||
SET s.payout_channel = ?, s.payout_request_no = ?, s.payout_reference = ?,
|
||||
s.payout_state = 'PROCESSING',
|
||||
s.payout_package_info = '', s.payout_error = '',
|
||||
s.note = CASE WHEN ? = '' THEN s.note ELSE ? END
|
||||
WHERE s.tenant_id = ? AND s.id = ? AND s.status = 'CONFIRMED'
|
||||
AND s.payout_state IN ('', 'FAIL') AND s.deleted_at IS NULL
|
||||
AND ${storeScopeSql(input.access, 'COALESCE(s.store_id, 0)')}`,
|
||||
[input.payoutChannel, input.payoutReference, input.payoutReference,
|
||||
input.note ?? '', input.note ?? '',
|
||||
input.tenantId, input.settlementId]
|
||||
);
|
||||
if (result.affectedRows !== 1) {
|
||||
throw new CleaningTaskError('CLEANING_SETTLEMENT_PAYOUT_ALREADY_STARTED');
|
||||
}
|
||||
await this.recordSettlementEventWithConnection(
|
||||
connection, input, input.settlementId, 'CONFIRMED', 'CONFIRMED', 'PAYOUT_BEGIN',
|
||||
input.note ?? '', {
|
||||
payoutChannel: input.payoutChannel,
|
||||
payoutReference: input.payoutReference,
|
||||
payoutState: 'PROCESSING'
|
||||
}
|
||||
);
|
||||
return this.getSettlement(connection, input.tenantId, input.settlementId);
|
||||
});
|
||||
}
|
||||
@@ -943,6 +1099,7 @@ export class CleaningTaskRepository {
|
||||
s.payout_package_info = ?, s.payout_error = '',
|
||||
s.note = CASE WHEN ? = '' THEN s.note ELSE ? END
|
||||
WHERE s.tenant_id = ? AND s.id = ? AND s.status = 'CONFIRMED' AND s.deleted_at IS NULL
|
||||
AND s.payout_state IN ('PROCESSING', 'WAIT_USER_CONFIRM')
|
||||
AND ${storeScopeSql(input.access, 'COALESCE(s.store_id, 0)')}`,
|
||||
[
|
||||
input.payoutChannel, input.payoutReference, input.payoutState.slice(0, 32),
|
||||
@@ -951,6 +1108,105 @@ export class CleaningTaskRepository {
|
||||
]
|
||||
);
|
||||
if (result.affectedRows !== 1) throw new CleaningTaskError('CLEANING_SETTLEMENT_STATUS_CONFLICT');
|
||||
await this.recordSettlementEventWithConnection(
|
||||
connection, input, input.settlementId, 'CONFIRMED', 'CONFIRMED', 'PAYOUT_PENDING',
|
||||
input.note ?? '', {
|
||||
payoutChannel: input.payoutChannel,
|
||||
payoutReference: input.payoutReference,
|
||||
payoutState: input.payoutState.slice(0, 32)
|
||||
}
|
||||
);
|
||||
return this.getSettlement(connection, input.tenantId, input.settlementId);
|
||||
});
|
||||
}
|
||||
|
||||
async cancelSettlement(input: CleaningActor & { settlementId: string; reason: string }) {
|
||||
this.assertSettlement(input.access, 'write');
|
||||
return this.transaction(async (connection) => {
|
||||
const [rows] = await connection.execute<Array<RowDataPacket & {
|
||||
status: CleaningSettlementStatus;
|
||||
payoutState: string;
|
||||
totalRewardCents: number;
|
||||
settlementNo: string;
|
||||
}>>(
|
||||
`SELECT s.status, s.payout_state AS payoutState,
|
||||
s.total_reward_cents AS totalRewardCents, s.settlement_no AS settlementNo
|
||||
FROM qipai_cleaning_settlements s
|
||||
WHERE s.tenant_id = ? AND s.id = ? AND s.deleted_at IS NULL
|
||||
AND ${storeScopeSql(input.access, 'COALESCE(s.store_id, 0)')}
|
||||
FOR UPDATE`,
|
||||
[input.tenantId, input.settlementId]
|
||||
);
|
||||
const settlement = rows[0];
|
||||
if (!settlement) throw new CleaningTaskError('CLEANING_SETTLEMENT_NOT_FOUND');
|
||||
if (!['DRAFT', 'CONFIRMED'].includes(settlement.status)) {
|
||||
throw new CleaningTaskError('CLEANING_SETTLEMENT_STATUS_CONFLICT');
|
||||
}
|
||||
if (['PROCESSING', 'WAIT_USER_CONFIRM', 'SUCCESS'].includes(settlement.payoutState)) {
|
||||
throw new CleaningTaskError('CLEANING_SETTLEMENT_PAYOUT_PENDING');
|
||||
}
|
||||
const reversalNo = `CLR-${Date.now()}-${input.settlementId}-${input.traceId.slice(-8)}`;
|
||||
const [created] = await connection.execute<ResultSetHeader>(
|
||||
`INSERT INTO qipai_cleaning_settlement_reversals
|
||||
(tenant_id, reversal_no, settlement_id, amount_delta_cents, reason, reversed_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[input.tenantId, reversalNo, input.settlementId,
|
||||
-Number(settlement.totalRewardCents), input.reason, input.userId]
|
||||
);
|
||||
const reversalId = String(created.insertId);
|
||||
const [items] = await connection.execute<Array<RowDataPacket & { taskId: string }>>(
|
||||
`SELECT i.task_id AS taskId
|
||||
FROM qipai_cleaning_settlement_items i
|
||||
WHERE i.tenant_id = ? AND i.settlement_id = ? AND i.reversed_at IS NULL
|
||||
FOR UPDATE`,
|
||||
[input.tenantId, input.settlementId]
|
||||
);
|
||||
if (items.length === 0) throw new CleaningTaskError('CLEANING_SETTLEMENT_STATUS_CONFLICT');
|
||||
await connection.execute(
|
||||
`UPDATE qipai_cleaning_settlement_items
|
||||
SET reversal_id = ?, reversed_at = UTC_TIMESTAMP(3)
|
||||
WHERE tenant_id = ? AND settlement_id = ? AND reversed_at IS NULL`,
|
||||
[reversalId, input.tenantId, input.settlementId]
|
||||
);
|
||||
await connection.execute(
|
||||
`UPDATE qipai_cleaning_task_members m
|
||||
INNER JOIN qipai_cleaning_settlement_items i
|
||||
ON i.tenant_id = m.tenant_id AND i.task_id = m.task_id
|
||||
AND i.cleaner_user_id = m.user_id
|
||||
SET m.settled_at = NULL, m.updated_at = UTC_TIMESTAMP(3)
|
||||
WHERE i.tenant_id = ? AND i.settlement_id = ? AND i.reversal_id = ?`,
|
||||
[input.tenantId, input.settlementId, reversalId]
|
||||
);
|
||||
await connection.execute(
|
||||
`UPDATE qipai_cleaning_tasks t
|
||||
INNER JOIN qipai_cleaning_settlement_items i
|
||||
ON i.tenant_id = t.tenant_id AND i.task_id = t.id
|
||||
SET t.status = 'COMPLETED', t.settled_at = NULL
|
||||
WHERE i.tenant_id = ? AND i.settlement_id = ? AND i.reversal_id = ?
|
||||
AND t.status IN ('COMPLETED', 'SETTLED')`,
|
||||
[input.tenantId, input.settlementId, reversalId]
|
||||
);
|
||||
const [updated] = await connection.execute<ResultSetHeader>(
|
||||
`UPDATE qipai_cleaning_settlements
|
||||
SET status = 'CANCELLED', cancelled_by = ?, cancelled_at = UTC_TIMESTAMP(3),
|
||||
note = CASE WHEN ? = '' THEN note ELSE ? END
|
||||
WHERE tenant_id = ? AND id = ? AND status = ? AND deleted_at IS NULL`,
|
||||
[input.userId, input.reason, input.reason, input.tenantId,
|
||||
input.settlementId, settlement.status]
|
||||
);
|
||||
if (updated.affectedRows !== 1) throw new CleaningTaskError('CLEANING_SETTLEMENT_STATUS_CONFLICT');
|
||||
await this.recordSettlementEventWithConnection(
|
||||
connection, input, input.settlementId, settlement.status, 'CANCELLED', 'CANCEL', input.reason, {
|
||||
reversalId,
|
||||
reversalNo,
|
||||
amountDeltaCents: -Number(settlement.totalRewardCents)
|
||||
}
|
||||
);
|
||||
for (const item of items) {
|
||||
await this.recordEventWithConnection(
|
||||
connection, input, String(item.taskId), 'COMPLETED', 'COMPLETED', 'REVERSE_SETTLEMENT', reversalNo
|
||||
);
|
||||
}
|
||||
return this.getSettlement(connection, input.tenantId, input.settlementId);
|
||||
});
|
||||
}
|
||||
@@ -1245,16 +1501,16 @@ export class CleaningTaskRepository {
|
||||
INNER JOIN qipai_cleaning_tasks t ON t.tenant_id = m.tenant_id AND t.id = m.task_id
|
||||
WHERE m.tenant_id = ? AND m.user_id = ? AND m.removed_at IS NULL
|
||||
AND m.settled_at IS NULL AND m.reward_cents > 0
|
||||
AND t.status IN ('SUBMITTED', 'COMPLETED') AND t.deleted_at IS NULL`,
|
||||
AND t.status = 'COMPLETED' AND t.deleted_at IS NULL`,
|
||||
[input.tenantId, input.userId]
|
||||
);
|
||||
const [settledRows] = await this.pool.execute<AmountRow[]>(
|
||||
`SELECT COALESCE(SUM(m.reward_cents), 0) AS amount
|
||||
FROM qipai_cleaning_task_members m
|
||||
INNER JOIN qipai_cleaning_tasks t ON t.tenant_id = m.tenant_id AND t.id = m.task_id
|
||||
WHERE m.tenant_id = ? AND m.user_id = ? AND m.removed_at IS NULL
|
||||
AND m.settled_at IS NOT NULL AND m.reward_cents > 0
|
||||
AND t.deleted_at IS NULL`,
|
||||
`SELECT COALESCE(SUM(i.reward_cents), 0) AS amount
|
||||
FROM qipai_cleaning_settlement_items i
|
||||
INNER JOIN qipai_cleaning_settlements s
|
||||
ON s.tenant_id = i.tenant_id AND s.id = i.settlement_id
|
||||
WHERE i.tenant_id = ? AND i.cleaner_user_id = ? AND i.reversed_at IS NULL
|
||||
AND s.status = 'PAID' AND s.deleted_at IS NULL`,
|
||||
[input.tenantId, input.userId]
|
||||
);
|
||||
const byStatus = Object.fromEntries(counts.map((row) => [row.status, Number(row.total)]));
|
||||
@@ -1368,10 +1624,21 @@ export class CleaningTaskRepository {
|
||||
SUM(CASE WHEN t.status IN ('COMPLETED', 'SETTLED') THEN 1 ELSE 0 END) AS completedTaskCount,
|
||||
SUM(CASE WHEN t.status = 'REJECTED' THEN 1 ELSE 0 END) AS rejectedTaskCount,
|
||||
COALESCE(SUM(CASE
|
||||
WHEN m.removed_at IS NULL AND m.settled_at IS NULL AND t.status IN ('SUBMITTED', 'COMPLETED')
|
||||
WHEN m.removed_at IS NULL AND m.settled_at IS NULL AND t.status = 'COMPLETED'
|
||||
THEN m.reward_cents ELSE 0 END), 0) AS pendingSettlementCents,
|
||||
COALESCE(SUM(CASE
|
||||
WHEN m.settled_at IS NOT NULL THEN m.reward_cents ELSE 0 END), 0) AS settledRewardCents
|
||||
COALESCE(SUM(COALESCE((
|
||||
SELECT MAX(paid_item.reward_cents)
|
||||
FROM qipai_cleaning_settlement_items paid_item
|
||||
INNER JOIN qipai_cleaning_settlements paid_settlement
|
||||
ON paid_settlement.tenant_id = paid_item.tenant_id
|
||||
AND paid_settlement.id = paid_item.settlement_id
|
||||
WHERE paid_item.tenant_id = m.tenant_id
|
||||
AND paid_item.task_id = m.task_id
|
||||
AND paid_item.cleaner_user_id = m.user_id
|
||||
AND paid_item.reversed_at IS NULL
|
||||
AND paid_settlement.status = 'PAID'
|
||||
AND paid_settlement.deleted_at IS NULL
|
||||
), 0)), 0) AS settledRewardCents
|
||||
FROM qipai_cleaning_task_members m
|
||||
INNER JOIN qipai_cleaning_tasks t ON t.tenant_id = m.tenant_id AND t.id = m.task_id
|
||||
INNER JOIN qipai_users u ON u.tenant_id = m.tenant_id AND u.id = m.user_id
|
||||
@@ -1414,6 +1681,66 @@ export class CleaningTaskRepository {
|
||||
return publicManagerStatistics(statusRows, storeRows, settlementRows, memberRows, trendRows);
|
||||
}
|
||||
|
||||
private async querySettlementRows(input: CleaningActor & {
|
||||
status?: CleaningSettlementStatus; payoutState?: CleaningSettlementPayoutState;
|
||||
storeId?: string; cleanerUserId?: string;
|
||||
}, limit: number, offset: number) {
|
||||
const where = [
|
||||
's.tenant_id = ?',
|
||||
's.deleted_at IS NULL',
|
||||
storeScopeSql(input.access, 'COALESCE(s.store_id, 0)')
|
||||
];
|
||||
const params: Array<string | number> = [input.tenantId];
|
||||
if (input.status) {
|
||||
where.push('s.status = ?');
|
||||
params.push(input.status);
|
||||
}
|
||||
if (input.payoutState) {
|
||||
if (input.payoutState === 'NONE') where.push("s.payout_state = ''");
|
||||
else {
|
||||
where.push('s.payout_state = ?');
|
||||
params.push(input.payoutState);
|
||||
}
|
||||
}
|
||||
if (input.storeId) {
|
||||
where.push('s.store_id = ?');
|
||||
params.push(input.storeId);
|
||||
}
|
||||
if (input.cleanerUserId) {
|
||||
where.push('s.cleaner_user_id = ?');
|
||||
params.push(input.cleanerUserId);
|
||||
}
|
||||
const whereSql = where.join(' AND ');
|
||||
const safeLimit = Math.max(1, Math.min(5000, Math.trunc(limit)));
|
||||
const safeOffset = Math.max(0, Math.trunc(offset));
|
||||
const [counts] = await this.pool.execute<CountRow[]>(
|
||||
`SELECT COUNT(*) AS total FROM qipai_cleaning_settlements s WHERE ${whereSql}`,
|
||||
params
|
||||
);
|
||||
const [rows] = await this.pool.execute<SettlementRow[]>(
|
||||
`SELECT s.id, s.settlement_no AS settlementNo,
|
||||
s.cleaner_user_id AS cleanerUserId, u.nickname AS cleanerName,
|
||||
s.store_id AS storeId, st.name AS storeName, s.status,
|
||||
s.task_count AS taskCount, s.total_reward_cents AS totalRewardCents,
|
||||
s.period_start AS periodStart, s.period_end AS periodEnd,
|
||||
s.paid_by AS paidBy, s.cancelled_by AS cancelledBy,
|
||||
s.confirmed_at AS confirmedAt, s.paid_at AS paidAt,
|
||||
s.cancelled_at AS cancelledAt,
|
||||
s.payout_channel AS payoutChannel, s.payout_request_no AS payoutRequestNo,
|
||||
s.payout_reference AS payoutReference,
|
||||
s.payout_state AS payoutState, s.payout_package_info AS payoutPackageInfo,
|
||||
s.payout_error AS payoutError, s.note, s.created_at AS createdAt
|
||||
FROM qipai_cleaning_settlements s
|
||||
INNER JOIN qipai_users u ON u.tenant_id = s.tenant_id AND u.id = s.cleaner_user_id
|
||||
LEFT JOIN qipai_stores st ON st.tenant_id = s.tenant_id AND st.id = s.store_id
|
||||
WHERE ${whereSql}
|
||||
ORDER BY s.created_at DESC, s.id DESC
|
||||
LIMIT ${safeLimit} OFFSET ${safeOffset}`,
|
||||
params
|
||||
);
|
||||
return { rows, total: Number(counts[0]?.total ?? 0) };
|
||||
}
|
||||
|
||||
private async listByWhere(
|
||||
where: string[],
|
||||
params: Array<string | number>,
|
||||
@@ -1615,6 +1942,21 @@ export class CleaningTaskRepository {
|
||||
);
|
||||
}
|
||||
|
||||
private async assertTaskSettlementMutable(
|
||||
connection: PoolConnection,
|
||||
tenantId: string,
|
||||
taskId: string
|
||||
) {
|
||||
const [rows] = await connection.execute<Array<RowDataPacket & { id: string }>>(
|
||||
`SELECT id
|
||||
FROM qipai_cleaning_settlement_items
|
||||
WHERE tenant_id = ? AND task_id = ? AND reversed_at IS NULL
|
||||
LIMIT 1 FOR UPDATE`,
|
||||
[tenantId, taskId]
|
||||
);
|
||||
if (rows[0]) throw new CleaningTaskError('CLEANING_MEMBER_SETTLEMENT_LOCKED');
|
||||
}
|
||||
|
||||
private async getTemplate(
|
||||
connection: Pick<MySqlPool, 'execute'> | PoolConnection,
|
||||
tenantId: string,
|
||||
@@ -1718,9 +2060,31 @@ export class CleaningTaskRepository {
|
||||
await connection.execute(
|
||||
`INSERT INTO qipai_cleaning_task_events
|
||||
(tenant_id, task_id, from_status, to_status, action, actor_id, trace_id, note, metadata)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, JSON_OBJECT())`,
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, JSON_OBJECT('actorType', ?))`,
|
||||
[input.tenantId, taskId, from, to, action, input.userId,
|
||||
`${input.traceId}-${taskId}`.slice(0, 128), note.slice(0, 512)]
|
||||
`${input.traceId}-${taskId}`.slice(0, 128), note.slice(0, 512),
|
||||
input.actorType ?? 'USER']
|
||||
);
|
||||
}
|
||||
|
||||
private async recordSettlementEventWithConnection(
|
||||
connection: PoolConnection,
|
||||
input: CleaningActor,
|
||||
settlementId: string,
|
||||
from: CleaningSettlementStatus | null,
|
||||
to: CleaningSettlementStatus,
|
||||
action: string,
|
||||
note: string,
|
||||
metadata: Record<string, unknown> = {}
|
||||
) {
|
||||
await connection.execute(
|
||||
`INSERT INTO qipai_cleaning_settlement_events
|
||||
(tenant_id, settlement_id, from_status, to_status, action,
|
||||
actor_id, actor_type, trace_id, note, metadata)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON))`,
|
||||
[input.tenantId, settlementId, from, to, action.slice(0, 32), input.userId,
|
||||
input.actorType ?? 'USER', input.traceId.slice(0, 64), note.slice(0, 512),
|
||||
JSON.stringify(metadata)]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1753,8 +2117,11 @@ export class CleaningTaskRepository {
|
||||
s.store_id AS storeId, st.name AS storeName, s.status,
|
||||
s.task_count AS taskCount, s.total_reward_cents AS totalRewardCents,
|
||||
s.period_start AS periodStart, s.period_end AS periodEnd,
|
||||
s.paid_by AS paidBy, s.confirmed_at AS confirmedAt, s.paid_at AS paidAt,
|
||||
s.payout_channel AS payoutChannel, s.payout_reference AS payoutReference,
|
||||
s.paid_by AS paidBy, s.cancelled_by AS cancelledBy,
|
||||
s.confirmed_at AS confirmedAt, s.paid_at AS paidAt,
|
||||
s.cancelled_at AS cancelledAt,
|
||||
s.payout_channel AS payoutChannel, s.payout_request_no AS payoutRequestNo,
|
||||
s.payout_reference AS payoutReference,
|
||||
s.payout_state AS payoutState, s.payout_package_info AS payoutPackageInfo,
|
||||
s.payout_error AS payoutError,
|
||||
s.note, s.created_at AS createdAt
|
||||
@@ -1775,8 +2142,11 @@ export class CleaningTaskRepository {
|
||||
s.store_id AS storeId, st.name AS storeName, s.status,
|
||||
s.task_count AS taskCount, s.total_reward_cents AS totalRewardCents,
|
||||
s.period_start AS periodStart, s.period_end AS periodEnd,
|
||||
s.paid_by AS paidBy, s.confirmed_at AS confirmedAt, s.paid_at AS paidAt,
|
||||
s.payout_channel AS payoutChannel, s.payout_reference AS payoutReference,
|
||||
s.paid_by AS paidBy, s.cancelled_by AS cancelledBy,
|
||||
s.confirmed_at AS confirmedAt, s.paid_at AS paidAt,
|
||||
s.cancelled_at AS cancelledAt,
|
||||
s.payout_channel AS payoutChannel, s.payout_request_no AS payoutRequestNo,
|
||||
s.payout_reference AS payoutReference,
|
||||
s.payout_state AS payoutState, s.payout_package_info AS payoutPackageInfo,
|
||||
s.payout_error AS payoutError,
|
||||
s.note, s.created_at AS createdAt
|
||||
@@ -1899,6 +2269,7 @@ function publicTaskEvent(row: CleaningTaskEventRow) {
|
||||
toStatus: row.toStatus,
|
||||
action: row.action,
|
||||
actorId: String(row.actorId),
|
||||
actorType: row.actorType,
|
||||
traceId: row.traceId,
|
||||
note: row.note,
|
||||
createdAt: row.createdAt
|
||||
@@ -1935,9 +2306,12 @@ function publicSettlement(row: SettlementRow) {
|
||||
periodStart: row.periodStart,
|
||||
periodEnd: row.periodEnd,
|
||||
paidBy: row.paidBy === null ? null : String(row.paidBy),
|
||||
cancelledBy: row.cancelledBy === null ? null : String(row.cancelledBy),
|
||||
confirmedAt: row.confirmedAt,
|
||||
paidAt: row.paidAt,
|
||||
cancelledAt: row.cancelledAt,
|
||||
payoutChannel: row.payoutChannel,
|
||||
payoutRequestNo: row.payoutRequestNo,
|
||||
payoutReference: row.payoutReference,
|
||||
payoutState: row.payoutState,
|
||||
payoutPackageInfo: row.payoutPackageInfo,
|
||||
@@ -1960,11 +2334,38 @@ function publicSettlementItem(row: SettlementItemRow) {
|
||||
cleanerUserId: String(row.cleanerUserId),
|
||||
cleanerName: row.cleanerName,
|
||||
rewardCents: Number(row.rewardCents),
|
||||
reversedAt: row.reversedAt,
|
||||
completedAt: row.completedAt,
|
||||
createdAt: row.createdAt
|
||||
};
|
||||
}
|
||||
|
||||
function publicSettlementEvent(row: SettlementEventRow) {
|
||||
return {
|
||||
id: String(row.id),
|
||||
settlementId: String(row.settlementId),
|
||||
fromStatus: row.fromStatus,
|
||||
toStatus: row.toStatus,
|
||||
action: row.action,
|
||||
actorId: String(row.actorId),
|
||||
traceId: row.traceId,
|
||||
note: row.note,
|
||||
createdAt: row.createdAt
|
||||
};
|
||||
}
|
||||
|
||||
function publicSettlementReversal(row: SettlementReversalRow) {
|
||||
return {
|
||||
id: String(row.id),
|
||||
settlementId: String(row.settlementId),
|
||||
reversalNo: row.reversalNo,
|
||||
amountDeltaCents: Number(row.amountDeltaCents),
|
||||
reason: row.reason,
|
||||
reversedBy: String(row.reversedBy),
|
||||
createdAt: row.createdAt
|
||||
};
|
||||
}
|
||||
|
||||
function publicManagerStatistics(
|
||||
statusRows: ManagerStatusStatsRow[],
|
||||
storeRows: ManagerStoreStatsRow[],
|
||||
@@ -1992,6 +2393,9 @@ function publicManagerStatistics(
|
||||
completed: countStatus(byStatus, 'COMPLETED') + countStatus(byStatus, 'SETTLED'),
|
||||
rewardCents: byStatus.reduce((sum, row) => sum + row.rewardCents, 0),
|
||||
pendingSettlementCents: memberRows.reduce((sum, row) => sum + Number(row.pendingSettlementCents ?? 0), 0),
|
||||
draftSettlementCents: settlementByStatus
|
||||
.filter((row) => row.status === 'DRAFT')
|
||||
.reduce((sum, row) => sum + row.rewardCents, 0),
|
||||
confirmedSettlementCents: settlementByStatus
|
||||
.filter((row) => row.status === 'CONFIRMED')
|
||||
.reduce((sum, row) => sum + row.rewardCents, 0),
|
||||
@@ -2057,6 +2461,17 @@ function maxDate(values: Array<Date | null>) {
|
||||
return new Date(Math.max(...dates.map((date) => date.getTime())));
|
||||
}
|
||||
|
||||
function isMySqlDuplicate(error: unknown) {
|
||||
return typeof error === 'object' && error !== null
|
||||
&& Reflect.get(error, 'code') === 'ER_DUP_ENTRY';
|
||||
}
|
||||
|
||||
function isMySqlSettlementContention(error: unknown) {
|
||||
if (isMySqlDuplicate(error)) return true;
|
||||
if (typeof error !== 'object' || error === null) return false;
|
||||
return ['ER_LOCK_DEADLOCK', 'ER_LOCK_WAIT_TIMEOUT'].includes(String(Reflect.get(error, 'code')));
|
||||
}
|
||||
|
||||
function parseJsonArray(value: string | string[] | null): string[] {
|
||||
if (Array.isArray(value)) return value.map(String);
|
||||
if (!value) return [];
|
||||
|
||||
@@ -54,7 +54,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
||||
'database/migrations/2026081002_m08d_content_asset_scope.up.sql',
|
||||
'database/migrations/2026081003_m08d_franchise_leads.up.sql',
|
||||
'database/migrations/2026081004_m08d_admin_password_auth.up.sql',
|
||||
'database/migrations/2026081005_m09b_cleaning_rules.up.sql'
|
||||
'database/migrations/2026081005_m09b_cleaning_rules.up.sql',
|
||||
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.up.sql'
|
||||
],
|
||||
verify: [
|
||||
'database/migrations/2026061601_m01b_core_schema.verify.sql',
|
||||
@@ -90,9 +91,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
||||
'database/migrations/2026081002_m08d_content_asset_scope.verify.sql',
|
||||
'database/migrations/2026081003_m08d_franchise_leads.verify.sql',
|
||||
'database/migrations/2026081004_m08d_admin_password_auth.verify.sql',
|
||||
'database/migrations/2026081005_m09b_cleaning_rules.verify.sql'
|
||||
'database/migrations/2026081005_m09b_cleaning_rules.verify.sql',
|
||||
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.verify.sql'
|
||||
],
|
||||
down: [
|
||||
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.down.sql',
|
||||
'database/migrations/2026081005_m09b_cleaning_rules.down.sql',
|
||||
'database/migrations/2026081004_m08d_admin_password_auth.down.sql',
|
||||
'database/migrations/2026081003_m08d_franchise_leads.down.sql',
|
||||
@@ -274,7 +277,9 @@ export async function executeMigrationPlan(
|
||||
2, 1, 1,
|
||||
1, 1, 1,
|
||||
1, 2, 3, 1,
|
||||
1, 2, 3, 1
|
||||
1, 2, 3, 1,
|
||||
3, 7, 3, 1,
|
||||
2, 8, 4, 1
|
||||
][index] ?? 1;
|
||||
if (!Array.isArray(result) || result.length < minimumRows) {
|
||||
throw new Error(
|
||||
|
||||
@@ -84,6 +84,7 @@ const settlementListSchema = z.object({
|
||||
storeId: z.string().regex(/^[1-9]\d{0,19}$/).optional(),
|
||||
cleanerUserId: z.string().regex(/^[1-9]\d{0,19}$/).optional()
|
||||
}).strict();
|
||||
const settlementExportSchema = settlementListSchema.omit({ page: true, pageSize: true });
|
||||
const settlementParamsSchema = z.object({ settlementId: z.string().regex(/^[1-9]\d{0,19}$/) });
|
||||
const settlementGenerateSchema = z.object({
|
||||
cleanerUserId: z.string().regex(/^[1-9]\d{0,19}$/),
|
||||
@@ -93,6 +94,9 @@ const settlementGenerateSchema = z.object({
|
||||
const settlementConfirmSchema = z.object({
|
||||
note: z.string().trim().max(512).optional()
|
||||
}).strict();
|
||||
const settlementCancelSchema = z.object({
|
||||
reason: z.string().trim().min(1).max(512)
|
||||
}).strict();
|
||||
const settlementPaidSchema = z.object({
|
||||
payoutChannel: z.string().trim().min(1).max(32),
|
||||
payoutReference: z.string().trim().min(1).max(128),
|
||||
@@ -129,7 +133,7 @@ export interface CleaningRouteOptions {
|
||||
repository: Pick<CleaningTaskRepository,
|
||||
'listHall' | 'listMine' | 'listManage' | 'claim' | 'start' | 'rework' | 'submit'
|
||||
| 'assign' | 'complete' | 'reject' | 'exempt' | 'listMembers' | 'listEvents' | 'listSubmissions' | 'addMember' | 'removeMember' | 'settlementCandidates'
|
||||
| 'listSettlements' | 'getSettlementDetail' | 'generateSettlement' | 'confirmSettlement' | 'markSettlementPaid'
|
||||
| 'listSettlements' | 'exportSettlements' | 'getSettlementDetail' | 'generateSettlement' | 'confirmSettlement' | 'cancelSettlement' | 'markSettlementPaid'
|
||||
| 'recordSettlementPayoutFailure' | 'reclaimTimeouts'
|
||||
| 'assertCanUploadPhoto' | 'recordPhotoUpload' | 'claimExpiredPhotoUploads' | 'markPhotoUploadsDeleted'
|
||||
| 'listTemplates' | 'upsertTemplate'
|
||||
@@ -269,6 +273,18 @@ export async function registerCleaningRoutes(
|
||||
}));
|
||||
});
|
||||
|
||||
app.get('/admin-api/cleaning/settlements/export', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'read');
|
||||
if (!actor) return;
|
||||
const query = settlementExportSchema.safeParse(request.query);
|
||||
if (!query.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.exportSettlements({ ...actor, ...query.data }),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.get('/admin-api/cleaning/settlements/:settlementId', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'read');
|
||||
if (!actor) return;
|
||||
@@ -607,6 +623,23 @@ export async function registerCleaningRoutes(
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/admin-api/cleaning/settlements/:settlementId/cancel', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
if (!actor) return;
|
||||
const params = settlementParamsSchema.safeParse(request.params);
|
||||
const body = settlementCancelSchema.safeParse(request.body ?? {});
|
||||
if (!params.success || !body.success) return invalid(reply, request.traceId);
|
||||
return handle(reply, request.traceId, async () => ({
|
||||
code: 0,
|
||||
data: await options.repository.cancelSettlement({
|
||||
...actor,
|
||||
settlementId: params.data.settlementId,
|
||||
reason: body.data.reason
|
||||
}),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
});
|
||||
|
||||
app.post('/admin-api/cleaning/settlements/:settlementId/paid', async (request, reply) => {
|
||||
const actor = await requireActor(request, reply, options, 'write');
|
||||
if (!actor) return;
|
||||
@@ -620,7 +653,8 @@ export async function registerCleaningRoutes(
|
||||
settlementId: params.data.settlementId,
|
||||
payoutChannel: body.data.payoutChannel,
|
||||
payoutReference: body.data.payoutReference,
|
||||
note: body.data.note
|
||||
note: body.data.note,
|
||||
manualOverride: true
|
||||
}),
|
||||
traceId: request.traceId
|
||||
}));
|
||||
|
||||
@@ -60,6 +60,8 @@ const actor = {
|
||||
mode: 'API'
|
||||
});
|
||||
assert.equal(result.transferState, 'SUCCESS');
|
||||
assert.equal(harness.repository.begun.length, 1);
|
||||
assert.equal(harness.repository.begun[0].payoutReference, 'CLP501');
|
||||
assert.equal(harness.repository.paid[0].payoutChannel, 'WECHAT_TRANSFER');
|
||||
assert.equal(harness.state.transferInput.amountCents, 1200);
|
||||
assert.equal(harness.state.transferInput.openid, 'openid-cleaner');
|
||||
@@ -79,6 +81,20 @@ const actor = {
|
||||
assert.equal(harness.repository.paid.length, 0);
|
||||
}
|
||||
|
||||
{
|
||||
const harness = createHarness({ payoutState: 'PROCESSING' });
|
||||
await assert.rejects(
|
||||
() => harness.service.executeWechatTransfer({
|
||||
...actor,
|
||||
settlementId: '501',
|
||||
mode: 'API'
|
||||
}),
|
||||
(error) => error instanceof CleaningPayoutError
|
||||
&& error.code === 'CLEANING_PAYOUT_ALREADY_STARTED'
|
||||
);
|
||||
assert.equal(harness.repository.begun.length, 0);
|
||||
}
|
||||
|
||||
{
|
||||
const harness = createHarness({ queryState: 'SUCCESS' });
|
||||
const result = await harness.service.syncWechatTransfer({
|
||||
@@ -87,7 +103,7 @@ const actor = {
|
||||
note: 'poll success'
|
||||
});
|
||||
assert.equal(result.transferState, 'SUCCESS');
|
||||
assert.equal(harness.state.queryOutBillNo, 'CLS-20260627-501');
|
||||
assert.equal(harness.state.queryOutBillNo, 'CLP501');
|
||||
assert.equal(harness.repository.paid[0].payoutReference, 'wx-transfer-query-501');
|
||||
}
|
||||
|
||||
@@ -115,7 +131,7 @@ const actor = {
|
||||
const harness = createHarness({
|
||||
notificationPayload: {
|
||||
mch_id: '1900000109',
|
||||
out_bill_no: 'CLS-20260627-501',
|
||||
out_bill_no: 'CLP501',
|
||||
transfer_bill_no: 'wx-notify-501',
|
||||
state: 'SUCCESS'
|
||||
}
|
||||
@@ -132,6 +148,27 @@ const actor = {
|
||||
assert.equal(harness.repository.paid[0].payoutReference, 'wx-notify-501');
|
||||
}
|
||||
|
||||
{
|
||||
const harness = createHarness({
|
||||
status: 'PAID',
|
||||
payoutState: 'SUCCESS',
|
||||
notificationPayload: {
|
||||
mch_id: '1900000109',
|
||||
out_bill_no: 'CLP501',
|
||||
transfer_bill_no: 'wx-notify-501',
|
||||
state: 'SUCCESS'
|
||||
}
|
||||
});
|
||||
const result = await harness.service.processWechatTransferNotification({
|
||||
timestamp: '1782700000',
|
||||
nonce: 'notify-nonce',
|
||||
serial: 'PLATFORM-SERIAL',
|
||||
signature: 'signature'
|
||||
}, '{"resource":"encrypted"}', 'notify-replay-trace');
|
||||
assert.equal(result.idempotent, true);
|
||||
assert.equal(harness.repository.paid.length, 0);
|
||||
}
|
||||
|
||||
{
|
||||
const harness = createHarness({ transferState: 'FAIL', failReason: 'REAL_NAME_CHECK_FAILED' });
|
||||
const result = await harness.service.executeWechatTransfer({
|
||||
@@ -175,12 +212,14 @@ function createHarness(options = {}) {
|
||||
id: '501',
|
||||
settlementNo: 'CLS-20260627-501',
|
||||
cleanerUserId: '31',
|
||||
generatedBy: '21',
|
||||
storeId: '11',
|
||||
status: options.status ?? 'CONFIRMED',
|
||||
totalRewardCents: 1200,
|
||||
payoutChannel: options.payoutChannel ?? 'WECHAT_TRANSFER',
|
||||
payoutReference: options.payoutReference ?? 'CLS-20260627-501',
|
||||
payoutState: options.payoutState ?? 'WAIT_USER_CONFIRM',
|
||||
payoutRequestNo: options.payoutRequestNo ?? 'CLP501',
|
||||
payoutReference: options.payoutReference ?? 'CLP501',
|
||||
payoutState: options.payoutState ?? '',
|
||||
payoutPackageInfo: options.payoutPackageInfo ?? 'package-info'
|
||||
},
|
||||
account: {
|
||||
@@ -210,9 +249,17 @@ function createHarness(options = {}) {
|
||||
}
|
||||
};
|
||||
const repository = {
|
||||
begun: [],
|
||||
paid: [],
|
||||
failures: [],
|
||||
pending: [],
|
||||
async beginSettlementPayout(input) {
|
||||
this.begun.push(input);
|
||||
state.settlement.payoutChannel = input.payoutChannel;
|
||||
state.settlement.payoutReference = input.payoutReference;
|
||||
state.settlement.payoutState = 'PROCESSING';
|
||||
return { ...state.settlement };
|
||||
},
|
||||
async markSettlementPaid(input) {
|
||||
this.paid.push(input);
|
||||
return { ...state.settlement, status: 'PAID', payoutReference: input.payoutReference };
|
||||
|
||||
@@ -160,11 +160,21 @@ const app = await buildApp({
|
||||
calls.push(['listSettlements', input]);
|
||||
return { items: [settlementResponse(input.status || 'DRAFT')], total: 1, page: input.page, pageSize: input.pageSize };
|
||||
},
|
||||
async exportSettlements(input) {
|
||||
calls.push(['exportSettlements', input]);
|
||||
return { items: [settlementResponse(input.status || 'DRAFT')], total: 1, exported: 1, truncated: false };
|
||||
},
|
||||
async getSettlementDetail(input) {
|
||||
calls.push(['getSettlementDetail', input]);
|
||||
return {
|
||||
settlement: settlementResponse('DRAFT'),
|
||||
items: [settlementItemResponse()]
|
||||
items: [settlementItemResponse()],
|
||||
events: [{
|
||||
id: '701', settlementId: input.settlementId, fromStatus: null, toStatus: 'DRAFT',
|
||||
action: 'GENERATE', actorId: input.userId, traceId: input.traceId,
|
||||
note: '', createdAt: new Date()
|
||||
}],
|
||||
reversals: []
|
||||
};
|
||||
},
|
||||
async generateSettlement(input) {
|
||||
@@ -175,6 +185,15 @@ const app = await buildApp({
|
||||
calls.push(['confirmSettlement', input]);
|
||||
return settlementResponse('CONFIRMED');
|
||||
},
|
||||
async cancelSettlement(input) {
|
||||
calls.push(['cancelSettlement', input]);
|
||||
return {
|
||||
...settlementResponse('CANCELLED'),
|
||||
cancelledBy: input.userId,
|
||||
cancelledAt: new Date(),
|
||||
note: input.reason
|
||||
};
|
||||
},
|
||||
async markSettlementPaid(input) {
|
||||
calls.push(['markSettlementPaid', input]);
|
||||
return {
|
||||
@@ -385,6 +404,16 @@ assert.equal(calls.at(-1)[1].status, 'SUBMITTED');
|
||||
assert.equal(calls.at(-1)[1].storeId, '11');
|
||||
assert.equal(calls.at(-1)[1].cleanerUserId, '31');
|
||||
|
||||
const settlementExport = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/admin-api/cleaning/settlements/export?status=DRAFT&payoutState=FAIL&storeId=11&cleanerUserId=31',
|
||||
headers: { authorization: `Bearer ${token}` }
|
||||
});
|
||||
assert.equal(settlementExport.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'exportSettlements');
|
||||
assert.equal(calls.at(-1)[1].status, 'DRAFT');
|
||||
assert.equal(settlementExport.json().data.exported, 1);
|
||||
|
||||
const appManageList = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/app-api/management/cleaning/tasks?status=SUBMITTED&storeId=11',
|
||||
@@ -643,6 +672,17 @@ assert.equal(confirmedSettlement.statusCode, 200);
|
||||
assert.equal(calls.at(-1)[0], 'confirmSettlement');
|
||||
assert.equal(calls.at(-1)[1].settlementId, '501');
|
||||
|
||||
const cancelledSettlement = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/cleaning/settlements/501/cancel',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { reason: 'duplicate weekly batch' }
|
||||
});
|
||||
assert.equal(cancelledSettlement.statusCode, 200);
|
||||
assert.equal(cancelledSettlement.json().data.status, 'CANCELLED');
|
||||
assert.equal(calls.at(-1)[0], 'cancelSettlement');
|
||||
assert.equal(calls.at(-1)[1].reason, 'duplicate weekly batch');
|
||||
|
||||
const failedPayout = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/admin-api/cleaning/settlements/501/payout-failure',
|
||||
|
||||
@@ -111,6 +111,15 @@ const adminAuthVerifySql = read('database/migrations/2026081004_m08d_admin_passw
|
||||
const cleaningRulesUpSql = read('database/migrations/2026081005_m09b_cleaning_rules.up.sql');
|
||||
const cleaningRulesDownSql = read('database/migrations/2026081005_m09b_cleaning_rules.down.sql');
|
||||
const cleaningRulesVerifySql = read('database/migrations/2026081005_m09b_cleaning_rules.verify.sql');
|
||||
const cleaningSettlementIntegrityUpSql = read(
|
||||
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.up.sql'
|
||||
);
|
||||
const cleaningSettlementIntegrityDownSql = read(
|
||||
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.down.sql'
|
||||
);
|
||||
const cleaningSettlementIntegrityVerifySql = read(
|
||||
'database/migrations/2026081006_m09c_cleaning_settlement_integrity.verify.sql'
|
||||
);
|
||||
|
||||
const coreTables = [
|
||||
'qipai_schema_migrations',
|
||||
@@ -507,7 +516,24 @@ assert.match(cleaningRulesUpSql, /uq_qipai_cleaning_submission_revision/);
|
||||
assert.match(cleaningRulesUpSql, /retention_until DATETIME\(3\) NOT NULL/);
|
||||
assert.match(cleaningRulesDownSql, /DROP FOREIGN KEY fk_qipai_cleaning_tasks_template/);
|
||||
assert.match(cleaningRulesVerifySql, /'2026081005'/);
|
||||
for (const table of [
|
||||
'qipai_cleaning_settlement_events', 'qipai_cleaning_settlement_reversals'
|
||||
]) {
|
||||
assert.match(cleaningSettlementIntegrityUpSql, new RegExp('CREATE TABLE IF NOT EXISTS ' + table));
|
||||
assert.match(cleaningSettlementIntegrityDownSql, new RegExp('DROP TABLE IF EXISTS ' + table));
|
||||
assert.match(cleaningSettlementIntegrityVerifySql, new RegExp("'" + table + "'"));
|
||||
}
|
||||
assert.match(cleaningSettlementIntegrityUpSql, /amount_delta_cents BIGINT NOT NULL/);
|
||||
assert.match(cleaningSettlementIntegrityUpSql, /ADD COLUMN cancelled_by/);
|
||||
assert.match(cleaningSettlementIntegrityUpSql, /ADD COLUMN cancelled_at/);
|
||||
assert.match(cleaningSettlementIntegrityUpSql, /ADD COLUMN payout_request_no/);
|
||||
assert.match(cleaningSettlementIntegrityUpSql, /GENERATED ALWAYS AS/);
|
||||
assert.match(cleaningSettlementIntegrityUpSql, /uq_qipai_cleaning_settlement_active_share/);
|
||||
assert.match(cleaningSettlementIntegrityUpSql, /uq_qipai_cleaning_settlement_payout_request/);
|
||||
assert.match(cleaningSettlementIntegrityDownSql, /DROP FOREIGN KEY fk_qipai_cleaning_settlement_items_reversal/);
|
||||
assert.match(cleaningSettlementIntegrityDownSql, /ADD UNIQUE KEY uq_qipai_cleaning_settlement_item_task_user/);
|
||||
assert.match(cleaningSettlementIntegrityVerifySql, /'2026081006'/);
|
||||
assert.match(franchiseDownSql, /DROP TABLE IF EXISTS qipai_franchise_follow_ups/);
|
||||
assert.match(franchiseVerifySql, /idx_qipai_franchise_follow_up_history/);
|
||||
|
||||
console.log('PASS: M01-B through M09-B migration contracts are present.');
|
||||
console.log('PASS: M01-B through M09-C migration contracts are present.');
|
||||
|
||||
@@ -44,7 +44,8 @@ assert.match(plan.file, /2026081001_m08c_staff_management_access\.up\.sql/);
|
||||
assert.match(plan.file, /2026081002_m08d_content_asset_scope\.up\.sql/);
|
||||
assert.match(plan.file, /2026081003_m08d_franchise_leads\.up\.sql/);
|
||||
assert.match(plan.file, /2026081004_m08d_admin_password_auth\.up\.sql/);
|
||||
assert.match(plan.file, /2026081005_m09b_cleaning_rules\.up\.sql$/);
|
||||
assert.match(plan.file, /2026081005_m09b_cleaning_rules\.up\.sql/);
|
||||
assert.match(plan.file, /2026081006_m09c_cleaning_settlement_integrity\.up\.sql$/);
|
||||
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
|
||||
assert.ok(plan.statements.length >= 11);
|
||||
|
||||
@@ -53,7 +54,15 @@ assert.match(verifyPlan.statements[90], /^SELECT column_name/);
|
||||
assert.match(verifyPlan.statements[91], /^SELECT index_name/);
|
||||
assert.match(verifyPlan.file, /2026081003_m08d_franchise_leads\.verify\.sql/);
|
||||
assert.match(verifyPlan.file, /2026081004_m08d_admin_password_auth\.verify\.sql/);
|
||||
assert.match(verifyPlan.file, /2026081005_m09b_cleaning_rules\.verify\.sql$/);
|
||||
assert.match(verifyPlan.file, /2026081005_m09b_cleaning_rules\.verify\.sql/);
|
||||
assert.match(verifyPlan.file, /2026081006_m09c_cleaning_settlement_integrity\.verify\.sql$/);
|
||||
|
||||
const downPlan = await loadMigrationPlan('down');
|
||||
assert.match(downPlan.file, /2026081006_m09c_cleaning_settlement_integrity\.down\.sql/);
|
||||
assert.ok(
|
||||
downPlan.file.indexOf('2026081006_m09c_cleaning_settlement_integrity.down.sql')
|
||||
< downPlan.file.indexOf('2026081005_m09b_cleaning_rules.down.sql')
|
||||
);
|
||||
|
||||
const calls = [];
|
||||
const fakePool = {
|
||||
|
||||
@@ -60,6 +60,8 @@ const expectedTables = [
|
||||
'qipai_async_tasks',
|
||||
'qipai_audit_logs',
|
||||
'qipai_auth_sessions',
|
||||
'qipai_cleaning_settlement_events',
|
||||
'qipai_cleaning_settlement_reversals',
|
||||
'qipai_cleaning_task_photos',
|
||||
'qipai_cleaning_task_submissions',
|
||||
'qipai_cleaning_templates',
|
||||
@@ -142,13 +144,14 @@ async function readMigrationVersions(pool) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT version, name
|
||||
FROM qipai_schema_migrations
|
||||
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ORDER BY version`,
|
||||
['2026061601', '2026061802', '2026061803', '2026061804',
|
||||
'2026061805', '2026061806', '2026061807', '2026061808', '2026061809',
|
||||
'2026061810', '2026061811', '2026062012', '2026062013', '2026062014',
|
||||
'2026062015', '2026062216', '2026062217', '2026062218', '2026062219',
|
||||
'2026062220', '2026081002', '2026081003', '2026081004', '2026081005']
|
||||
'2026062220', '2026081002', '2026081003', '2026081004', '2026081005',
|
||||
'2026081006']
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
@@ -1836,7 +1839,7 @@ async function assertSystemOperations(pool, context) {
|
||||
assert.equal(logs.items[0].metadata.nested.safe, 'visible');
|
||||
const overview = await repository.getSystemOverview(context.tenantId);
|
||||
assert.equal(overview.tenant.id, context.tenantId);
|
||||
assert.equal(overview.latestMigration.version, '2026081005');
|
||||
assert.equal(overview.latestMigration.version, '2026081006');
|
||||
assert.ok(overview.counts.userCount > 0);
|
||||
await repository.updateTenant(actor, context.tenantId, {
|
||||
name: overview.tenant.name, timezone: overview.tenant.timezone
|
||||
@@ -2957,8 +2960,163 @@ async function assertCleaningTaskTransactions(pool, context) {
|
||||
exemptPolicy: snapshotRows[0].exemptPolicy
|
||||
}, { version: 1, minPhotoCount: 2, exemptPolicy: 'BEFORE_START' });
|
||||
|
||||
const collaborativeTaskId = await insertTask({
|
||||
status: 'COMPLETED', cleanerUserId: firstCleanerId, rewardCents: 100,
|
||||
claimedAt: new Date(), completedAt: new Date()
|
||||
});
|
||||
await insertMember({
|
||||
taskId: collaborativeTaskId, userId: firstCleanerId, memberRole: 'LEAD', rewardCents: 70
|
||||
});
|
||||
await insertMember({
|
||||
taskId: collaborativeTaskId, userId: secondCleanerId, memberRole: 'ASSIST', rewardCents: 30
|
||||
});
|
||||
|
||||
const concurrentSettlementResults = await Promise.allSettled([
|
||||
repository.generateSettlement({
|
||||
...managerActor('m09c-generate-one'), cleanerUserId: firstCleanerId, storeId
|
||||
}),
|
||||
repository.generateSettlement({
|
||||
...managerActor('m09c-generate-two'), cleanerUserId: firstCleanerId, storeId
|
||||
})
|
||||
]);
|
||||
assert.equal(
|
||||
concurrentSettlementResults.filter((result) => result.status === 'fulfilled').length,
|
||||
1
|
||||
);
|
||||
const rejectedSettlement = concurrentSettlementResults.find((result) => result.status === 'rejected');
|
||||
assert.ok(rejectedSettlement?.reason instanceof CleaningTaskError);
|
||||
assert.ok([
|
||||
'CLEANING_SETTLEMENT_EMPTY', 'CLEANING_SETTLEMENT_CONCURRENTLY_CLAIMED'
|
||||
].includes(rejectedSettlement.reason.code));
|
||||
const leadDraft = concurrentSettlementResults.find((result) => result.status === 'fulfilled').value;
|
||||
const assistDraft = await repository.generateSettlement({
|
||||
...managerActor('m09c-generate-assist'), cleanerUserId: secondCleanerId, storeId
|
||||
});
|
||||
|
||||
const [activeShareRows] = await pool.query(
|
||||
`SELECT task_id AS taskId, cleaner_user_id AS cleanerUserId, COUNT(*) AS total
|
||||
FROM qipai_cleaning_settlement_items
|
||||
WHERE tenant_id = ? AND reversed_at IS NULL
|
||||
AND task_id IN (?, ?)
|
||||
GROUP BY task_id, cleaner_user_id`,
|
||||
[context.tenantId, lifecycleTaskId, collaborativeTaskId]
|
||||
);
|
||||
assert.ok(activeShareRows.length >= 3);
|
||||
assert.ok(activeShareRows.every((row) => Number(row.total) === 1));
|
||||
const [draftTaskRows] = await pool.query(
|
||||
`SELECT id, status, settled_at AS settledAt
|
||||
FROM qipai_cleaning_tasks WHERE tenant_id = ? AND id IN (?, ?) ORDER BY id`,
|
||||
[context.tenantId, lifecycleTaskId, collaborativeTaskId]
|
||||
);
|
||||
assert.ok(draftTaskRows.every((row) => row.status === 'COMPLETED' && row.settledAt === null));
|
||||
const firstStatsInDraft = await repository.stats({
|
||||
...cleanerActor(firstCleanerId, 'm09c-stats-draft')
|
||||
});
|
||||
assert.ok(firstStatsInDraft.pendingSettlementCents >= 170);
|
||||
assert.equal(firstStatsInDraft.settledRewardCents, 0);
|
||||
await assert.rejects(
|
||||
() => repository.removeMember({
|
||||
...managerActor('m09c-member-locked'), taskId: collaborativeTaskId,
|
||||
cleanerUserId: secondCleanerId
|
||||
}),
|
||||
(error) => error instanceof CleaningTaskError
|
||||
&& error.code === 'CLEANING_MEMBER_SETTLEMENT_LOCKED'
|
||||
);
|
||||
|
||||
const cancelledLead = await repository.cancelSettlement({
|
||||
...managerActor('m09c-cancel-draft'), settlementId: leadDraft.id,
|
||||
reason: 'M09-C cancellation roundtrip'
|
||||
});
|
||||
assert.equal(cancelledLead.status, 'CANCELLED');
|
||||
const cancelledDetail = await repository.getSettlementDetail({
|
||||
...managerActor('m09c-cancel-detail'), settlementId: leadDraft.id
|
||||
});
|
||||
assert.equal(cancelledDetail.reversals.length, 1);
|
||||
assert.equal(cancelledDetail.reversals[0].amountDeltaCents, -leadDraft.totalRewardCents);
|
||||
assert.ok(cancelledDetail.items.every((item) => item.reversedAt instanceof Date));
|
||||
assert.ok(cancelledDetail.events.some((event) => event.action === 'CANCEL'));
|
||||
|
||||
const retryLead = await repository.generateSettlement({
|
||||
...managerActor('m09c-generate-retry'), cleanerUserId: firstCleanerId, storeId
|
||||
});
|
||||
await repository.confirmSettlement({
|
||||
...managerActor('m09c-confirm-retry'), settlementId: retryLead.id
|
||||
});
|
||||
await repository.beginSettlementPayout({
|
||||
...managerActor('m09c-payout-begin'), settlementId: retryLead.id,
|
||||
payoutChannel: 'WECHAT_TRANSFER', payoutReference: `CLP${retryLead.id}`
|
||||
});
|
||||
await assert.rejects(
|
||||
() => repository.cancelSettlement({
|
||||
...managerActor('m09c-cancel-processing'), settlementId: retryLead.id,
|
||||
reason: 'must not cancel uncertain transfer'
|
||||
}),
|
||||
(error) => error instanceof CleaningTaskError
|
||||
&& error.code === 'CLEANING_SETTLEMENT_PAYOUT_PENDING'
|
||||
);
|
||||
await repository.recordSettlementPayoutFailure({
|
||||
...managerActor('m09c-payout-explicit-fail'), settlementId: retryLead.id,
|
||||
payoutChannel: 'WECHAT_TRANSFER', payoutReference: `CLP${retryLead.id}`,
|
||||
error: 'PROVIDER_CONFIRMED_FAIL', providerConfirmed: true
|
||||
});
|
||||
await repository.cancelSettlement({
|
||||
...managerActor('m09c-cancel-failed'), settlementId: retryLead.id,
|
||||
reason: 'provider confirmed failure'
|
||||
});
|
||||
|
||||
const paidLead = await repository.generateSettlement({
|
||||
...managerActor('m09c-generate-paid'), cleanerUserId: firstCleanerId, storeId
|
||||
});
|
||||
await repository.confirmSettlement({
|
||||
...managerActor('m09c-confirm-paid'), settlementId: paidLead.id
|
||||
});
|
||||
await repository.markSettlementPaid({
|
||||
...managerActor('m09c-manual-paid-lead'), settlementId: paidLead.id,
|
||||
payoutChannel: 'MANUAL', payoutReference: `MANUAL-${paidLead.id}`,
|
||||
manualOverride: true
|
||||
});
|
||||
const [partiallyPaidRows] = await pool.query(
|
||||
`SELECT status FROM qipai_cleaning_tasks WHERE tenant_id = ? AND id = ?`,
|
||||
[context.tenantId, collaborativeTaskId]
|
||||
);
|
||||
assert.equal(partiallyPaidRows[0].status, 'COMPLETED');
|
||||
|
||||
await repository.confirmSettlement({
|
||||
...managerActor('m09c-confirm-assist'), settlementId: assistDraft.id
|
||||
});
|
||||
await repository.markSettlementPaid({
|
||||
...managerActor('m09c-manual-paid-assist'), settlementId: assistDraft.id,
|
||||
payoutChannel: 'MANUAL', payoutReference: `MANUAL-${assistDraft.id}`,
|
||||
manualOverride: true
|
||||
});
|
||||
const [fullyPaidRows] = await pool.query(
|
||||
`SELECT status, settled_at IS NOT NULL AS settled
|
||||
FROM qipai_cleaning_tasks WHERE tenant_id = ? AND id = ?`,
|
||||
[context.tenantId, collaborativeTaskId]
|
||||
);
|
||||
assert.deepEqual(
|
||||
{ status: fullyPaidRows[0].status, settled: Number(fullyPaidRows[0].settled) },
|
||||
{ status: 'SETTLED', settled: 1 }
|
||||
);
|
||||
const firstStatsPaid = await repository.stats({
|
||||
...cleanerActor(firstCleanerId, 'm09c-stats-paid')
|
||||
});
|
||||
assert.ok(firstStatsPaid.settledRewardCents >= 170);
|
||||
|
||||
const paidPage = await repository.listSettlements({
|
||||
...managerActor('m09c-list-paid'), page: 1, pageSize: 50, status: 'PAID', storeId
|
||||
});
|
||||
const paidExport = await repository.exportSettlements({
|
||||
...managerActor('m09c-export-paid'), status: 'PAID', storeId
|
||||
});
|
||||
assert.deepEqual(
|
||||
paidExport.items.slice(0, paidPage.items.length).map((item) => item.id),
|
||||
paidPage.items.map((item) => item.id)
|
||||
);
|
||||
assert.equal(paidExport.total, paidPage.total);
|
||||
|
||||
console.log(
|
||||
'PASS: M09-A/M09-B cleaning transactions, template snapshots, photo revisions and exemption rules are consistent.'
|
||||
'PASS: M09-A/M09-B/M09-C cleaning transactions, rules and settlement integrity are consistent.'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3009,7 +3167,8 @@ try {
|
||||
{ version: '2026081002', name: 'm08d_content_asset_scope' },
|
||||
{ version: '2026081003', name: 'm08d_franchise_leads' },
|
||||
{ version: '2026081004', name: 'm08d_admin_password_auth' },
|
||||
{ version: '2026081005', name: 'm09b_cleaning_rules' }
|
||||
{ version: '2026081005', name: 'm09b_cleaning_rules' },
|
||||
{ version: '2026081006', name: 'm09c_cleaning_settlement_integrity' }
|
||||
]);
|
||||
await assertTaskDurability(pool);
|
||||
const loginContext = await assertPlatformTenantIsolation(pool);
|
||||
@@ -3038,7 +3197,7 @@ try {
|
||||
await executeMigrationPlan(pool, plans.down);
|
||||
assert.deepEqual(await readCoreTables(pool), []);
|
||||
await assertLegacyCompatibility(pool);
|
||||
console.log('PASS: down removed all M01-B through M09-B migration tables.');
|
||||
console.log('PASS: down removed all M01-B through M09-C migration tables.');
|
||||
|
||||
await executeMigrationPlan(pool, plans.up);
|
||||
await executeMigrationPlan(pool, plans.verify);
|
||||
@@ -3067,7 +3226,8 @@ try {
|
||||
{ version: '2026081002', name: 'm08d_content_asset_scope' },
|
||||
{ version: '2026081003', name: 'm08d_franchise_leads' },
|
||||
{ version: '2026081004', name: 'm08d_admin_password_auth' },
|
||||
{ version: '2026081005', name: 'm09b_cleaning_rules' }
|
||||
{ version: '2026081005', name: 'm09b_cleaning_rules' },
|
||||
{ version: '2026081006', name: 'm09c_cleaning_settlement_integrity' }
|
||||
]);
|
||||
await assertLegacyCompatibility(pool);
|
||||
console.log('PASS: second up and verify restored the schema.');
|
||||
@@ -3208,7 +3368,14 @@ try {
|
||||
'cleaning template snapshot remains stable after config version change',
|
||||
'rejected and accepted cleaning photo revisions remain distinguishable',
|
||||
'expired unattached cleaning photo retention cleanup',
|
||||
'task-level cleaning exemption policy denial'
|
||||
'task-level cleaning exemption policy denial',
|
||||
'concurrent cleaning settlement generation has one winner per member share',
|
||||
'draft and confirmed rewards remain pending until paid',
|
||||
'active settlement freezes member reward allocation',
|
||||
'settlement reversal preserves history and releases the share',
|
||||
'processing payout blocks cancellation until provider-confirmed failure',
|
||||
'collaborative task settles only after every member share is paid',
|
||||
'settlement page and CSV export reuse identical query ordering'
|
||||
]
|
||||
}, null, 2));
|
||||
} finally {
|
||||
|
||||
@@ -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.');
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
|
||||
Reference in New Issue
Block a user