feat(cleaning): close settlement integrity and reconciliation

This commit is contained in:
Codex
2026-08-11 02:07:59 +08:00
parent 5e0f5e39ee
commit 1f1071501f
23 changed files with 1562 additions and 208 deletions
+48
View File
@@ -826,6 +826,28 @@ export function listCleaningSettlements(
return request<PageResult<CleaningSettlement>>(session, `/cleaning/settlements?${params}`);
}
export function exportCleaningSettlements(
session: ApiSession,
input: {
status?: SettlementStatus;
payoutState?: PayoutStateFilter;
storeId?: string;
cleanerUserId?: string;
}
) {
const params = new URLSearchParams();
if (input.status) params.set('status', input.status);
if (input.payoutState) params.set('payoutState', input.payoutState);
if (input.storeId) params.set('storeId', input.storeId);
if (input.cleanerUserId) params.set('cleanerUserId', input.cleanerUserId);
return request<{
items: CleaningSettlement[];
total: number;
exported: number;
truncated: boolean;
}>(session, `/cleaning/settlements/export?${params}`);
}
export function listCleaningSettlementCandidates(
session: ApiSession,
input: { page: number; pageSize: number; storeId?: string; cleanerUserId?: string }
@@ -867,6 +889,32 @@ export function confirmCleaningSettlement(session: ApiSession, settlementId: str
);
}
export function cancelCleaningSettlement(session: ApiSession, settlementId: string, reason: string) {
return request<CleaningSettlement>(
session,
`/cleaning/settlements/${encodeURIComponent(settlementId)}/cancel`,
{
method: 'POST',
body: JSON.stringify({ reason })
}
);
}
export function markCleaningSettlementPaid(
session: ApiSession,
settlementId: string,
input: { payoutChannel: string; payoutReference: string; note?: string }
) {
return request<CleaningSettlement>(
session,
`/cleaning/settlements/${encodeURIComponent(settlementId)}/paid`,
{
method: 'POST',
body: JSON.stringify(input)
}
);
}
export function executeWechatTransfer(
session: ApiSession,
settlementId: string,
+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)],
@@ -288,7 +288,7 @@ function filterByCleaner(cleanerUserId: string) {
function exportCleanerPerformance() {
downloadCsv(`cleaning-cleaner-performance-${Date.now()}.csv`, [
['排名', '保洁员', '保洁员ID', '任务', '完成', '驳回', '完成率', '待结算', '已结算'],
['排名', '保洁员', '保洁员ID', '任务', '完成', '驳回', '完成率', '待结算(分)', '已结算(分)'],
...cleanerPerformanceRows.value.map((row) => [
String(row.rank),
row.cleanerName || '',
@@ -297,8 +297,8 @@ function exportCleanerPerformance() {
String(row.completedTaskCount),
String(row.rejectedTaskCount),
`${row.completionRate}%`,
money(row.pendingSettlementCents),
money(row.settledRewardCents)
String(row.pendingSettlementCents),
String(row.settledRewardCents)
])
]);
}
@@ -316,14 +316,14 @@ function exportStatistics() {
['summary', '\u9a73\u56de', String(props.statistics.summary.rejected), '', '', '', ''],
['summary', '\u514d\u6e05\u6d01', String(props.statistics.summary.exempted), '', '', '', ''],
['summary', '\u5df2\u5b8c\u6210', String(props.statistics.summary.completed), '', '', '', ''],
['summary', '\u5956\u52b1\u5408\u8ba1', money(props.statistics.summary.rewardCents), '', '', '', ''],
['summary', '\u5f85\u7ed3\u7b97', money(props.statistics.summary.pendingSettlementCents), '', '', '', ''],
['summary', '\u5f85\u53d1\u653e', money(props.statistics.summary.confirmedSettlementCents), '', '', '', ''],
['summary', '\u5df2\u53d1\u653e', money(props.statistics.summary.paidSettlementCents), '', '', '', ''],
['\u4efb\u52a1\u72b6\u6001', '\u72b6\u6001', '\u6570\u91cf', '\u5956\u52b1', '', '', ''],
...props.statistics.byStatus.map((row) => ['\u4efb\u52a1\u72b6\u6001', row.status, String(row.total), money(row.rewardCents), '', '', '']),
['\u7ed3\u7b97\u53e3\u5f84', '\u72b6\u6001', '\u5355\u6570', '\u91d1\u989d', '', '', ''],
...props.statistics.settlements.map((row) => ['\u7ed3\u7b97\u53e3\u5f84', row.status, String(row.total), money(row.rewardCents), '', '', '']),
['summary', '\u5956\u52b1\u5408\u8ba1(\u5206)', String(props.statistics.summary.rewardCents), '', '', '', ''],
['summary', '\u5f85\u7ed3\u7b97(\u5206)', String(props.statistics.summary.pendingSettlementCents), '', '', '', ''],
['summary', '\u5f85\u53d1\u653e(\u5206)', String(props.statistics.summary.confirmedSettlementCents), '', '', '', ''],
['summary', '\u5df2\u53d1\u653e(\u5206)', String(props.statistics.summary.paidSettlementCents), '', '', '', ''],
['\u4efb\u52a1\u72b6\u6001', '\u72b6\u6001', '\u6570\u91cf', '\u5956\u52b1(\u5206)', '', '', ''],
...props.statistics.byStatus.map((row) => ['\u4efb\u52a1\u72b6\u6001', row.status, String(row.total), String(row.rewardCents), '', '', '']),
['\u7ed3\u7b97\u53e3\u5f84', '\u72b6\u6001', '\u5355\u6570', '\u91d1\u989d(\u5206)', '', '', ''],
...props.statistics.settlements.map((row) => ['\u7ed3\u7b97\u53e3\u5f84', row.status, String(row.total), String(row.rewardCents), '', '', '']),
['\u6bcf\u65e5\u8d8b\u52bf', '\u65e5\u671f', '\u4efb\u52a1', '\u5f85\u9a8c', '\u5b8c\u6210', '\u9a73\u56de', '\u514d\u6e05\u6d01'],
...props.statistics.trend.map((row) => [
'\u6bcf\u65e5\u8d8b\u52bf',
@@ -334,8 +334,8 @@ function exportStatistics() {
String(row.rejected),
String(row.exempted)
]),
['\u6bcf\u65e5\u53d1\u653e', '\u65e5\u671f', '\u5df2\u53d1\u653e', '', '', '', ''],
...props.statistics.trend.map((row) => ['\u6bcf\u65e5\u53d1\u653e', row.date, money(row.paidSettlementCents), '', '', '', '']),
['\u6bcf\u65e5\u53d1\u653e', '\u65e5\u671f', '\u5df2\u53d1\u653e(\u5206)', '', '', '', ''],
...props.statistics.trend.map((row) => ['\u6bcf\u65e5\u53d1\u653e', row.date, String(row.paidSettlementCents), '', '', '', '']),
['\u95e8\u5e97\u660e\u7ec6', '\u95e8\u5e97', '\u4efb\u52a1', '\u5f85\u9a8c', '\u5b8c\u6210', '\u9a73\u56de', '\u514d\u6e05\u6d01'],
...props.statistics.byStore.map((row) => [
'\u95e8\u5e97\u660e\u7ec6',
@@ -346,19 +346,19 @@ function exportStatistics() {
String(row.rejected),
String(row.exempted)
]),
['\u95e8\u5e97\u5956\u52b1', '\u95e8\u5e97', '\u5956\u52b1', '', '', '', ''],
...props.statistics.byStore.map((row) => ['\u95e8\u5e97\u5956\u52b1', `${row.storeName || ''}(${row.storeId})`, money(row.rewardCents), '', '', '', '']),
['\u6210\u5458\u5206\u8d26', '\u4fdd\u6d01\u5458', '\u4efb\u52a1', '\u5b8c\u6210', '\u9a73\u56de', '\u5f85\u7ed3\u7b97', '\u5df2\u7ed3\u7b97'],
['\u95e8\u5e97\u5956\u52b1', '\u95e8\u5e97', '\u5956\u52b1(\u5206)', '', '', '', ''],
...props.statistics.byStore.map((row) => ['\u95e8\u5e97\u5956\u52b1', `${row.storeName || ''}(${row.storeId})`, String(row.rewardCents), '', '', '', '']),
['\u6210\u5458\u5206\u8d26', '\u4fdd\u6d01\u5458', '\u4efb\u52a1', '\u5b8c\u6210', '\u9a73\u56de', '\u5f85\u7ed3\u7b97(\u5206)', '\u5df2\u7ed3\u7b97(\u5206)'],
...props.statistics.members.map((row) => [
'\u6210\u5458\u5206\u8d26',
`${row.cleanerName || ''}(${row.cleanerUserId})`,
String(row.taskCount),
String(row.completedTaskCount),
String(row.rejectedTaskCount),
money(row.pendingSettlementCents),
money(row.settledRewardCents)
String(row.pendingSettlementCents),
String(row.settledRewardCents)
]),
['\u7ee9\u6548\u6392\u884c', '\u6392\u540d', '\u4fdd\u6d01\u5458', '\u4efb\u52a1', '\u5b8c\u6210\u7387', '\u9a73\u56de', '\u7ed3\u7b97\u5408\u8ba1'],
['\u7ee9\u6548\u6392\u884c', '\u6392\u540d', '\u4fdd\u6d01\u5458', '\u4efb\u52a1', '\u5b8c\u6210\u7387', '\u9a73\u56de', '\u7ed3\u7b97\u5408\u8ba1(\u5206)'],
...cleanerPerformanceRows.value.map((row) => [
'\u7ee9\u6548\u6392\u884c',
String(row.rank),
@@ -366,7 +366,7 @@ function exportStatistics() {
String(row.taskCount),
`${row.completionRate}%`,
String(row.rejectedTaskCount),
money(row.settlementTotalCents)
String(row.settlementTotalCents)
])
]);
}
+6 -6
View File
@@ -791,7 +791,7 @@ async function removeMember(cleanerUserId: string) {
function exportTasks() {
downloadCsv(`cleaning-tasks-${Date.now()}.csv`, [
['任务号', '状态', '门店', '房间', '订单号', '保洁员ID', '协作人数', '奖励金额', '照片数', '提交时间', '完成时间', '驳回原因'],
['任务号', '状态', '门店', '房间', '订单号', '保洁员ID', '协作人数', '奖励金额(分)', '照片数', '提交时间', '完成时间', '驳回原因'],
...props.items.map((item) => [
item.taskNo,
item.status,
@@ -800,7 +800,7 @@ function exportTasks() {
item.orderNo || '',
item.cleanerUserId || '',
String(item.memberCount || 0),
money(item.rewardCents),
String(item.rewardCents),
String(item.photoUrls?.length || 0),
shortDate(item.submittedAt),
shortDate(item.completedAt),
@@ -820,7 +820,7 @@ function exportTaskDetail() {
['task', '房间', task.roomName || task.roomNo],
['task', '订单号', task.orderNo || ''],
['task', '保洁员ID', task.cleanerUserId || ''],
['task', '奖励金额', money(task.rewardCents)],
['task', '奖励金额(分)', String(task.rewardCents)],
['task', '照片数', String(task.photoUrls?.length || 0)],
['task', '领取时间', shortDate(task.claimedAt)],
['task', '开始时间', shortDate(task.startedAt)],
@@ -831,7 +831,7 @@ function exportTaskDetail() {
...detailDialog.members.map((member) => [
'member',
`${member.memberRole}:${member.userId}`,
`${member.nickname || ''} ${money(member.rewardCents)} ${member.settledAt ? shortDate(member.settledAt) : '未结算'}`
`${member.nickname || ''} ${member.rewardCents} ${member.settledAt ? shortDate(member.settledAt) : '未结算'}`
]),
...detailDialog.events.map((event) => [
'event',
@@ -873,13 +873,13 @@ function exportTaskEvents() {
function exportTaskMembers() {
downloadCsv(`cleaning-task-members-${memberDialog.taskNo}-${Date.now()}.csv`, [
['任务号', '成员ID', '昵称', '角色', '分账金额', '结算时间'],
['任务号', '成员ID', '昵称', '角色', '分账金额(分)', '结算时间'],
...memberDialog.members.map((member) => [
memberDialog.taskNo,
member.userId,
member.nickname || '',
member.memberRole,
money(member.rewardCents),
String(member.rewardCents),
shortDate(member.settledAt)
])
]);
+31 -3
View File
@@ -113,6 +113,8 @@
@sync-transfer="handleSyncTransfer"
@sync-transfer-many="handleSyncTransferMany"
@failure="handleFailure"
@cancel="handleCancelSettlement"
@paid="handleManualPaid"
/>
</el-tab-pane>
<el-tab-pane label="统计" name="statistics">
@@ -167,6 +169,7 @@ import CleaningFieldHandoffPanel from './CleaningFieldHandoffPanel.vue';
import {
ApiError,
assignCleaningTask,
cancelCleaningSettlement,
completeCleaningTask,
confirmCleaningSettlement,
createStaffUser,
@@ -177,6 +180,7 @@ import {
listCleaningSettlements,
listCleaningTasks,
listStaffUsers,
markCleaningSettlementPaid,
reclaimCleaningTimeouts,
recordPayoutFailure,
rejectCleaningTask,
@@ -234,6 +238,7 @@ const statistics = reactive<CleaningStatistics>({
completed: 0,
rewardCents: 0,
pendingSettlementCents: 0,
draftSettlementCents: 0,
confirmedSettlementCents: 0,
paidSettlementCents: 0
},
@@ -372,12 +377,12 @@ function jumpToSettlementStatus(status: SettlementStatus) {
function exportOperationalQueue() {
downloadCsv(`cleaning-operational-queue-${Date.now()}.csv`, [
['队列', '数量', '金额', '入口'],
['队列', '数量', '金额(分)', '入口'],
['待验收任务', String(statistics.summary.pendingReview), '', '任务/SUBMITTED'],
['驳回补做', String(statistics.summary.rejected), '', '任务/REJECTED'],
['待确认结算', String(draftSettlementTotal.value), '', '结算/DRAFT'],
['待发放金额', '', money(statistics.summary.confirmedSettlementCents), '结算/CONFIRMED'],
['待结算金额', '', money(statistics.summary.pendingSettlementCents), '统计/待结算'],
['待发放金额', '', String(statistics.summary.confirmedSettlementCents), '结算/CONFIRMED'],
['待结算金额', '', String(statistics.summary.pendingSettlementCents), '统计/待结算'],
['已完成任务', String(statistics.summary.completed), '', '统计/已完成']
]);
}
@@ -519,6 +524,29 @@ async function handleConfirmSettlement(input: { settlementId: string; note?: str
}, '已确认结算单');
}
async function handleCancelSettlement(input: { settlementId: string; reason: string }) {
await runAction(async () => {
await cancelCleaningSettlement(session.value, input.settlementId, input.reason);
await Promise.all([loadTasks(), loadSettlements(), loadStatistics()]);
}, '结算单已冲正');
}
async function handleManualPaid(input: {
settlementId: string;
payoutChannel: string;
payoutReference: string;
note?: string;
}) {
await runAction(async () => {
await markCleaningSettlementPaid(session.value, input.settlementId, {
payoutChannel: input.payoutChannel,
payoutReference: input.payoutReference,
note: input.note
});
await Promise.all([loadTasks(), loadSettlements(), loadStatistics()]);
}, '人工付款已完成');
}
async function handleTransfer(input: { settlementId: string; mode: TransferMode; note?: string }) {
await runAction(async () => {
await executeWechatTransfer(session.value, input.settlementId, {
+31
View File
@@ -673,6 +673,7 @@ export interface CleaningTaskEvent {
toStatus: TaskStatus;
action: string;
actorId: string;
actorType: 'USER' | 'SYSTEM';
traceId: string;
note: string;
createdAt: string;
@@ -705,9 +706,12 @@ export interface CleaningSettlement {
periodStart: string | null;
periodEnd: string | null;
paidBy: string | null;
cancelledBy: string | null;
confirmedAt: string | null;
paidAt: string | null;
cancelledAt: string | null;
payoutChannel: string;
payoutRequestNo: string;
payoutReference: string;
payoutState: string;
payoutPackageInfo: string;
@@ -728,13 +732,39 @@ export interface CleaningSettlementItem {
cleanerUserId: string;
cleanerName: string;
rewardCents: number;
reversedAt: string | null;
completedAt: string | null;
createdAt: string;
}
export interface CleaningSettlementEvent {
id: string;
settlementId: string;
fromStatus: SettlementStatus | null;
toStatus: SettlementStatus;
action: string;
actorType: 'USER' | 'SYSTEM';
actorId: string;
traceId: string;
note: string;
createdAt: string;
}
export interface CleaningSettlementReversal {
id: string;
settlementId: string;
reversalNo: string;
amountDeltaCents: number;
reason: string;
reversedBy: string;
createdAt: string;
}
export interface CleaningSettlementDetail {
settlement: CleaningSettlement;
items: CleaningSettlementItem[];
events: CleaningSettlementEvent[];
reversals: CleaningSettlementReversal[];
}
export interface CleaningStatistics {
@@ -747,6 +777,7 @@ export interface CleaningStatistics {
completed: number;
rewardCents: number;
pendingSettlementCents: number;
draftSettlementCents: number;
confirmedSettlementCents: number;
paidSettlementCents: number;
};