feat(cleaning): close settlement integrity and reconciliation

This commit is contained in:
Codex
2026-08-11 02:07:59 +08:00
parent 5e0f5e39ee
commit 1f1071501f
23 changed files with 1562 additions and 208 deletions
+189 -27
View File
@@ -56,7 +56,12 @@
>
批量同步
</el-button>
<el-button :icon="Download" :disabled="items.length === 0" @click="exportSettlements">
<el-button
:icon="Download"
:disabled="total === 0"
:loading="exporting"
@click="exportSettlements"
>
导出
</el-button>
<el-button :icon="ListChecks" @click="openCandidates">
@@ -108,7 +113,7 @@
<el-table-column prop="createdAt" label="创建" width="130">
<template #default="{ row }">{{ shortDate(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="320" fixed="right">
<el-table-column label="操作" width="470" fixed="right">
<template #default="{ row }">
<div class="row-actions">
<el-button size="small" :icon="ListChecks" @click="openDetail(row)">详情</el-button>
@@ -137,6 +142,14 @@
>
转账
</el-button>
<el-button
size="small"
:icon="BadgeCheck"
:disabled="!canMarkPaid(row)"
@click="openManualPaid(row)"
>
人工已付
</el-button>
<el-button
size="small"
:icon="RefreshCw"
@@ -149,11 +162,21 @@
size="small"
type="danger"
:icon="CircleAlert"
:disabled="row.status !== 'CONFIRMED'"
:disabled="!canRecordFailure(row)"
@click="openFailure(row)"
>
失败
</el-button>
<el-button
size="small"
type="danger"
plain
:icon="CircleX"
:disabled="!canCancelSettlement(row)"
@click="openCancel(row)"
>
冲正
</el-button>
</div>
</template>
</el-table-column>
@@ -390,7 +413,7 @@
</template>
</el-dialog>
<el-dialog v-model="detailDialog.open" title="结算单详情" width="720px">
<el-dialog v-model="detailDialog.open" title="结算单详情" width="880px">
<div v-if="detailDialog.detail" class="dialog-stack">
<div class="detail-grid">
<span>单号<strong>{{ detailDialog.detail.settlement.settlementNo }}</strong></span>
@@ -416,6 +439,9 @@
<el-table-column prop="rewardCents" label="金额" width="110">
<template #default="{ row }">{{ money(row.rewardCents) }}</template>
</el-table-column>
<el-table-column label="份额状态" width="100">
<template #default="{ row }">{{ row.reversedAt ? '已冲正' : '有效' }}</template>
</el-table-column>
<el-table-column prop="completedAt" label="完成" width="130">
<template #default="{ row }">{{ shortDate(row.completedAt) }}</template>
</el-table-column>
@@ -427,6 +453,9 @@
<el-descriptions-item label="外部流水">
{{ compactText(detailDialog.detail.settlement.payoutReference) }}
</el-descriptions-item>
<el-descriptions-item label="付款请求号">
{{ compactText(detailDialog.detail.settlement.payoutRequestNo) }}
</el-descriptions-item>
<el-descriptions-item label="微信确认参数" :span="2">
{{ compactText(detailDialog.detail.settlement.payoutPackageInfo) }}
</el-descriptions-item>
@@ -436,7 +465,45 @@
<el-descriptions-item label="备注" :span="2">
{{ compactText(detailDialog.detail.settlement.note) }}
</el-descriptions-item>
<el-descriptions-item label="冲正操作人">
{{ compactText(detailDialog.detail.settlement.cancelledBy) }}
</el-descriptions-item>
<el-descriptions-item label="冲正时间">
{{ shortDate(detailDialog.detail.settlement.cancelledAt) }}
</el-descriptions-item>
</el-descriptions>
<section v-if="detailDialog.detail.reversals.length" class="detail-section">
<h4>反向记录</h4>
<el-table :data="detailDialog.detail.reversals" size="small">
<el-table-column prop="reversalNo" label="反向单号" min-width="190" />
<el-table-column label="金额(分)" width="120">
<template #default="{ row }">{{ row.amountDeltaCents }}</template>
</el-table-column>
<el-table-column prop="reason" label="原因" min-width="180" />
<el-table-column prop="reversedBy" label="操作人" width="100" />
<el-table-column prop="createdAt" label="时间" width="140">
<template #default="{ row }">{{ shortDate(row.createdAt) }}</template>
</el-table-column>
</el-table>
</section>
<section class="detail-section">
<h4>状态历史</h4>
<el-table :data="detailDialog.detail.events" size="small">
<el-table-column prop="action" label="动作" width="150" />
<el-table-column label="状态" min-width="150">
<template #default="{ row }">
{{ compactText(row.fromStatus) }} {{ row.toStatus }}
</template>
</el-table-column>
<el-table-column label="操作人" width="130">
<template #default="{ row }">{{ row.actorType }} · {{ row.actorId }}</template>
</el-table-column>
<el-table-column prop="note" label="备注" min-width="170" />
<el-table-column prop="createdAt" label="时间" width="140">
<template #default="{ row }">{{ shortDate(row.createdAt) }}</template>
</el-table-column>
</el-table>
</section>
</div>
<template #footer>
<el-button :icon="Download" :disabled="!detailDialog.detail" @click="exportSettlementDetail">
@@ -452,6 +519,7 @@ import { computed, reactive, ref, watch } from 'vue';
import {
BadgeCheck,
CircleAlert,
CircleX,
ClipboardCheck,
Download,
FilePlus2,
@@ -460,8 +528,9 @@ import {
Search,
Send
} from '@lucide/vue';
import { ElMessage } from 'element-plus';
import { ElMessage, ElMessageBox } from 'element-plus';
import {
exportCleaningSettlements,
getCleaningSettlementDetail,
listCleaningSettlementCandidates,
preflightWechatTransfer
@@ -502,6 +571,13 @@ const emit = defineEmits<{
'sync-transfer': [{ settlementId: string; note?: string }];
'sync-transfer-many': [{ settlementIds: string[]; note?: string }];
failure: [{ settlementId: string; error: string; payoutReference?: string; note?: string }];
cancel: [{ settlementId: string; reason: string }];
paid: [{
settlementId: string;
payoutChannel: string;
payoutReference: string;
note?: string;
}];
}>();
const statusOptions = [
@@ -524,6 +600,7 @@ const filterDraft = reactive({
payoutState: props.filters.payoutState
});
const selectedSettlements = ref<CleaningSettlement[]>([]);
const exporting = ref(false);
const generateDialog = reactive({ open: false, cleanerUserId: '', storeId: '', note: '' });
const transferDialog = reactive({
open: false,
@@ -616,6 +693,19 @@ function canSelectSettlement(row: CleaningSettlement) {
return row.status === 'CONFIRMED';
}
function canCancelSettlement(row: CleaningSettlement) {
return (row.status === 'DRAFT' || row.status === 'CONFIRMED')
&& !['PROCESSING', 'WAIT_USER_CONFIRM', 'SUCCESS'].includes(row.payoutState);
}
function canMarkPaid(row: CleaningSettlement) {
return row.status === 'CONFIRMED' && ['', 'FAIL'].includes(row.payoutState);
}
function canRecordFailure(row: CleaningSettlement) {
return row.status === 'CONFIRMED' && ['', 'FAIL'].includes(row.payoutState);
}
function handleSelectionChange(rows: CleaningSettlement[]) {
selectedSettlements.value = rows;
}
@@ -712,6 +802,51 @@ function submitFailure() {
failureDialog.open = false;
}
async function openCancel(row: CleaningSettlement) {
try {
const result = await ElMessageBox.prompt(
'结算单 ' + row.settlementNo + ' 将生成反向记录并释放任务份额,原单不会删除。',
'冲正结算单',
{
confirmButtonText: '确认冲正',
cancelButtonText: '返回',
inputPlaceholder: '请输入冲正原因',
inputPattern: /\S+/,
inputErrorMessage: '冲正原因不能为空',
type: 'warning'
}
);
emit('cancel', { settlementId: row.id, reason: result.value.trim() });
} catch {
// Dialog cancellation is not an operation failure.
}
}
async function openManualPaid(row: CleaningSettlement) {
try {
const result = await ElMessageBox.prompt(
'结算单 ' + row.settlementNo + ' 将由人工渠道完成,请填写可对账的付款流水。',
'标记人工已付',
{
confirmButtonText: '确认已付',
cancelButtonText: '返回',
inputPlaceholder: '付款流水号',
inputPattern: /\S+/,
inputErrorMessage: '付款流水不能为空',
type: 'warning'
}
);
emit('paid', {
settlementId: row.id,
payoutChannel: 'MANUAL',
payoutReference: result.value.trim(),
note: '后台人工确认付款完成'
});
} catch {
// Dialog cancellation is not an operation failure.
}
}
async function openDetail(row: CleaningSettlement) {
detailDialog.open = true;
detailDialog.loading = true;
@@ -760,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)],