feat(cleaning): close settlement integrity and reconciliation
This commit is contained in:
@@ -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)],
|
||||
|
||||
Reference in New Issue
Block a user