Files
BMS/bms-front/src/views/price/orderBill/index.vue
T
2026-11-06 16:13:03 +08:00

1119 lines
39 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>