Files
BMS/bms-front/src/views/monitor/orderSource/index.vue
T
2026-10-27 00:04:21 +08:00

628 lines
21 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-source-page">
<div v-if="syncFloatVisible" class="retry-status-float">
<el-tooltip :content="syncFloatTooltip" placement="bottom">
<el-button class="retry-status-button" type="primary" plain size="small" @click="openFilterSync()">
<span class="retry-progress-ring" :style="{ '--retry-progress': syncProgressDegree + 'deg' }" aria-hidden="true">
<span class="retry-progress-value">{{ syncProgressText }}</span>
</span>
<span class="retry-status-text">
<strong>{{ syncFloatTitle }}</strong>
<small>{{ syncFloatSummary }}</small>
</span>
</el-button>
</el-tooltip>
</div>
<basic-table
ref="tableRef"
:columns="columns"
:search-columns="searchColumns"
:default-params="defaultParams"
:query="queryList"
row-key="orderId"
:auto-load="false"
selection
:search-per-row="4"
:page-sizes="[100, 200, 300, 400]"
:page-size="100"
virtual
>
<template #toolbar="{ selection }">
<el-col :span="1.5">
<el-button
type="primary"
plain
icon="Refresh"
v-hasPermi="['monitor:orderSource:sync']"
@click="openFilterSync()"
>同步落库</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="warning"
plain
icon="Search"
v-hasPermi="['monitor:orderSource:list']"
@click="openOrderCompare()"
>订单比对</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="success"
plain
icon="Search"
:loading="checkLoading"
v-hasPermi="['monitor:orderSource: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"
v-hasPermi="['monitor:orderSource: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="['monitor:orderSource:sync']"
@click="handleBatchSync(selection)"
>同步选中</el-button>
</el-col>
</template>
<template #col-product="{ row }">
{{ row.productName || '-' }} ({{ row.productCode || '-' }})
</template>
<template #col-country="{ row }">
{{ row.countryName || '-' }} ({{ row.country || '-' }})
</template>
<template #search-orderStatuses="{ model }">
<el-select
v-model="model.orderStatuses"
multiple
clearable
collapse-tags
collapse-tags-tooltip
placeholder="请选择"
style="width: 100%"
>
<template #header>
<el-checkbox
:model-value="isAllOrderStatusSelected(model)"
:indeterminate="isOrderStatusIndeterminate(model)"
@change="(checked) => toggleAllOrderStatus(model, checked)"
>全选</el-checkbox>
</template>
<el-option
v-for="opt in orderStatusOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</template>
<template #col-orderStatus="{ row }">
{{ orderStatusLabel(row.orderStatus) }}
</template>
<!-- <template #col-action="{ row }">
<el-button
link
type="warning"
icon="Refresh"
:loading="syncingKey === rowKey(row)"
v-hasPermi="['monitor:orderSource:sync']"
@click="handleSyncOne(row)"
>同步落库</el-button>
</template> -->
</basic-table>
<order-source-sync-dialog
ref="syncDialogRef"
@progress="onSyncProgress"
@success="onSyncSuccess"
/>
<order-source-sync-log-drawer ref="logDrawerRef" />
<order-source-product-check-drawer ref="productCheckDrawerRef" />
<order-source-compare-drawer ref="compareDrawerRef" @fill-search="onCompareFillSearch" />
<excel-export-field-dialog
ref="exportFieldRef"
:fields="ORDER_SOURCE_EXPORT_FIELDS"
storage-key="exportFields:orderSource"
/>
</div>
</template>
<script setup name="OrderSource">
import { exportAsync } from '@/utils/excelExport'
import ExcelExportFieldDialog from '@/components/ExcelExportFieldDialog/index.vue'
import { ORDER_SOURCE_EXPORT_FIELDS } from '@/utils/exportFieldDefs'
import { ElMessage } from 'element-plus'
import { listOrderSource, listOrderSourceByTransferNos, syncOrderSourceSelected } from '@/api/monitor/orderSource'
import OrderSourceSyncDialog from './components/OrderSourceSyncDialog.vue'
import OrderSourceSyncLogDrawer from './components/OrderSourceSyncLogDrawer.vue'
import OrderSourceProductCheckDrawer from './components/OrderSourceProductCheckDrawer.vue'
import OrderSourceCompareDrawer from './components/OrderSourceCompareDrawer.vue'
const { proxy } = getCurrentInstance()
const tableRef = ref()
const exportFieldRef = ref()
const syncDialogRef = ref()
const logDrawerRef = ref()
const productCheckDrawerRef = ref()
const compareDrawerRef = ref()
const checkLoading = ref(false)
const syncingKey = ref(null)
const batchLoading = ref(false)
const syncFloat = ref(null)
/** 比对回填搜索模式:走专用转单号接口,不影响普通列表 */
const transferFillMode = ref(false)
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 defaultParams = {
orderStatuses: [3, 4, 5, 6, 7, 8, 11]
}
const searchColumns = [
{
// 客户名称或客户代码混合录入,一行一个
label: '客户',
prop: 'customerKeywords',
component: 'textarea',
placeholder: '客户名称/客户编码/对外代码,一行一个',
props: {
rows: 2,
autosize: { minRows: 1, maxRows: 4 },
clearable: true
}
},
{
// 订单号/运单号/转单号混合录入,一行一个;有单号即可查询,不要求客户+提货时间
label: '单号',
prop: 'orderIdentifiers',
component: 'textarea',
placeholder: '订单号/运单号/转单号,一行一个,单次最多 1 万个',
props: {
rows: 2,
autosize: { minRows: 1, maxRows: 4 },
clearable: true
}
},
{
label: '提货时间',
prop: 'dateRange',
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
}
},
]
const columns = [
{ label: '客户订单号', prop: 'orderId', minWidth: 160, showOverflowTooltip: true },
{ label: '运单号', prop: 'orgOrderId', minWidth: 150, showOverflowTooltip: true },
{ label: '转单号', prop: 'transferOrderId', minWidth: 160, showOverflowTooltip: true },
{ label: '轨迹ID', prop: 'trackId', minWidth: 140, showOverflowTooltip: true },
{ label: '订单状态', prop: 'orderStatus', align: 'center', width: 110, slot: 'orderStatus' },
{ label: '客户代码', prop: 'customerCode', minWidth: 110, showOverflowTooltip: true },
{ label: '客户名称', prop: 'customerName', minWidth: 140, showOverflowTooltip: true },
{ label: '产品', prop: 'product', minWidth: 180, slot: 'product', showOverflowTooltip: true },
{ label: '产品底级代码', prop: 'productFloorCode', minWidth: 130, showOverflowTooltip: true },
{ label: '国家', prop: 'country', minWidth: 140, slot: 'country', showOverflowTooltip: true },
{ label: '收件人邮编', prop: 'consigneePostcode', minWidth: 120, showOverflowTooltip: true },
{ label: '客户确认重', prop: 'confirmWeight', align: 'center', width: 110 },
{ label: '计费重', prop: 'dimensionalWeightFactor', align: 'center', width: 100 },
{ label: '体积重', prop: 'volumeWeight', align: 'center', width: 100 },
{ label: '实重', prop: 'weight', align: 'center', width: 100 },
{ label: '提货时间', prop: 'pickupDate', align: 'center', width: 170 }
// { label: '操作', prop: 'action', align: 'center', width: 120, fixed: 'right', slot: 'action', showInToolbar: false }
]
const syncFloatVisible = computed(() => {
const p = syncFloat.value
return !!(p && (p.loading || p.finished))
})
const syncProgressDegree = computed(() => Math.round(((syncFloat.value?.percent || 0) / 100) * 360))
const syncProgressText = computed(() => (syncFloat.value?.percent || 0) + '%')
const syncFloatTitle = computed(() => {
const st = (syncFloat.value?.status || '').toUpperCase()
if (syncFloat.value?.loading) return '异步同步落库中'
if (st === 'SUCCESS') return '同步落库完成'
if (st === 'FAIL') return '同步落库失败'
return '异步同步落库'
})
const syncFloatSummary = computed(() => {
const p = syncFloat.value || {}
return '成功 ' + (p.ok || 0) + ' / 失败 ' + (p.fail || 0) + ' / 总数 ' + (p.total || 0)
})
const syncFloatTooltip = computed(() => syncFloat.value?.message || '点击查看详情')
const orderStatusAllValues = computed(() => orderStatusOptions.map(o => o.value))
function selectedOrderStatuses(model) {
return Array.isArray(model?.orderStatuses) ? model.orderStatuses : []
}
function isAllOrderStatusSelected(model) {
const selected = selectedOrderStatuses(model)
const all = orderStatusAllValues.value
return all.length > 0 && selected.length === all.length
}
function isOrderStatusIndeterminate(model) {
const selected = selectedOrderStatuses(model)
const all = orderStatusAllValues.value
return selected.length > 0 && selected.length < all.length
}
function toggleAllOrderStatus(model, checked) {
if (!model) return
model.orderStatuses = checked ? [...orderStatusAllValues.value] : []
}
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 rowKey(row) {
return (row && (row.orderId || row.transferOrderId || row.orgOrderId)) || ''
}
/** 单次查询/同步可接受的单号上限(后端按 900/批分批走索引,另有服务端 20 万防 OOM 硬兜底) */
const MAX_ORDER_IDENTIFIERS = 10000
/** 产品检测走单条 SQL(Oracle IN 上限 1000),与后端保持一致 */
const MAX_PRODUCT_CHECK_IDENTIFIERS = 1000
/** 长文本拆成数组:一行一条,兼容逗号/分号/顿号分隔;不按空格拆(客户名称可能含空格) */
function splitLines(text) {
return String(text || '')
.split(/[\r\n,;,;、]+/)
.map(s => s.trim())
.filter(Boolean)
}
function buildFilterQuery(params) {
const src = params || tableRef.value?.getQueryParams?.() || {}
const { dateRange, pageNum, pageSize, orderIdentifiers, customerKeywords, ...rest } = src
const query = { ...rest }
const identifiers = splitLines(orderIdentifiers)
const customers = splitLines(customerKeywords)
if (identifiers.length) query.orderIdentifiers = identifiers
if (customers.length) query.customerKeywords = customers
if (Array.isArray(dateRange) && dateRange[0] && dateRange[1]) {
query.beginTime = dateRange[0]
query.endTime = dateRange[1]
}
return query
}
async function handleExport() {
const q = buildFilterQuery()
const identifierCount = (q.orderIdentifiers || []).length
const hasIdentifier = identifierCount > 0
const hasCustomer = !!(q.customerKeywords && q.customerKeywords.length) || !!(q.customerCodes && q.customerCodes.length)
const hasTime = !!q.beginTime && !!q.endTime
if (!hasIdentifier && !(hasCustomer && hasTime)) {
ElMessage.warning('请先设置列表查询条件后再导出')
return
}
if (identifierCount > MAX_ORDER_IDENTIFIERS) {
ElMessage.warning(`单次最多导出 ${MAX_ORDER_IDENTIFIERS} 个单号,当前 ${identifierCount} 个`)
return
}
const fields = await exportFieldRef.value?.open?.()
if (!fields || !fields.length) return
return exportAsync({
baseUrl: '/monitor/orderSource/export',
query: { ...q, exportFields: fields },
requireFilter: false,
filePrefix: 'order_source'
})
}
function queryList(params) {
const src = params || tableRef.value?.getQueryParams?.() || {}
const identifiers = splitLines(src.orderIdentifiers)
if (!identifiers.length) transferFillMode.value = false
// 回填搜索:走专用接口,不走普通 /list
if (transferFillMode.value && identifiers.length) {
if (identifiers.length > MAX_ORDER_IDENTIFIERS) {
ElMessage.warning(`单次最多查询 ${MAX_ORDER_IDENTIFIERS} 个单号,当前 ${identifiers.length} 个,请减少后再查`)
return Promise.resolve({ rows: [], total: 0 })
}
const customers = splitLines(src.customerKeywords)
const fillQuery = { orderIdentifiers: identifiers }
if (customers.length) fillQuery.customerKeywords = customers
fillQuery.pageNum = params.pageNum
fillQuery.pageSize = params.pageSize
return listOrderSourceByTransferNos(fillQuery)
}
const query = buildFilterQuery(params)
const identifierCount = (query.orderIdentifiers || []).length
const hasIdentifier = identifierCount > 0
const hasCustomer = !!(query.customerKeywords && query.customerKeywords.length)
const hasTime = !!query.beginTime && !!query.endTime
if (!hasIdentifier && !(hasCustomer && hasTime)) {
ElMessage.warning('请输入单号,或选择客户和提货时间')
return Promise.resolve({ rows: [], total: 0 })
}
if (identifierCount > MAX_ORDER_IDENTIFIERS) {
ElMessage.warning(`单次最多查询 ${MAX_ORDER_IDENTIFIERS} 个单号,当前 ${identifierCount} 个,请减少后再查`)
return Promise.resolve({ rows: [], total: 0 })
}
query.pageNum = params.pageNum
query.pageSize = params.pageSize
return listOrderSource(query)
}
function openOrderCompare() {
const q = buildFilterQuery()
const hasCustomer = !!(q.customerKeywords && q.customerKeywords.length)
const hasTime = !!q.beginTime && !!q.endTime
if (!hasCustomer || !hasTime) {
ElMessage.warning('订单比对需先输入客户并选择提货时间')
return
}
proxy.$modal.confirm(
'将按当前客户、提货时间与订单状态筛选,异步分页比对上游转单号是否已落库,确认继续?',
'二次确认'
).then(() => {
// 比对只按客户+时间;不带单号框内容(回填后可能有数千转单号,带上会把比对拖死)
const compareQuery = { ...q }
delete compareQuery.orderIdentifiers
delete compareQuery.orderId
delete compareQuery.orgOrderId
delete compareQuery.transferOrderId
compareDrawerRef.value?.open(compareQuery)
}).catch(() => {})
}
/** 比对缺失转单号回填到列表「单号」搜索框并查询 */
function onCompareFillSearch(payload) {
const list = (payload && payload.transferNos) || []
if (!list.length) {
ElMessage.warning('暂无可回填的转单号')
return
}
let nos = list
if (nos.length > MAX_ORDER_IDENTIFIERS) {
nos = nos.slice(0, MAX_ORDER_IDENTIFIERS)
ElMessage.warning(`单次最多查询 ${MAX_ORDER_IDENTIFIERS} 个单号,已截取前 ${MAX_ORDER_IDENTIFIERS} 个`)
} else if (payload && payload.truncated) {
ElMessage.warning(`缺失转单号较多,仅回填样本 ${nos.length} 个`)
} else {
ElMessage.success(`已回填 ${nos.length} 个转单号并开始搜索`)
}
transferFillMode.value = true
tableRef.value?.setQueryParams?.({
orderIdentifiers: nos.join('\n'),
pageNum: 1
})
tableRef.value?.handleQuery?.()
}
function openFilterSync() {
const q = buildFilterQuery()
const identifierCount = (q.orderIdentifiers || []).length
const hasIdentifier = identifierCount > 0
const hasCustomer = !!(q.customerKeywords && q.customerKeywords.length)
const hasTime = !!q.beginTime && !!q.endTime
// 与查询同口径:有单号即可同步;无单号时必须客户+提货时间
if (!hasIdentifier && !(hasCustomer && hasTime)) {
ElMessage.warning('请输入单号,或选择客户和提货时间后再同步落库')
return
}
if (identifierCount > MAX_ORDER_IDENTIFIERS) {
ElMessage.warning(`单次最多同步 ${MAX_ORDER_IDENTIFIERS} 个单号,当前 ${identifierCount} 个,请减少后再同步`)
return
}
const tip = hasIdentifier && !(hasCustomer && hasTime)
? `将按 ${identifierCount} 个单号查询匹配订单并异步覆盖上游字段(不计费),确认继续?`
: '确认按当前筛选分页查询并异步覆盖上游字段(不计费)?'
proxy.$modal.confirm(tip, '二次确认').then(() => {
syncDialogRef.value?.open(q, true)
}).catch(() => {})
}
function onSyncProgress(payload) {
syncFloat.value = payload
if (payload && payload.finished && !payload.loading) {
window.setTimeout(() => {
if (syncFloat.value && syncFloat.value.finished && !syncFloat.value.loading) {
syncFloat.value = null
}
}, 8000)
}
}
function onSyncSuccess() {
tableRef.value?.refresh()
}
function toSyncRow(row) {
return {
orderId: row.orderId,
orgOrderId: row.orgOrderId,
transferOrderId: row.transferOrderId,
customerCode: row.customerCode,
pickupDate: row.pickupDate,
orderStatus: row.orderStatus
}
}
function handleSyncOne(row) {
proxy.$modal.confirm('确认同步落库该订单(仅覆盖上游字段,不计费)?', '二次确认').then(() => {
syncingKey.value = rowKey(row)
return syncOrderSourceSelected([toSyncRow(row)])
}).then(res => {
const data = res.data || {}
if ((data.ok || 0) > 0 && !(data.fail || 0)) {
proxy.$modal.msgSuccess('同步成功')
} else {
proxy.$modal.msgError((data.details && data.details[0] && data.details[0].errorMessage) || '同步失败')
}
tableRef.value.refresh()
}).catch(() => {}).finally(() => {
syncingKey.value = null
})
}
function handleCheckProducts() {
const q = buildFilterQuery()
if (!(q.customerKeywords && q.customerKeywords.length) || !q.beginTime || !q.endTime) {
ElMessage.warning('检测产品需先输入客户并选择提货时间')
return
}
const identifierCount = (q.orderIdentifiers || []).length
if (identifierCount > MAX_PRODUCT_CHECK_IDENTIFIERS) {
ElMessage.warning(`检测产品最多支持 ${MAX_PRODUCT_CHECK_IDENTIFIERS} 个单号,请减少单号或直接在列表勾选同步`)
return
}
checkLoading.value = true
productCheckDrawerRef.value?.open(q)
window.setTimeout(() => { checkLoading.value = false }, 300)
}
function handleBatchSync(selection) {
if (!selection || !selection.length) return
proxy.$modal.confirm('确认同步选中的 ' + selection.length + ' 条订单(仅覆盖上游字段,不计费)?', '二次确认').then(() => {
batchLoading.value = true
return syncOrderSourceSelected(selection.map(toSyncRow))
}).then(res => {
const data = res.data || {}
proxy.$modal.msgSuccess('完成:成功 ' + (data.ok || 0) + ' / 失败 ' + (data.fail || 0))
}).catch(() => {}).finally(() => {
batchLoading.value = false
})
}
</script>
<style scoped>
.order-source-page .retry-status-float {
position: fixed;
top: 92px;
right: 24px;
z-index: 3001;
}
.order-source-page .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-source-page .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-source-page .retry-progress-ring::after {
position: absolute;
inset: 5px;
content: '';
border-radius: 50%;
background: var(--el-bg-color);
}
.order-source-page .retry-progress-value {
position: relative;
z-index: 1;
color: var(--el-color-primary);
font-size: 12px;
font-weight: 600;
line-height: 1;
}
.order-source-page .retry-status-text {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 3px;
line-height: 1.2;
white-space: nowrap;
}
.order-source-page .retry-status-text strong {
font-size: 14px;
font-weight: 600;
}
.order-source-page .retry-status-text small {
color: var(--el-text-color-secondary);
font-size: 11px;
}
</style>