869 lines
31 KiB
Vue
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>
|