1119 lines
39 KiB
Vue
1119 lines
39 KiB
Vue
<template>
|
||
<div class="app-container order-bill-query">
|
||
<div v-if="exportFloatVisible" class="retry-status-float export-status-float" :class="{ 'is-offset': rebillFloatVisible }">
|
||
<el-tooltip :content="exportFloatTooltip" placement="bottom">
|
||
<el-button class="retry-status-button" type="warning" plain size="small" @click="openExportProgress()">
|
||
<span class="retry-progress-ring" :style="{ '--retry-progress': exportProgressDegree + 'deg' }" aria-hidden="true">
|
||
<span class="retry-progress-value">{{ exportProgressText }}</span>
|
||
</span>
|
||
<span class="retry-status-text">
|
||
<strong>{{ exportFloatTitle }}</strong>
|
||
<small>{{ exportFloatSummary }}</small>
|
||
</span>
|
||
</el-button>
|
||
</el-tooltip>
|
||
</div>
|
||
|
||
<div v-if="rebillFloatVisible" class="retry-status-float">
|
||
<el-tooltip :content="rebillFloatTooltip" placement="bottom">
|
||
<el-button class="retry-status-button" type="primary" plain size="small" @click="openRebillProgress()">
|
||
<span class="retry-progress-ring" :style="{ '--retry-progress': rebillProgressDegree + 'deg' }" aria-hidden="true">
|
||
<span class="retry-progress-value">{{ rebillProgressText }}</span>
|
||
</span>
|
||
<span class="retry-status-text">
|
||
<strong>{{ rebillFloatTitle }}</strong>
|
||
<small>{{ rebillFloatSummary }}</small>
|
||
</span>
|
||
</el-button>
|
||
</el-tooltip>
|
||
</div>
|
||
|
||
<basic-table
|
||
ref="tableRef"
|
||
:columns="columns"
|
||
:search-columns="searchColumns"
|
||
:query="queryList"
|
||
:default-params="defaultParams"
|
||
:search-per-row="4"
|
||
row-key="billId"
|
||
:auto-load="false"
|
||
:page-sizes="[100, 200, 300, 400]"
|
||
:page-size="100"
|
||
selection
|
||
:height="tableHeight"
|
||
virtual
|
||
>
|
||
<template #toolbar="{ selection }">
|
||
<el-col :span="1.5">
|
||
<el-button
|
||
type="primary"
|
||
plain
|
||
icon="Refresh"
|
||
:loading="batchLoading"
|
||
v-hasPermi="['price:orderBill:rebill']"
|
||
@click="openFilterRebill()"
|
||
>重新计费</el-button>
|
||
</el-col>
|
||
<el-col :span="1.5">
|
||
<el-button
|
||
type="success"
|
||
plain
|
||
icon="Search"
|
||
:loading="checkLoading"
|
||
v-hasPermi="['price:orderBill:list']"
|
||
@click="handleCheckProducts()"
|
||
>检测产品</el-button>
|
||
</el-col>
|
||
<el-col :span="1.5">
|
||
<el-button type="info" plain icon="Document" @click="logDrawerRef.open()">操作记录</el-button>
|
||
</el-col>
|
||
<el-col :span="1.5">
|
||
<el-button
|
||
type="warning"
|
||
plain
|
||
icon="Download"
|
||
:loading="exportLoading"
|
||
:disabled="exportLoading"
|
||
v-hasPermi="['price:orderBill:export']"
|
||
@click="handleExport"
|
||
>导出</el-button>
|
||
</el-col>
|
||
<el-col :span="1.5">
|
||
<el-button
|
||
type="warning"
|
||
plain
|
||
icon="Refresh"
|
||
:disabled="!selection.length"
|
||
:loading="batchLoading"
|
||
v-hasPermi="['price:orderBill:rebill']"
|
||
@click="handleBatchRebill(selection)"
|
||
>重新计费选中</el-button>
|
||
</el-col>
|
||
</template>
|
||
<template #search-orderIdentifiers="{ model }">
|
||
<div class="order-identifier-search">
|
||
<el-input
|
||
v-model="model.orderIdentifiers"
|
||
type="textarea"
|
||
:rows="1"
|
||
resize="none"
|
||
placeholder="请输入订单号、运单号或转单号"
|
||
clearable
|
||
/>
|
||
</div>
|
||
</template>
|
||
<template #search-customerCodes="{ model }">
|
||
<customer-select
|
||
v-model="model.customerCodes"
|
||
type="customer"
|
||
value-key="customerCode"
|
||
label-key="customerName"
|
||
code-key="customerCode"
|
||
placeholder="客户名称/客户代码/对外代码"
|
||
multiple
|
||
remote-search
|
||
option-select
|
||
/>
|
||
</template>
|
||
<template #search-orderStatuses="{ model }">
|
||
<el-select
|
||
v-model="model.orderStatuses"
|
||
multiple
|
||
filterable
|
||
clearable
|
||
collapse-tags
|
||
collapse-tags-tooltip
|
||
placeholder="请选择"
|
||
style="width: 100%"
|
||
>
|
||
<template #header>
|
||
<el-checkbox
|
||
:model-value="isAllSelected(model, 'orderStatuses', orderStatusAllValues)"
|
||
:indeterminate="isIndeterminate(model, 'orderStatuses', orderStatusAllValues)"
|
||
@change="(checked) => toggleAllSelected(model, 'orderStatuses', orderStatusAllValues, checked)"
|
||
>全选</el-checkbox>
|
||
</template>
|
||
<el-option
|
||
v-for="opt in orderStatusOptions"
|
||
:key="opt.value"
|
||
:label="opt.label"
|
||
:value="opt.value"
|
||
/>
|
||
</el-select>
|
||
</template>
|
||
<template #search-billingFailStatuses="{ model }">
|
||
<el-select
|
||
v-model="model.billingFailStatuses"
|
||
multiple
|
||
filterable
|
||
clearable
|
||
collapse-tags
|
||
collapse-tags-tooltip
|
||
placeholder="请选择"
|
||
style="width: 100%"
|
||
>
|
||
<template #header>
|
||
<el-checkbox
|
||
:model-value="isAllSelected(model, 'billingFailStatuses', billingFailAllValues)"
|
||
:indeterminate="isIndeterminate(model, 'billingFailStatuses', billingFailAllValues)"
|
||
@change="(checked) => toggleAllSelected(model, 'billingFailStatuses', billingFailAllValues, checked)"
|
||
>全选</el-checkbox>
|
||
</template>
|
||
<el-option
|
||
v-for="opt in billingFailStatusOptions"
|
||
:key="opt.value"
|
||
:label="opt.label"
|
||
:value="opt.value"
|
||
/>
|
||
</el-select>
|
||
</template>
|
||
<template #col-product="{ row }">
|
||
{{ row.productName || '-' }} ({{ row.productCode || '-' }})
|
||
</template>
|
||
<template #col-country="{ row }">
|
||
{{ row.countryName || '-' }} ({{ row.countryCode || '-' }})
|
||
</template>
|
||
<template #col-weight="{ row }">
|
||
{{ formatWeight(row.weight) }}
|
||
</template>
|
||
<template #col-volumeWeight="{ row }">
|
||
{{ formatWeight(row.volumeWeight) }}
|
||
</template>
|
||
<template #col-receiveWeight="{ row }">
|
||
{{ formatWeight(row.receiveWeight) }}
|
||
</template>
|
||
<template #col-orderStatus="{ row }">
|
||
{{ orderStatusLabel(row.orderStatus) }}
|
||
</template>
|
||
<template #col-billingFailStatus="{ row }">
|
||
<el-tag size="small" :type="billingFailTagType(row.billingFailStatus)">{{ billingFailLabel(row.billingFailStatus) }}</el-tag>
|
||
</template>
|
||
</basic-table>
|
||
<el-dialog
|
||
v-model="rebillProgressVisible"
|
||
:title="rebillDialogTitle"
|
||
width="480px"
|
||
append-to-body
|
||
:close-on-click-modal="false"
|
||
:close-on-press-escape="true"
|
||
:show-close="true"
|
||
>
|
||
<el-progress
|
||
:percentage="rebillProgressPercent"
|
||
:status="rebillProgressStatus"
|
||
:stroke-width="14"
|
||
striped
|
||
:striped-flow="batchLoading"
|
||
/>
|
||
<div class="rebill-progress__msg">{{ rebillProgressMessage }}</div>
|
||
<div class="rebill-progress__text">
|
||
总数 {{ rebillProgressTotal }}
|
||
/ 成功 {{ rebillProgressSuccess }}
|
||
/ 失败 {{ rebillProgressFail }}
|
||
</div>
|
||
<p v-if="batchLoading" class="rebill-progress__hint">计费在后台进行,可收起弹窗继续操作;请勿关闭或刷新本页</p>
|
||
<template #footer>
|
||
<el-button v-if="batchLoading" @click="rebillProgressVisible = false">收起</el-button>
|
||
<el-button
|
||
v-if="batchLoading"
|
||
type="danger"
|
||
plain
|
||
:loading="rebillCancelLoading"
|
||
@click="handleCancelRebill"
|
||
>取消计费</el-button>
|
||
<el-button v-else type="primary" @click="rebillProgressVisible = false">关闭</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
<el-dialog
|
||
v-model="exportProgressVisible"
|
||
:title="exportDialogTitle"
|
||
width="480px"
|
||
append-to-body
|
||
:close-on-click-modal="false"
|
||
:close-on-press-escape="true"
|
||
:show-close="true"
|
||
>
|
||
<el-progress
|
||
:percentage="exportProgressPercent"
|
||
:status="exportProgressStatus"
|
||
:stroke-width="14"
|
||
striped
|
||
:striped-flow="exportLoading"
|
||
/>
|
||
<div class="rebill-progress__msg">{{ exportProgressMessage }}</div>
|
||
<div class="rebill-progress__text">
|
||
导出行数 {{ exportProgressRowCount }}
|
||
</div>
|
||
<p v-if="exportLoading" class="rebill-progress__hint">导出在后台进行,可收起弹窗继续操作;请勿关闭或刷新本页</p>
|
||
<template #footer>
|
||
<el-button v-if="exportLoading" @click="exportProgressVisible = false">收起</el-button>
|
||
<el-button v-else type="primary" @click="exportProgressVisible = false">关闭</el-button>
|
||
</template>
|
||
</el-dialog>
|
||
<order-source-sync-log-drawer ref="logDrawerRef" />
|
||
<order-bill-product-check-drawer ref="productCheckDrawerRef" />
|
||
|
||
<excel-export-field-dialog
|
||
ref="exportFieldRef"
|
||
:fields="ORDER_BILL_EXPORT_FIELDS"
|
||
storage-key="exportFields:orderBill"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup name="OrderBillQuery">
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import { listOrderBill, localRebillAsync, pollLocalRebillTask, cancelLocalRebill, getLocalRebillTask } from '@/api/price/billing'
|
||
import { exportAsyncFromTable } from '@/utils/excelExport'
|
||
import ExcelExportFieldDialog from '@/components/ExcelExportFieldDialog/index.vue'
|
||
import { ORDER_BILL_EXPORT_FIELDS } from '@/utils/exportFieldDefs'
|
||
import OrderSourceSyncLogDrawer from '../../monitor/orderSource/components/OrderSourceSyncLogDrawer.vue'
|
||
import OrderBillProductCheckDrawer from './components/OrderBillProductCheckDrawer.vue'
|
||
|
||
const tableRef = ref()
|
||
const exportFieldRef = ref()
|
||
const logDrawerRef = ref()
|
||
const productCheckDrawerRef = ref()
|
||
const checkLoading = ref(false)
|
||
const exportLoading = ref(false)
|
||
const exportFinished = ref(false)
|
||
const exportProgressVisible = ref(false)
|
||
const exportProgressPercent = ref(0)
|
||
const exportProgressMessage = ref('')
|
||
const exportProgressStatus = ref(undefined)
|
||
const exportProgressRowCount = ref(0)
|
||
const exportTaskId = ref('')
|
||
let exportFloatHideTimer = null
|
||
|
||
const exportFloatVisible = computed(() => exportLoading.value || exportFinished.value)
|
||
const exportProgressDegree = computed(() => Math.round(((exportProgressPercent.value || 0) / 100) * 360))
|
||
const exportProgressText = computed(() => (exportProgressPercent.value || 0) + '%')
|
||
const exportDialogTitle = computed(() => {
|
||
if (exportLoading.value) return '异步导出进行中'
|
||
if (exportFinished.value) return '异步导出完成'
|
||
return '异步导出'
|
||
})
|
||
const exportFloatTitle = computed(() => {
|
||
if (exportLoading.value) return '导出中'
|
||
if (exportProgressStatus.value === 'exception') return '导出失败'
|
||
if (exportFinished.value) return '导出完成'
|
||
return '导出'
|
||
})
|
||
const exportFloatSummary = computed(() => {
|
||
const pct = exportProgressPercent.value || 0
|
||
const rows = exportProgressRowCount.value || 0
|
||
if (rows > 0) return pct + '% / ' + rows + ' 行'
|
||
return pct + '%'
|
||
})
|
||
const exportFloatTooltip = computed(() => exportProgressMessage.value || '点击查看详情')
|
||
|
||
function openExportProgress() {
|
||
exportProgressVisible.value = true
|
||
}
|
||
|
||
function scheduleHideExportFloat() {
|
||
if (exportFloatHideTimer) {
|
||
clearTimeout(exportFloatHideTimer)
|
||
exportFloatHideTimer = null
|
||
}
|
||
exportFloatHideTimer = window.setTimeout(() => {
|
||
if (!exportLoading.value && exportFinished.value) {
|
||
exportFinished.value = false
|
||
}
|
||
exportFloatHideTimer = null
|
||
}, 8000)
|
||
}
|
||
|
||
function applyExportProgress(t) {
|
||
const pct = Math.min(100, Number(t && t.percent) || 0)
|
||
exportProgressPercent.value = pct
|
||
exportProgressRowCount.value = Number(t && t.rowCount) || 0
|
||
const st = String((t && t.status) || '').toUpperCase()
|
||
if (st === 'PENDING') {
|
||
exportProgressMessage.value = (t && t.message) || '排队中...'
|
||
exportProgressStatus.value = undefined
|
||
} else if (st === 'RUNNING') {
|
||
exportProgressMessage.value = (t && t.message) || ('导出中 ' + pct + '%')
|
||
exportProgressStatus.value = undefined
|
||
} else if (st === 'SUCCESS') {
|
||
exportProgressMessage.value = (t && t.message) || '导出完成,正在下载'
|
||
exportProgressStatus.value = 'success'
|
||
exportProgressPercent.value = 100
|
||
} else if (st === 'FAIL' || st === 'FAILED') {
|
||
exportProgressMessage.value = (t && (t.error || t.message)) || '导出失败'
|
||
exportProgressStatus.value = 'exception'
|
||
} else {
|
||
exportProgressMessage.value = (t && t.message) || ''
|
||
}
|
||
}
|
||
|
||
const batchLoading = ref(false)
|
||
const rebillFinished = ref(false)
|
||
const rebillProgressVisible = ref(false)
|
||
const rebillProgressPercent = ref(0)
|
||
const rebillProgressMessage = ref('')
|
||
const rebillProgressStatus = ref(undefined)
|
||
const rebillProgressTotal = ref(0)
|
||
const rebillProgressSuccess = ref(0)
|
||
const rebillProgressFail = ref(0)
|
||
const rebillTaskId = ref('')
|
||
const rebillCancelLoading = ref(false)
|
||
let rebillFloatHideTimer = null
|
||
const REBILL_TASK_STORAGE_KEY = 'price:orderBill:localRebillTaskId'
|
||
|
||
function saveRebillTaskId(taskId) {
|
||
rebillTaskId.value = taskId ? String(taskId) : ''
|
||
try {
|
||
if (rebillTaskId.value) sessionStorage.setItem(REBILL_TASK_STORAGE_KEY, rebillTaskId.value)
|
||
else sessionStorage.removeItem(REBILL_TASK_STORAGE_KEY)
|
||
} catch (e) { /* ignore */ }
|
||
}
|
||
|
||
function clearRebillTaskId() {
|
||
saveRebillTaskId('')
|
||
}
|
||
|
||
function readStoredRebillTaskId() {
|
||
try {
|
||
return String(sessionStorage.getItem(REBILL_TASK_STORAGE_KEY) || '').trim()
|
||
} catch (e) {
|
||
return ''
|
||
}
|
||
}
|
||
|
||
const rebillFloatVisible = computed(() => batchLoading.value || rebillFinished.value)
|
||
const rebillProgressDegree = computed(() => Math.round(((rebillProgressPercent.value || 0) / 100) * 360))
|
||
const rebillProgressText = computed(() => (rebillProgressPercent.value || 0) + '%')
|
||
const rebillDialogTitle = computed(() => {
|
||
if (batchLoading.value) return '异步重新计费进行中'
|
||
if (rebillFinished.value) return '异步重新计费完成'
|
||
return '本地重新计费'
|
||
})
|
||
const rebillFloatTitle = computed(() => {
|
||
if (batchLoading.value) return '异步重新计费中'
|
||
if (rebillProgressStatus.value === 'exception') return '重新计费失败'
|
||
if (rebillProgressStatus.value === 'warning' && String(rebillProgressMessage.value || '').includes('已取消')) return '重新计费已取消'
|
||
if (rebillFinished.value) return '重新计费完成'
|
||
return '异步重新计费'
|
||
})
|
||
const rebillFloatSummary = computed(() =>
|
||
'成功 ' + (rebillProgressSuccess.value || 0) +
|
||
' / 失败 ' + (rebillProgressFail.value || 0) +
|
||
' / 总数 ' + (rebillProgressTotal.value || 0)
|
||
)
|
||
const rebillFloatTooltip = computed(() => rebillProgressMessage.value || '点击查看详情')
|
||
|
||
function openRebillProgress() {
|
||
rebillProgressVisible.value = true
|
||
}
|
||
|
||
function scheduleHideRebillFloat() {
|
||
if (rebillFloatHideTimer) {
|
||
clearTimeout(rebillFloatHideTimer)
|
||
rebillFloatHideTimer = null
|
||
}
|
||
rebillFloatHideTimer = window.setTimeout(() => {
|
||
if (!batchLoading.value && rebillFinished.value) {
|
||
rebillFinished.value = false
|
||
}
|
||
rebillFloatHideTimer = null
|
||
}, 8000)
|
||
}
|
||
const tableHeight = ref(480)
|
||
let tableResizeObserver = null
|
||
const MAX_ORDER_IDENTIFIERS = 1000
|
||
|
||
const defaultParams = {
|
||
orderStatuses: [3, 4, 5, 6, 7, 8, 11],
|
||
billingFailStatuses: []
|
||
}
|
||
|
||
const columns = [
|
||
{ label: '订单号', prop: 'orderNo', width: 160, fixed: 'left', showOverflowTooltip: true },
|
||
{ label: '运单号', prop: 'trackingNo', minWidth: 140, showOverflowTooltip: true },
|
||
{ label: '转单号', prop: 'transferNo', minWidth: 140, showOverflowTooltip: true },
|
||
{ label: '客户', prop: 'customerName', minWidth: 140, showOverflowTooltip: true },
|
||
{ label: '客户代码', prop: 'customerCode', width: 120 },
|
||
{ label: '产品', prop: 'product', minWidth: 160, slot: 'product' },
|
||
{ label: '国家', prop: 'country', minWidth: 130, slot: 'country' },
|
||
{ label: '件数', prop: 'pieces', align: 'center', width: 70 },
|
||
{ label: '计费重', prop: 'weight', align: 'center', width: 90, slot: 'weight' },
|
||
{ label: '体积重', prop: 'volumeWeight', align: 'center', width: 90, slot: 'volumeWeight' },
|
||
{ label: '实重', prop: 'receiveWeight', align: 'center', width: 90, slot: 'receiveWeight' },
|
||
{ label: '收件人邮编', prop: 'destPostcode', minWidth: 120, showOverflowTooltip: true },
|
||
{ label: '订单状态', prop: 'orderStatus', align: 'center', width: 110, slot: 'orderStatus' },
|
||
{ label: '计费状态', prop: 'billingFailStatus', align: 'center', width: 140, slot: 'billingFailStatus' },
|
||
{ label: '计费时间', prop: 'bizDate', align: 'center', width: 170, parseTime: true },
|
||
// { label: '下单时间', prop: 'orderTime', align: 'center', width: 170, parseTime: true },
|
||
{ label: '提货时间', prop: 'pickupDate', align: 'center', width: 170, parseTime: true },
|
||
{ label: '销售员', prop: 'belongSaler', minWidth: 100, showOverflowTooltip: true },
|
||
{ label: '结算员', prop: 'settlementMan', minWidth: 100, showOverflowTooltip: true },
|
||
{ label: '客服员', prop: 'serviceMan', minWidth: 100, showOverflowTooltip: true }
|
||
]
|
||
|
||
const orderStatusMap = {
|
||
0: '草稿订单',
|
||
1: '确认订单',
|
||
2: '已中转',
|
||
3: '已收货',
|
||
4: '已装箱',
|
||
5: '已出库',
|
||
6: '转运中',
|
||
7: '已上网',
|
||
8: '已签收',
|
||
9: '已退件',
|
||
10: '已取消',
|
||
11: '已复核'
|
||
}
|
||
|
||
const orderStatusOptions = Object.keys(orderStatusMap).map(k => ({ label: orderStatusMap[k], value: Number(k) }))
|
||
|
||
const billingFailStatusOptions = [
|
||
{ label: '计费成功', value: 0 },
|
||
{ label: '销售价未匹配产品', value: 1 },
|
||
{ label: '销售价未匹配国家', value: 2 },
|
||
{ label: '销售价未匹配重量段', value: 3 },
|
||
{ label: '产品价未匹配产品', value: 23 },
|
||
{ label: '产品价未匹配国家', value: 24 },
|
||
{ label: '产品价未匹配重量段', value: 25 },
|
||
{ label: '未绑定套餐', value: 4 },
|
||
{ label: '套餐未启用/无生效版本', value: 5 },
|
||
{ label: '已生成应收账单', value: 6 },
|
||
{ label: '产品档案缺失', value: 7 },
|
||
{ label: '未命中价格', value: 8 },
|
||
{ label: '分区无价格', value: 9 },
|
||
{ label: '邮编必填', value: 10 },
|
||
{ label: '邮编未匹配分区', value: 11 },
|
||
{ label: '上游订单不存在', value: 12 },
|
||
{ label: '上游订单号为空', value: 13 },
|
||
{ label: '上游客户为空', value: 14 },
|
||
{ label: '上游产品为空', value: 15 },
|
||
{ label: '上游重量无效', value: 16 },
|
||
{ label: '计费时间为空', value: 17 },
|
||
{ label: '退费订单无法解析', value: 18 },
|
||
{ label: '客户代码为空', value: 19 },
|
||
{ label: '客户已停用', value: 20 },
|
||
{ label: '运费为空', value: 21 },
|
||
{ label: '未知失败', value: 22 }
|
||
]
|
||
|
||
const orderStatusAllValues = computed(() => orderStatusOptions.map(o => o.value))
|
||
const billingFailAllValues = computed(() => billingFailStatusOptions.map(o => o.value))
|
||
|
||
/** 多选下拉「全选」复选框的通用逻辑,订单状态 / 计费状态共用 */
|
||
function selectedValues(model, key) {
|
||
const value = model?.[key]
|
||
return Array.isArray(value) ? value : []
|
||
}
|
||
|
||
/** 模板中 computed 会自动解包为数组,script 内直接引用时是 ref,两种形态都兼容 */
|
||
function resolveAllValues(allValues) {
|
||
if (Array.isArray(allValues)) return allValues
|
||
const value = allValues?.value
|
||
return Array.isArray(value) ? value : []
|
||
}
|
||
|
||
function isAllSelected(model, key, allValues) {
|
||
const all = resolveAllValues(allValues)
|
||
const selected = selectedValues(model, key)
|
||
return all.length > 0 && selected.length === all.length
|
||
}
|
||
|
||
function isIndeterminate(model, key, allValues) {
|
||
const all = resolveAllValues(allValues)
|
||
const selected = selectedValues(model, key)
|
||
return selected.length > 0 && selected.length < all.length
|
||
}
|
||
|
||
function toggleAllSelected(model, key, allValues, checked) {
|
||
if (!model) return
|
||
model[key] = checked ? [...resolveAllValues(allValues)] : []
|
||
}
|
||
|
||
const searchColumns = [
|
||
{ label: '单号', prop: 'orderIdentifiers', placeholder: '订单号/运单号/转单号' },
|
||
{ label: '客户', prop: 'customerCodes', placeholder: '客户名称/客户代码/对外代码' },
|
||
{
|
||
label: '计费时间',
|
||
prop: 'bizDateRange',
|
||
component: 'datetimerange',
|
||
colSpan: 2,
|
||
props: {
|
||
clearable: true,
|
||
defaultTime: [new Date(2000, 0, 1, 0, 0, 0), new Date(2000, 0, 1, 23, 59, 59)]
|
||
}
|
||
},
|
||
{
|
||
label: '提货时间',
|
||
prop: 'pickupDateRange',
|
||
component: 'datetimerange',
|
||
colSpan: 2,
|
||
props: {
|
||
clearable: true,
|
||
defaultTime: [new Date(2000, 0, 1, 0, 0, 0), new Date(2000, 0, 1, 23, 59, 59)]
|
||
}
|
||
},
|
||
{
|
||
label: '订单状态',
|
||
prop: 'orderStatuses',
|
||
component: 'select',
|
||
options: orderStatusOptions,
|
||
placeholder: '请选择',
|
||
props: {
|
||
multiple: true,
|
||
clearable: true,
|
||
collapseTags: true,
|
||
collapseTagsTooltip: true
|
||
}
|
||
},
|
||
{
|
||
label: '计费状态',
|
||
prop: 'billingFailStatuses',
|
||
component: 'select',
|
||
options: billingFailStatusOptions,
|
||
placeholder: '请选择',
|
||
props: {
|
||
multiple: true,
|
||
filterable: true,
|
||
clearable: true,
|
||
collapseTags: true,
|
||
collapseTagsTooltip: true
|
||
}
|
||
}
|
||
]
|
||
|
||
function applySearchRanges(params) {
|
||
const { bizDateRange, pickupDateRange, orderIdentifiers: rawOrderIdentifiers, ...rest } = params || {}
|
||
const query = { ...rest }
|
||
const orderIdentifiers = parseOrderIdentifiers(rawOrderIdentifiers)
|
||
if (orderIdentifiers.length) query.orderIdentifiers = orderIdentifiers
|
||
const rangeParams = {}
|
||
if (Array.isArray(bizDateRange) && (bizDateRange[0] || bizDateRange[1])) {
|
||
rangeParams.beginBizDate = bizDateRange[0] || null
|
||
rangeParams.endBizDate = bizDateRange[1] || null
|
||
}
|
||
if (Array.isArray(pickupDateRange) && (pickupDateRange[0] || pickupDateRange[1])) {
|
||
rangeParams.beginPickupDate = pickupDateRange[0] || null
|
||
rangeParams.endPickupDate = pickupDateRange[1] || null
|
||
}
|
||
if (Object.keys(rangeParams).length) {
|
||
query.params = { ...(query.params || {}), ...rangeParams }
|
||
}
|
||
return query
|
||
}
|
||
|
||
function parseOrderIdentifiers(raw) {
|
||
const values = Array.isArray(raw) ? raw : [raw]
|
||
const identifiers = values
|
||
.filter(v => v !== null && v !== undefined)
|
||
.flatMap(v => String(v).split(/[\s,,;;]+/))
|
||
.map(v => v.trim())
|
||
.filter(Boolean)
|
||
return [...new Set(identifiers)]
|
||
}
|
||
|
||
function getUniqueOrderIdentifierCount(raw) {
|
||
return parseOrderIdentifiers(raw).length
|
||
}
|
||
|
||
function validateOrderIdentifierLimit(raw) {
|
||
const count = getUniqueOrderIdentifierCount(raw)
|
||
if (count <= MAX_ORDER_IDENTIFIERS) return true
|
||
ElMessage.warning(`单号去重后最多输入 ${MAX_ORDER_IDENTIFIERS} 个,当前 ${count} 个`)
|
||
return false
|
||
}
|
||
|
||
function queryList(params) {
|
||
const query = applySearchRanges(params)
|
||
if (!validateOrderIdentifierLimit(query.orderIdentifiers)) return Promise.reject(new Error('order identifier limit exceeded'))
|
||
return listOrderBill(query)
|
||
}
|
||
|
||
async function handleExport() {
|
||
if (exportLoading.value) return
|
||
const params = tableRef.value?.getQueryParams?.() || {}
|
||
if (!validateOrderIdentifierLimit(params.orderIdentifiers)) return
|
||
const fields = await exportFieldRef.value?.open?.()
|
||
if (!fields || !fields.length) return
|
||
if (exportFloatHideTimer) {
|
||
clearTimeout(exportFloatHideTimer)
|
||
exportFloatHideTimer = null
|
||
}
|
||
exportLoading.value = true
|
||
exportFinished.value = false
|
||
exportProgressVisible.value = false
|
||
exportProgressPercent.value = 0
|
||
exportProgressRowCount.value = 0
|
||
exportProgressStatus.value = undefined
|
||
exportProgressMessage.value = '导出任务已提交'
|
||
exportTaskId.value = ''
|
||
try {
|
||
const result = await exportAsyncFromTable({
|
||
baseUrl: '/price/billing/orderBill/export',
|
||
tableRef: tableRef.value,
|
||
normalize: (raw) => {
|
||
const q = applySearchRanges(raw)
|
||
q.exportFields = fields
|
||
return q
|
||
},
|
||
filePrefix: 'order_bill',
|
||
requireFilter: true,
|
||
showLoading: false,
|
||
showSuccessMessage: true,
|
||
showErrorMessage: true,
|
||
onTaskId: (id) => { exportTaskId.value = id },
|
||
onProgress: (task) => applyExportProgress(task || {})
|
||
})
|
||
if (result && result.task) {
|
||
applyExportProgress({ ...(result.task || {}), status: 'SUCCESS', percent: 100 })
|
||
exportFinished.value = true
|
||
scheduleHideExportFloat()
|
||
} else if (!exportTaskId.value) {
|
||
// filter rejected / cancelled before task created
|
||
exportFinished.value = false
|
||
exportProgressPercent.value = 0
|
||
exportProgressMessage.value = ''
|
||
exportProgressStatus.value = undefined
|
||
} else if (exportProgressStatus.value !== 'exception') {
|
||
exportProgressStatus.value = 'exception'
|
||
exportProgressMessage.value = exportProgressMessage.value || '导出失败'
|
||
exportFinished.value = true
|
||
scheduleHideExportFloat()
|
||
} else {
|
||
exportFinished.value = true
|
||
scheduleHideExportFloat()
|
||
}
|
||
} catch (e) {
|
||
exportProgressStatus.value = 'exception'
|
||
exportProgressMessage.value = (e && e.message) || '导出失败'
|
||
exportFinished.value = true
|
||
scheduleHideExportFloat()
|
||
} finally {
|
||
exportLoading.value = false
|
||
}
|
||
}
|
||
|
||
|
||
function currentIdentifiers() {
|
||
const q = tableRef.value?.getQueryParams?.() || {}
|
||
return parseOrderIdentifiers(q.orderIdentifiers)
|
||
}
|
||
|
||
function buildRebillFilter() {
|
||
const q = applySearchRanges(tableRef.value?.getQueryParams?.() || {})
|
||
const filter = {
|
||
scopeMode: 'ALL',
|
||
orderIdentifiers: currentIdentifiers(),
|
||
customerCodes: Array.isArray(q.customerCodes) ? q.customerCodes : [],
|
||
productCodes: Array.isArray(q.productCodes) ? q.productCodes : [],
|
||
orderStatuses: Array.isArray(q.orderStatuses) ? q.orderStatuses : [],
|
||
billingFailStatuses: Array.isArray(q.billingFailStatuses) ? q.billingFailStatuses : [],
|
||
beginBizDate: q.params?.beginBizDate || null,
|
||
endBizDate: q.params?.endBizDate || null,
|
||
beginPickupDate: q.params?.beginPickupDate || null,
|
||
endPickupDate: q.params?.endPickupDate || null
|
||
}
|
||
return filter
|
||
}
|
||
|
||
function hasRebillFilter(filter) {
|
||
const ids = filter.orderIdentifiers || []
|
||
if (ids.length) return true
|
||
const hasCustomer = Array.isArray(filter.customerCodes) && filter.customerCodes.length > 0
|
||
const hasBizDate = !!(filter.beginBizDate && filter.endBizDate)
|
||
const hasPickupDate = !!(filter.beginPickupDate && filter.endPickupDate)
|
||
return hasCustomer && (hasBizDate || hasPickupDate)
|
||
}
|
||
|
||
function buildCheckQuery() {
|
||
const q = tableRef.value?.getQueryParams?.() || {}
|
||
return applySearchRanges(q)
|
||
}
|
||
|
||
function handleCheckProducts() {
|
||
const q = buildCheckQuery()
|
||
if (!validateOrderIdentifierLimit(q.orderIdentifiers)) return
|
||
const hasCustomer = Array.isArray(q.customerCodes) && q.customerCodes.length > 0
|
||
const begin = q.params?.beginBizDate
|
||
const end = q.params?.endBizDate
|
||
if (!hasCustomer || !begin || !end) {
|
||
ElMessage.warning('检测产品需先选择客户并选择计费时间')
|
||
return
|
||
}
|
||
checkLoading.value = true
|
||
try {
|
||
productCheckDrawerRef.value?.open(q)
|
||
} finally {
|
||
checkLoading.value = false
|
||
}
|
||
}
|
||
|
||
function applyRebillProgress(t) {
|
||
const total = Number(t.total) || 0
|
||
const success = Number(t.success) || 0
|
||
const fail = Number(t.fail) || 0
|
||
rebillProgressPercent.value = Math.min(100, Number(t.percent) || 0)
|
||
rebillProgressTotal.value = total
|
||
rebillProgressSuccess.value = success
|
||
rebillProgressFail.value = fail
|
||
if (t.status === 'PENDING') {
|
||
rebillProgressMessage.value = '排队中…'
|
||
} else if (t.status === 'RUNNING' && String(t.message || '').startsWith('cancelling')) {
|
||
rebillProgressMessage.value = '正在取消,等待当前批次结束… ' + success + '/' + total
|
||
} else if (t.status === 'RUNNING') {
|
||
rebillProgressMessage.value = '正在重新计费 ' + success + '/' + total +
|
||
(fail ? ('(失败 ' + fail + ')') : '')
|
||
}
|
||
}
|
||
|
||
function finishRebillFromTask(task, cancelled) {
|
||
const success = Number(task && task.success) || 0
|
||
const fail = Number(task && task.fail) || 0
|
||
rebillProgressPercent.value = Math.min(100, Number(task && task.percent) || 100)
|
||
rebillProgressStatus.value = cancelled ? 'warning' : (fail > 0 ? 'warning' : 'success')
|
||
rebillProgressTotal.value = Number(task && task.total) || rebillProgressTotal.value
|
||
rebillProgressSuccess.value = success
|
||
rebillProgressFail.value = fail
|
||
rebillProgressMessage.value = cancelled
|
||
? ('已取消:成功 ' + success + ' / 失败 ' + fail)
|
||
: ('完成:成功 ' + success + ' / 失败 ' + fail)
|
||
rebillFinished.value = true
|
||
clearRebillTaskId()
|
||
tableRef.value?.refresh?.()
|
||
if (cancelled) ElMessage.warning(rebillProgressMessage.value)
|
||
else ElMessage.success(rebillProgressMessage.value)
|
||
scheduleHideRebillFloat()
|
||
}
|
||
|
||
async function resumeRebillTask(taskId) {
|
||
if (!taskId || batchLoading.value) return
|
||
batchLoading.value = true
|
||
rebillFinished.value = false
|
||
rebillCancelLoading.value = false
|
||
saveRebillTaskId(taskId)
|
||
rebillProgressVisible.value = false
|
||
rebillProgressPercent.value = 0
|
||
rebillProgressStatus.value = undefined
|
||
rebillProgressMessage.value = '恢复进度中…'
|
||
rebillProgressTotal.value = 0
|
||
rebillProgressSuccess.value = 0
|
||
rebillProgressFail.value = 0
|
||
let superseded = false
|
||
try {
|
||
const result = await pollLocalRebillTask(taskId, applyRebillProgress)
|
||
finishRebillFromTask(result && result.task, !!(result && result.cancelled))
|
||
} catch (e) {
|
||
if (e && e.superseded) {
|
||
superseded = true
|
||
return
|
||
}
|
||
const task = e && e.task
|
||
rebillProgressStatus.value = 'exception'
|
||
rebillProgressMessage.value = (e && e.message) || '重新计费失败'
|
||
rebillFinished.value = true
|
||
if (task) {
|
||
rebillProgressPercent.value = Math.min(100, Number(task.percent) || 100)
|
||
rebillProgressTotal.value = Number(task.total) || 0
|
||
rebillProgressSuccess.value = Number(task.success) || 0
|
||
rebillProgressFail.value = Number(task.fail) || 0
|
||
tableRef.value?.refresh?.()
|
||
}
|
||
clearRebillTaskId()
|
||
rebillProgressVisible.value = true
|
||
scheduleHideRebillFloat()
|
||
} finally {
|
||
// Keep loading if a newer poll replaced this one.
|
||
if (!superseded) batchLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function tryRestoreRebillProgress() {
|
||
const taskId = readStoredRebillTaskId()
|
||
if (!taskId) return
|
||
try {
|
||
const res = await getLocalRebillTask(taskId)
|
||
const task = (res && res.data) || {}
|
||
const st = String(task.status || '').toUpperCase()
|
||
if (st === 'PENDING' || st === 'RUNNING') {
|
||
applyRebillProgress(task)
|
||
resumeRebillTask(taskId)
|
||
return
|
||
}
|
||
if (st === 'SUCCESS' || st === 'CANCELLED' || st === 'FAIL') {
|
||
clearRebillTaskId()
|
||
return
|
||
}
|
||
clearRebillTaskId()
|
||
} catch (e) {
|
||
clearRebillTaskId()
|
||
}
|
||
}
|
||
|
||
async function runLocalRebill(payload, confirmText) {
|
||
if (batchLoading.value) {
|
||
openRebillProgress()
|
||
return
|
||
}
|
||
try {
|
||
await ElMessageBox.confirm(confirmText, '二次确认', { type: 'warning' })
|
||
} catch (e) {
|
||
return
|
||
}
|
||
if (rebillFloatHideTimer) {
|
||
clearTimeout(rebillFloatHideTimer)
|
||
rebillFloatHideTimer = null
|
||
}
|
||
batchLoading.value = true
|
||
rebillFinished.value = false
|
||
rebillCancelLoading.value = false
|
||
clearRebillTaskId()
|
||
rebillProgressVisible.value = true
|
||
rebillProgressPercent.value = 0
|
||
rebillProgressStatus.value = undefined
|
||
rebillProgressMessage.value = '任务提交中…'
|
||
rebillProgressTotal.value = 0
|
||
rebillProgressSuccess.value = 0
|
||
rebillProgressFail.value = 0
|
||
window.setTimeout(() => {
|
||
if (batchLoading.value) rebillProgressVisible.value = false
|
||
}, 600)
|
||
let superseded = false
|
||
try {
|
||
const result = await localRebillAsync(
|
||
payload,
|
||
applyRebillProgress,
|
||
{ getTaskId: (id) => { saveRebillTaskId(id) } }
|
||
)
|
||
finishRebillFromTask(result && result.task, !!(result && result.cancelled))
|
||
} catch (e) {
|
||
if (e && e.superseded) {
|
||
superseded = true
|
||
return
|
||
}
|
||
const task = e && e.task
|
||
rebillProgressStatus.value = 'exception'
|
||
rebillProgressMessage.value = (e && e.message) || '重新计费失败'
|
||
rebillFinished.value = true
|
||
if (task) {
|
||
rebillProgressPercent.value = Math.min(100, Number(task.percent) || 100)
|
||
rebillProgressTotal.value = Number(task.total) || 0
|
||
rebillProgressSuccess.value = Number(task.success) || 0
|
||
rebillProgressFail.value = Number(task.fail) || 0
|
||
tableRef.value?.refresh?.()
|
||
}
|
||
clearRebillTaskId()
|
||
rebillProgressVisible.value = true
|
||
scheduleHideRebillFloat()
|
||
} finally {
|
||
if (!superseded) batchLoading.value = false
|
||
}
|
||
}
|
||
|
||
async function handleCancelRebill() {
|
||
if (!batchLoading.value || !rebillTaskId.value || rebillCancelLoading.value) return
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
'确认取消当前重新计费?已计费完成的订单会保留,未开始的将停止。',
|
||
'取消计费',
|
||
{ type: 'warning' }
|
||
)
|
||
} catch (e) {
|
||
return
|
||
}
|
||
rebillCancelLoading.value = true
|
||
try {
|
||
const res = await cancelLocalRebill(rebillTaskId.value)
|
||
const task = (res && res.data) || {}
|
||
rebillProgressPercent.value = Math.min(100, Number(task.percent) || rebillProgressPercent.value || 0)
|
||
rebillProgressTotal.value = Number(task.total) || rebillProgressTotal.value
|
||
rebillProgressSuccess.value = Number(task.success) || rebillProgressSuccess.value
|
||
rebillProgressFail.value = Number(task.fail) || rebillProgressFail.value
|
||
rebillProgressMessage.value = '取消请求已提交,等待当前批次结束…'
|
||
ElMessage.success('已提交取消')
|
||
} catch (e) {
|
||
// global interceptor shows error
|
||
} finally {
|
||
rebillCancelLoading.value = false
|
||
}
|
||
}
|
||
|
||
function openFilterRebill() {
|
||
const filter = buildRebillFilter()
|
||
const identifiers = filter.orderIdentifiers || []
|
||
if (!validateOrderIdentifierLimit(identifiers)) return
|
||
if (!hasRebillFilter(filter)) {
|
||
ElMessage.warning('请输入单号,或选择客户并选择计费时间或提货时间后再重新计费;也可勾选订单后点重新计费选中')
|
||
return
|
||
}
|
||
if (identifiers.length > MAX_ORDER_IDENTIFIERS) {
|
||
ElMessage.warning('单次最多重新计费 ' + MAX_ORDER_IDENTIFIERS + ' 个单号')
|
||
return
|
||
}
|
||
let confirmText
|
||
if (identifiers.length) {
|
||
confirmText = '将按 ' + identifiers.length + ' 个单号从本地订单表重新计费(不查上游),确认继续?'
|
||
} else {
|
||
confirmText = '将按当前列表筛选(客户 + 计费时间/提货时间等)从本地订单表重新计费(不查上游),确认继续?'
|
||
}
|
||
runLocalRebill(filter, confirmText)
|
||
}
|
||
|
||
function handleBatchRebill(selection) {
|
||
const rows = selection || []
|
||
if (!rows.length) return
|
||
const missingTime = rows.filter(row => !(row.bizDate || row.pickupDate || row.orderTime)).length
|
||
if (missingTime) {
|
||
ElMessage.warning(missingTime + ' 条没有计费时间,无法重新计费')
|
||
return
|
||
}
|
||
if (rows.length > 200) {
|
||
ElMessage.warning('选中重新计费最多 200 条')
|
||
return
|
||
}
|
||
const billIds = rows.map(r => r.billId).filter(Boolean)
|
||
if (!billIds.length) {
|
||
ElMessage.warning('选中行缺少 billId,无法重新计费')
|
||
return
|
||
}
|
||
runLocalRebill(
|
||
{ scopeMode: 'SELECTED', billIds },
|
||
'确认用本地订单数据重新计费选中的 ' + rows.length + ' 条订单?'
|
||
)
|
||
}
|
||
|
||
function formatWeight(val) {
|
||
if (val == null || val === '') return '-'
|
||
const num = Number(val)
|
||
if (Number.isNaN(num)) return val
|
||
return num.toFixed(3)
|
||
}
|
||
|
||
function orderStatusLabel(status) {
|
||
if (status === null || status === undefined || status === '') return '-'
|
||
const key = Number(status)
|
||
if (Object.prototype.hasOwnProperty.call(orderStatusMap, key)) return orderStatusMap[key]
|
||
return String(status)
|
||
}
|
||
|
||
function billingFailLabel(status) {
|
||
const code = status == null ? 0 : Number(status)
|
||
const hit = billingFailStatusOptions.find(item => item.value === code)
|
||
return hit ? hit.label : '未知失败'
|
||
}
|
||
|
||
function billingFailTagType(status) {
|
||
const code = status == null ? 0 : Number(status)
|
||
return code > 0 ? 'danger' : 'success'
|
||
}
|
||
|
||
function updateTableHeight() {
|
||
const tableEl = document.querySelector('.order-bill-query > .basic-table .el-table, .order-bill-query > .basic-table .basic-table-virtual')
|
||
if (!tableEl) return
|
||
const top = tableEl.getBoundingClientRect().top
|
||
tableHeight.value = Math.max(240, Math.floor(window.innerHeight - top - 58 - 12))
|
||
}
|
||
|
||
onMounted(() => {
|
||
nextTick(() => {
|
||
updateTableHeight()
|
||
const form = document.querySelector('.order-bill-query > .basic-table .basic-form')
|
||
if (form && typeof ResizeObserver !== 'undefined') {
|
||
tableResizeObserver = new ResizeObserver(() => updateTableHeight())
|
||
tableResizeObserver.observe(form)
|
||
}
|
||
})
|
||
window.addEventListener('resize', updateTableHeight)
|
||
tryRestoreRebillProgress()
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
window.removeEventListener('resize', updateTableHeight)
|
||
if (tableResizeObserver) {
|
||
tableResizeObserver.disconnect()
|
||
tableResizeObserver = null
|
||
}
|
||
if (rebillFloatHideTimer) {
|
||
clearTimeout(rebillFloatHideTimer)
|
||
rebillFloatHideTimer = null
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.order-bill-query :deep(.basic-form .el-form-item .el-select),
|
||
.order-bill-query :deep(.basic-form .el-form-item .el-input),
|
||
.order-bill-query :deep(.basic-form .el-form-item .el-date-editor) {
|
||
width: 100%;
|
||
}
|
||
.order-identifier-search { width: 100%; }
|
||
.rebill-progress__msg { margin-top: 12px; color: var(--el-text-color-regular); }
|
||
.rebill-progress__text { margin-top: 8px; font-size: 13px; color: var(--el-text-color-secondary); }
|
||
.rebill-progress__hint { margin-top: 12px; font-size: 12px; color: var(--el-color-warning); }
|
||
.order-bill-query .export-status-float {
|
||
top: 92px;
|
||
}
|
||
.order-bill-query .export-status-float.is-offset {
|
||
top: 178px;
|
||
}
|
||
.order-bill-query .retry-status-float {
|
||
position: fixed;
|
||
top: 92px;
|
||
right: 24px;
|
||
z-index: 3001;
|
||
}
|
||
.order-bill-query .retry-status-button {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
min-width: 200px;
|
||
min-height: 68px;
|
||
padding: 6px 16px 6px 7px;
|
||
border-radius: 36px;
|
||
}
|
||
.order-bill-query .retry-progress-ring {
|
||
position: relative;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex: 0 0 54px;
|
||
width: 54px;
|
||
height: 54px;
|
||
border-radius: 50%;
|
||
background: conic-gradient(
|
||
var(--el-color-primary) 0deg var(--retry-progress, 45deg),
|
||
var(--el-color-primary-light-8) var(--retry-progress, 45deg) 360deg
|
||
);
|
||
}
|
||
.order-bill-query .retry-progress-ring::after {
|
||
position: absolute;
|
||
inset: 5px;
|
||
content: '';
|
||
border-radius: 50%;
|
||
background: var(--el-bg-color);
|
||
}
|
||
.order-bill-query .retry-progress-value {
|
||
position: relative;
|
||
z-index: 1;
|
||
color: var(--el-color-primary);
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
line-height: 1;
|
||
}
|
||
.order-bill-query .retry-status-text {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
gap: 3px;
|
||
line-height: 1.2;
|
||
white-space: nowrap;
|
||
}
|
||
.order-bill-query .retry-status-text strong {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
}
|
||
.order-bill-query .retry-status-text small {
|
||
color: var(--el-text-color-secondary);
|
||
font-size: 11px;
|
||
}
|
||
</style>
|