Files
qipai/admin/src/components/CleaningSettlementsPanel.vue
T
2026-07-06 15:03:17 +08:00

869 lines
31 KiB
Vue

<template>
<section class="panel">
<div class="panel-toolbar">
<div class="toolbar-actions">
<el-segmented
:model-value="status"
:options="statusOptions"
@update:model-value="$emit('status-change', $event as SettlementStatus | '')"
/>
<el-input
v-model="filterDraft.storeId"
class="narrow-input"
placeholder="门店 ID"
clearable
@keyup.enter="submitFilters"
@clear="submitFilters"
/>
<el-select
v-model="filterDraft.cleanerUserId"
class="filter-select"
filterable
clearable
placeholder="保洁员"
@change="submitFilters"
@clear="submitFilters"
>
<el-option
v-for="cleaner in cleaners"
:key="cleaner.id"
:label="cleanerLabel(cleaner)"
:value="cleaner.id"
/>
</el-select>
<el-select
v-model="filterDraft.payoutState"
class="filter-select"
clearable
placeholder="转账状态"
@change="submitFilters"
@clear="submitFilters"
>
<el-option
v-for="option in payoutStateOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
<el-button :icon="Search" @click="submitFilters">筛选</el-button>
</div>
<div class="toolbar-actions">
<el-button
:icon="RefreshCw"
:disabled="selectedSyncableCount === 0"
@click="submitBatchSync"
>
批量同步
</el-button>
<el-button :icon="Download" :disabled="items.length === 0" @click="exportSettlements">
导出
</el-button>
<el-button :icon="ListChecks" @click="openCandidates">
候选
</el-button>
<el-button :icon="FilePlus2" type="primary" @click="generateDialog.open = true">
生成
</el-button>
<el-tooltip content="刷新结算">
<el-button :icon="RefreshCw" :loading="loading" circle @click="$emit('refresh')" />
</el-tooltip>
</div>
</div>
<el-table
:data="items"
:loading="loading"
class="data-table"
row-key="id"
@selection-change="handleSelectionChange"
>
<el-table-column type="selection" width="44" :selectable="canSelectSettlement" />
<el-table-column prop="settlementNo" label="结算单" min-width="190" fixed>
<template #default="{ row }">
<div class="stack">
<strong>{{ row.settlementNo }}</strong>
<span>{{ row.cleanerName }} · {{ compactText(row.storeName) }}</span>
</div>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="115">
<template #default="{ row }">
<el-tag :type="settlementTag(row.status)" effect="light">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="taskCount" label="任务" width="80" />
<el-table-column prop="totalRewardCents" label="金额" width="110">
<template #default="{ row }">{{ money(row.totalRewardCents) }}</template>
</el-table-column>
<el-table-column prop="payoutState" label="转账状态" width="130">
<template #default="{ row }">{{ compactText(row.payoutState) }}</template>
</el-table-column>
<el-table-column prop="payoutReference" label="流水" min-width="150">
<template #default="{ row }">{{ compactText(row.payoutReference) }}</template>
</el-table-column>
<el-table-column prop="payoutError" label="失败原因" min-width="150">
<template #default="{ row }">{{ compactText(row.payoutError) }}</template>
</el-table-column>
<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">
<template #default="{ row }">
<div class="row-actions">
<el-button size="small" :icon="ListChecks" @click="openDetail(row)">详情</el-button>
<el-button
size="small"
:icon="BadgeCheck"
:disabled="row.status !== 'DRAFT'"
@click="$emit('confirm', { settlementId: row.id, note: '后台确认结算' })"
>
确认
</el-button>
<el-button
size="small"
:icon="ClipboardCheck"
:disabled="row.status !== 'CONFIRMED'"
@click="openPreflight(row)"
>
预检
</el-button>
<el-button
size="small"
type="primary"
:icon="Send"
:disabled="row.status !== 'CONFIRMED'"
@click="openTransfer(row)"
>
转账
</el-button>
<el-button
size="small"
:icon="RefreshCw"
:disabled="row.status !== 'CONFIRMED'"
@click="$emit('sync-transfer', { settlementId: row.id, note: '后台同步微信转账状态' })"
>
同步
</el-button>
<el-button
size="small"
type="danger"
:icon="CircleAlert"
:disabled="row.status !== 'CONFIRMED'"
@click="openFailure(row)"
>
失败
</el-button>
</div>
</template>
</el-table-column>
</el-table>
<div class="pager">
<el-pagination
layout="prev, pager, next, total"
:current-page="page"
:page-size="pageSize"
:total="total"
@current-change="$emit('page-change', $event)"
/>
</div>
</section>
<el-dialog v-model="generateDialog.open" title="生成结算单" width="420px">
<el-form label-position="top">
<el-form-item label="保洁员">
<el-select
v-model="generateDialog.cleanerUserId"
class="cleaner-select"
filterable
placeholder="选择保洁员"
:disabled="!cleaners.length"
>
<el-option
v-for="cleaner in cleaners"
:key="cleaner.id"
:label="cleanerLabel(cleaner)"
:value="cleaner.id"
>
<div class="option-row">
<strong>{{ cleaner.nickname || compactText(cleaner.id) }}</strong>
<span>ID {{ cleaner.id }} · {{ cleaner.maskedPhone || '无手机号' }}</span>
</div>
<el-tag :type="cleaner.wechatMiniappBound ? 'success' : 'warning'" effect="light" size="small">
{{ cleaner.wechatMiniappBound ? '已绑微信' : '未绑微信' }}
</el-tag>
</el-option>
</el-select>
</el-form-item>
<el-form-item label="门店 ID">
<el-input v-model="generateDialog.storeId" inputmode="numeric" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="generateDialog.note" type="textarea" :rows="3" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="generateDialog.open = false">取消</el-button>
<el-button type="primary" @click="submitGenerate">生成</el-button>
</template>
</el-dialog>
<el-dialog v-model="candidateDialog.open" title="待结算候选任务" width="760px">
<div class="dialog-stack">
<div class="detail-grid">
<span>候选任务<strong>{{ candidateDialog.total }}</strong></span>
<span>当前页金额<strong>{{ money(candidatePageRewardCents) }}</strong></span>
<span>门店筛选<strong>{{ compactText(filterDraft.storeId || '全部') }}</strong></span>
<span>保洁员筛选<strong>{{ compactText(filterDraft.cleanerUserId || '全部') }}</strong></span>
</div>
<el-table :data="candidateDialog.items" size="small" v-loading="candidateDialog.loading">
<el-table-column prop="taskNo" label="任务" min-width="170">
<template #default="{ row }">
<div class="stack">
<strong>{{ row.taskNo }}</strong>
<span>{{ compactText(row.orderNo) }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="房间" min-width="150">
<template #default="{ row }">
{{ row.storeName }} · {{ row.roomName || row.roomNo }}
</template>
</el-table-column>
<el-table-column prop="cleanerUserId" label="保洁员" width="110">
<template #default="{ row }">{{ compactText(row.cleanerUserId) }}</template>
</el-table-column>
<el-table-column prop="rewardCents" label="金额" width="110">
<template #default="{ row }">{{ money(row.rewardCents) }}</template>
</el-table-column>
<el-table-column prop="completedAt" label="完成" width="130">
<template #default="{ row }">{{ shortDate(row.completedAt) }}</template>
</el-table-column>
</el-table>
<div class="pager">
<el-pagination
layout="prev, pager, next, total"
:current-page="candidateDialog.page"
:page-size="candidateDialog.pageSize"
:total="candidateDialog.total"
@current-change="changeCandidatePage"
/>
</div>
</div>
<template #footer>
<el-button :icon="Download" :disabled="candidateDialog.items.length === 0" @click="exportCandidates">
导出候选
</el-button>
<el-button @click="candidateDialog.open = false">关闭</el-button>
<el-button type="primary" @click="useCandidateFilters">用当前筛选生成</el-button>
</template>
</el-dialog>
<el-dialog v-model="transferDialog.open" title="微信转账" width="420px">
<el-form label-position="top">
<el-form-item label="模式">
<el-radio-group v-model="transferDialog.mode">
<el-radio-button label="API">API</el-radio-button>
<el-radio-button label="MOCK">MOCK</el-radio-button>
</el-radio-group>
</el-form-item>
<el-alert
v-if="transferDialog.mode === 'API'"
class="transfer-preflight-alert"
:title="transferPreflightTitle"
:type="transferDialog.preflight?.ready ? 'success' : 'warning'"
show-icon
:closable="false"
/>
<el-table
v-if="transferDialog.mode === 'API' && transferDialog.preflight"
:data="transferDialog.preflight.checks"
size="small"
class="preflight-table"
max-height="220"
>
<el-table-column prop="key" label="检查项" width="160" />
<el-table-column prop="status" label="状态" width="90">
<template #default="{ row }">
<el-tag :type="preflightTag(row.status)" effect="light">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="message" label="说明" min-width="220" />
</el-table>
<el-form-item label="备注">
<el-input v-model="transferDialog.note" type="textarea" :rows="3" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="transferDialog.open = false">取消</el-button>
<el-button
type="primary"
:loading="transferDialog.preflightLoading"
:disabled="!transferCanSubmit"
@click="submitTransfer"
>
发起
</el-button>
</template>
</el-dialog>
<el-dialog v-model="preflightDialog.open" title="微信转账预检" width="560px">
<div v-if="preflightDialog.result" class="preflight-summary">
<el-alert
:title="preflightDialog.result.ready ? '真实转账前置条件已通过' : '仍有阻断项需要处理'"
:type="preflightDialog.result.ready ? 'success' : 'warning'"
show-icon
:closable="false"
/>
<div class="preflight-meta">
<span>结算单 {{ preflightDialog.result.settlement.settlementNo }}</span>
<span>商户 {{ compactText(preflightDialog.result.account.merchantIdMasked) }}</span>
<span>场景 {{ compactText(preflightDialog.result.credential.transferSceneId) }}</span>
</div>
<el-descriptions :column="2" size="small" border>
<el-descriptions-item label="凭据引用">
{{ compactText(preflightDialog.result.account.credentialRefMasked) }}
</el-descriptions-item>
<el-descriptions-item label="授权状态">
{{ compactText(preflightDialog.result.account.authorizationStatus) }}
</el-descriptions-item>
<el-descriptions-item label="账户范围">
{{ preflightDialog.result.account.storeScoped ? '门店' : '租户' }}
</el-descriptions-item>
<el-descriptions-item label="AppID">
{{ configuredText(preflightDialog.result.credential.appIdPresent) }}
</el-descriptions-item>
<el-descriptions-item label="商户证书序列号">
{{ configuredText(preflightDialog.result.credential.serialNoPresent) }}
</el-descriptions-item>
<el-descriptions-item label="场景报备">
{{ preflightDialog.result.credential.reportInfoCount ?? 0 }}
</el-descriptions-item>
<el-descriptions-item label="通知URL">
{{ configuredText(preflightDialog.result.credential.transferNotifyUrlConfigured) }}
</el-descriptions-item>
<el-descriptions-item label="平台证书">
{{ preflightDialog.result.credential.platformCertificateCount ?? 0 }}
</el-descriptions-item>
<el-descriptions-item label="保洁员OpenID">
{{ preflightDialog.result.cleaner.openidConfigured ? '已绑定' : '缺失' }}
</el-descriptions-item>
</el-descriptions>
<el-table :data="preflightDialog.result.checks" size="small" class="preflight-table">
<el-table-column prop="key" label="检查项" width="170" />
<el-table-column prop="status" label="状态" width="90">
<template #default="{ row }">
<el-tag :type="preflightTag(row.status)" effect="light">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="message" label="说明" min-width="220" />
</el-table>
</div>
<template #footer>
<el-button
:icon="Download"
:disabled="!preflightDialog.result"
@click="exportPreflight"
>
导出预检
</el-button>
<el-button @click="preflightDialog.open = false">关闭</el-button>
</template>
</el-dialog>
<el-dialog v-model="failureDialog.open" title="记录失败" width="420px">
<el-form label-position="top">
<el-form-item label="失败原因">
<el-input v-model="failureDialog.error" />
</el-form-item>
<el-form-item label="外部流水">
<el-input v-model="failureDialog.payoutReference" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="failureDialog.note" type="textarea" :rows="3" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="failureDialog.open = false">取消</el-button>
<el-button type="danger" @click="submitFailure">记录</el-button>
</template>
</el-dialog>
<el-dialog v-model="detailDialog.open" title="结算单详情" width="720px">
<div v-if="detailDialog.detail" class="dialog-stack">
<div class="detail-grid">
<span>单号<strong>{{ detailDialog.detail.settlement.settlementNo }}</strong></span>
<span>保洁员<strong>{{ detailDialog.detail.settlement.cleanerName }}</strong></span>
<span>金额<strong>{{ money(detailDialog.detail.settlement.totalRewardCents) }}</strong></span>
<span>状态<strong>{{ detailDialog.detail.settlement.status }}</strong></span>
</div>
<el-table :data="detailDialog.detail.items" size="small" v-loading="detailDialog.loading">
<el-table-column prop="taskNo" label="任务" min-width="170">
<template #default="{ row }">
<div class="stack">
<strong>{{ row.taskNo }}</strong>
<span>{{ compactText(row.orderNo) }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="房间" min-width="150">
<template #default="{ row }">
{{ row.storeName }} · {{ row.roomName || row.roomNo }}
</template>
</el-table-column>
<el-table-column prop="cleanerName" label="成员" width="130" />
<el-table-column prop="rewardCents" label="金额" width="110">
<template #default="{ row }">{{ money(row.rewardCents) }}</template>
</el-table-column>
<el-table-column prop="completedAt" label="完成" width="130">
<template #default="{ row }">{{ shortDate(row.completedAt) }}</template>
</el-table-column>
</el-table>
<el-descriptions :column="2" size="small" border>
<el-descriptions-item label="转账状态">
{{ compactText(detailDialog.detail.settlement.payoutState) }}
</el-descriptions-item>
<el-descriptions-item label="外部流水">
{{ compactText(detailDialog.detail.settlement.payoutReference) }}
</el-descriptions-item>
<el-descriptions-item label="微信确认参数" :span="2">
{{ compactText(detailDialog.detail.settlement.payoutPackageInfo) }}
</el-descriptions-item>
<el-descriptions-item label="失败原因" :span="2">
{{ compactText(detailDialog.detail.settlement.payoutError) }}
</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">
{{ compactText(detailDialog.detail.settlement.note) }}
</el-descriptions-item>
</el-descriptions>
</div>
<template #footer>
<el-button :icon="Download" :disabled="!detailDialog.detail" @click="exportSettlementDetail">
导出详情
</el-button>
<el-button @click="detailDialog.open = false">关闭</el-button>
</template>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import {
BadgeCheck,
CircleAlert,
ClipboardCheck,
Download,
FilePlus2,
ListChecks,
RefreshCw,
Search,
Send
} from '@lucide/vue';
import { ElMessage } from 'element-plus';
import {
getCleaningSettlementDetail,
listCleaningSettlementCandidates,
preflightWechatTransfer
} from '../api';
import { compactText, money, shortDate } from '../format';
import type {
CleaningSettlement,
CleaningSettlementDetail,
CleaningTask,
ManagedUser,
PageResult,
PayoutStateFilter,
SettlementStatus,
TransferMode,
WechatTransferPreflight
} from '../types';
const props = defineProps<{
session: { token: string };
loading: boolean;
items: PageResult<CleaningSettlement>['items'];
cleaners: ManagedUser[];
total: number;
page: number;
pageSize: number;
status: SettlementStatus | '';
filters: { storeId: string; cleanerUserId: string; payoutState: PayoutStateFilter | '' };
}>();
const emit = defineEmits<{
refresh: [];
'status-change': [SettlementStatus | ''];
filter: [{ storeId: string; cleanerUserId: string; payoutState: PayoutStateFilter | '' }];
'page-change': [number];
generate: [{ cleanerUserId: string; storeId?: string; note?: string }];
confirm: [{ settlementId: string; note?: string }];
transfer: [{ settlementId: string; mode: TransferMode; note?: string }];
'sync-transfer': [{ settlementId: string; note?: string }];
'sync-transfer-many': [{ settlementIds: string[]; note?: string }];
failure: [{ settlementId: string; error: string; payoutReference?: string; note?: string }];
}>();
const statusOptions = [
{ label: '全部', value: '' },
{ label: '草稿', value: 'DRAFT' },
{ label: '待发', value: 'CONFIRMED' },
{ label: '已发', value: 'PAID' },
{ label: '取消', value: 'CANCELLED' }
];
const payoutStateOptions: Array<{ label: string; value: PayoutStateFilter }> = [
{ label: '未发起', value: 'NONE' },
{ label: '处理中', value: 'PROCESSING' },
{ label: '待确认', value: 'WAIT_USER_CONFIRM' },
{ label: '成功', value: 'SUCCESS' },
{ label: '失败', value: 'FAIL' }
];
const filterDraft = reactive({
storeId: props.filters.storeId,
cleanerUserId: props.filters.cleanerUserId,
payoutState: props.filters.payoutState
});
const selectedSettlements = ref<CleaningSettlement[]>([]);
const generateDialog = reactive({ open: false, cleanerUserId: '', storeId: '', note: '' });
const transferDialog = reactive({
open: false,
settlementId: '',
mode: 'API' as TransferMode,
note: '',
preflightLoading: false,
preflight: null as WechatTransferPreflight | null
});
watch(
() => props.filters,
(value) => Object.assign(filterDraft, value),
{ deep: true }
);
const preflightDialog = reactive<{
open: boolean;
result: WechatTransferPreflight | null;
}>({
open: false,
result: null
});
const failureDialog = reactive({
open: false,
settlementId: '',
error: '',
payoutReference: '',
note: ''
});
const detailDialog = reactive<{
open: boolean;
loading: boolean;
detail: CleaningSettlementDetail | null;
}>({
open: false,
loading: false,
detail: null
});
const candidateDialog = reactive<{
open: boolean;
loading: boolean;
items: CleaningTask[];
total: number;
page: number;
pageSize: number;
}>({
open: false,
loading: false,
items: [],
total: 0,
page: 1,
pageSize: 10
});
const candidatePageRewardCents = computed(() => candidateDialog.items
.reduce((sum, item) => sum + item.rewardCents, 0));
const selectedSyncableSettlements = computed(() => selectedSettlements.value
.filter((settlement) => settlement.status === 'CONFIRMED'));
const selectedSyncableCount = computed(() => selectedSyncableSettlements.value.length);
const transferCanSubmit = computed(() => {
if (transferDialog.mode === 'MOCK') return true;
return !transferDialog.preflightLoading && Boolean(transferDialog.preflight?.ready);
});
const transferPreflightTitle = computed(() => {
if (transferDialog.preflightLoading) return '正在执行真实转账预检';
if (!transferDialog.preflight) return '请等待预检完成后再发起真实转账';
if (transferDialog.preflight.ready) return '真实转账前置条件已通过';
const failedCount = transferDialog.preflight.checks
.filter((check) => check.status === 'FAIL').length;
return `仍有 ${failedCount} 个阻断项需要处理`;
});
function settlementTag(status: SettlementStatus) {
if (status === 'PAID') return 'success';
if (status === 'CONFIRMED') return 'warning';
if (status === 'CANCELLED') return 'info';
return 'primary';
}
function preflightTag(status: string) {
if (status === 'PASS') return 'success';
if (status === 'WARN') return 'warning';
return 'danger';
}
function configuredText(value?: boolean) {
return value ? '已配置' : '缺失';
}
function canSelectSettlement(row: CleaningSettlement) {
return row.status === 'CONFIRMED';
}
function handleSelectionChange(rows: CleaningSettlement[]) {
selectedSettlements.value = rows;
}
function cleanerLabel(cleaner: ManagedUser) {
const name = cleaner.nickname || `ID ${cleaner.id}`;
const phone = cleaner.maskedPhone ? ` · ${cleaner.maskedPhone}` : '';
const stores = cleaner.storeIds.length ? ` · 门店 ${cleaner.storeIds.join('/')}` : '';
return `${name}${phone}${stores}`;
}
function submitFilters() {
emit('filter', {
storeId: filterDraft.storeId.trim(),
cleanerUserId: filterDraft.cleanerUserId,
payoutState: filterDraft.payoutState
});
}
function submitGenerate() {
if (!generateDialog.cleanerUserId) {
ElMessage.warning('请选择保洁员');
return;
}
emit('generate', {
cleanerUserId: generateDialog.cleanerUserId,
storeId: generateDialog.storeId || undefined,
note: generateDialog.note
});
generateDialog.open = false;
}
async function openTransfer(row: CleaningSettlement) {
transferDialog.open = true;
transferDialog.settlementId = row.id;
transferDialog.mode = 'API';
transferDialog.note = '';
transferDialog.preflight = null;
transferDialog.preflightLoading = true;
try {
transferDialog.preflight = await preflightWechatTransfer(props.session, row.id);
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '微信转账预检失败');
} finally {
transferDialog.preflightLoading = false;
}
}
function submitTransfer() {
emit('transfer', {
settlementId: transferDialog.settlementId,
mode: transferDialog.mode,
note: transferDialog.note
});
transferDialog.open = false;
}
function submitBatchSync() {
const settlementIds = selectedSyncableSettlements.value.map((settlement) => settlement.id);
if (!settlementIds.length) {
ElMessage.warning('请选择待发放结算单');
return;
}
emit('sync-transfer-many', {
settlementIds,
note: '后台批量同步微信转账状态'
});
}
async function openPreflight(row: CleaningSettlement) {
try {
preflightDialog.result = await preflightWechatTransfer(props.session, row.id);
preflightDialog.open = true;
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '微信转账预检失败');
}
}
function openFailure(row: CleaningSettlement) {
failureDialog.open = true;
failureDialog.settlementId = row.id;
failureDialog.error = row.payoutError || '';
failureDialog.payoutReference = row.payoutReference || '';
failureDialog.note = '';
}
function submitFailure() {
emit('failure', {
settlementId: failureDialog.settlementId,
error: failureDialog.error,
payoutReference: failureDialog.payoutReference,
note: failureDialog.note
});
failureDialog.open = false;
}
async function openDetail(row: CleaningSettlement) {
detailDialog.open = true;
detailDialog.loading = true;
try {
detailDialog.detail = await getCleaningSettlementDetail(props.session, row.id);
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '结算详情加载失败');
} finally {
detailDialog.loading = false;
}
}
async function openCandidates() {
candidateDialog.open = true;
candidateDialog.page = 1;
await loadCandidates();
}
async function changeCandidatePage(page: number) {
candidateDialog.page = page;
await loadCandidates();
}
async function loadCandidates() {
candidateDialog.loading = true;
try {
const result = await listCleaningSettlementCandidates(props.session, {
page: candidateDialog.page,
pageSize: candidateDialog.pageSize,
storeId: filterDraft.storeId || undefined,
cleanerUserId: filterDraft.cleanerUserId || undefined
});
candidateDialog.items = result.items;
candidateDialog.total = result.total;
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '待结算候选加载失败');
} finally {
candidateDialog.loading = false;
}
}
function useCandidateFilters() {
generateDialog.open = true;
generateDialog.storeId = filterDraft.storeId;
generateDialog.cleanerUserId = filterDraft.cleanerUserId;
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)
])
]);
}
function exportCandidates() {
downloadCsv(`cleaning-settlement-candidates-${Date.now()}.csv`, [
['任务号', '订单号', '门店', '房间', '保洁员ID', '金额', '完成时间'],
...candidateDialog.items.map((item) => [
item.taskNo,
item.orderNo || '',
item.storeName,
item.roomName || item.roomNo,
item.cleanerUserId || '',
money(item.rewardCents),
shortDate(item.completedAt)
])
]);
}
function exportSettlementDetail() {
const detail = detailDialog.detail;
if (!detail) return;
const settlement = detail.settlement;
downloadCsv(`cleaning-settlement-detail-${settlement.settlementNo}-${Date.now()}.csv`, [
['类别', '字段', '值'],
['settlement', '结算单号', settlement.settlementNo],
['settlement', '状态', settlement.status],
['settlement', '保洁员', settlement.cleanerName],
['settlement', '门店', settlement.storeName || ''],
['settlement', '任务数', String(settlement.taskCount)],
['settlement', '金额', money(settlement.totalRewardCents)],
['settlement', '转账状态', settlement.payoutState],
['settlement', '外部流水', settlement.payoutReference],
['settlement', '微信确认参数', settlement.payoutPackageInfo],
['settlement', '失败原因', settlement.payoutError],
['settlement', '备注', settlement.note],
['settlement', '创建时间', shortDate(settlement.createdAt)],
...detail.items.map((item) => [
'item',
item.taskNo,
`${item.storeName} ${item.roomName || item.roomNo} ${item.cleanerName} ${money(item.rewardCents)} ${shortDate(item.completedAt)}`
])
]);
}
function exportPreflight() {
const result = preflightDialog.result;
if (!result) return;
downloadCsv(`cleaning-transfer-preflight-${result.settlement.settlementNo}-${Date.now()}.csv`, [
['类别', '字段', '值'],
['summary', 'ready', result.ready ? 'PASS' : 'BLOCKED'],
['settlement', 'settlementNo', result.settlement.settlementNo],
['settlement', 'status', result.settlement.status],
['settlement', 'amount', money(result.settlement.totalRewardCents)],
['settlement', 'cleanerUserId', result.settlement.cleanerUserId],
['settlement', 'storeId', result.settlement.storeId || ''],
['account', 'merchantId', compactText(result.account.merchantIdMasked)],
['account', 'credentialRef', compactText(result.account.credentialRefMasked)],
['account', 'authorizationStatus', compactText(result.account.authorizationStatus)],
['account', 'scope', result.account.storeScoped ? 'STORE' : 'TENANT'],
['credential', 'appId', configuredText(result.credential.appIdPresent)],
['credential', 'serialNo', configuredText(result.credential.serialNoPresent)],
['credential', 'transferSceneId', compactText(result.credential.transferSceneId)],
['credential', 'reportInfoCount', String(result.credential.reportInfoCount ?? 0)],
['credential', 'transferNotifyUrl', configuredText(result.credential.transferNotifyUrlConfigured)],
['credential', 'platformCertificateCount', String(result.credential.platformCertificateCount ?? 0)],
['cleaner', 'openid', result.cleaner.openidConfigured ? 'BOUND' : 'MISSING'],
...result.checks.map((check) => [
'check',
`${check.key}:${check.status}`,
check.message
])
]);
}
function downloadCsv(filename: string, rows: string[][]) {
const content = rows.map((row) => row.map(csvCell).join(',')).join('\r\n');
const blob = new Blob([`\uFEFF${content}`], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
}
function csvCell(value: string) {
const normalized = value.replace(/\r?\n/g, ' ');
return /[",\r\n]/.test(normalized) ? `"${normalized.replace(/"/g, '""')}"` : normalized;
}
</script>