修改同步计费

This commit is contained in:
wuweihua
2026-10-13 10:21:28 +08:00
parent 26bdd3ba87
commit 4d27887d4a
14 changed files with 1231 additions and 12 deletions
+40
View File
@@ -8,3 +8,43 @@ export function listOrderSource(query) {
timeout: 60000
})
}
export function syncOrderSourceByFilter(data) {
return request({
url: '/monitor/orderSource/syncByFilter',
method: 'post',
data,
timeout: 60000
})
}
export function getOrderSourceSyncProgress(asyncTaskId) {
return request({
url: '/monitor/orderSource/syncTask/' + asyncTaskId,
method: 'get'
})
}
export function syncOrderSourceSelected(rows) {
return request({
url: '/monitor/orderSource/sync',
method: 'post',
data: rows,
timeout: 180000
})
}
export function listOrderSourceSyncLog(query) {
return request({
url: '/monitor/orderSource/syncLog/list',
method: 'get',
params: query
})
}
export function getOrderSourceSyncLog(docId) {
return request({
url: '/monitor/orderSource/syncLog/' + encodeURIComponent(docId),
method: 'get'
})
}
+2 -1
View File
@@ -80,7 +80,8 @@ const typeOptions = [
{ label: '计费', value: 'BILL' },
{ label: '试算', value: 'QUOTE' },
{ label: '扣减额度', value: 'CREDIT' },
{ label: '欠费清除', value: 'ARREARS' }
{ label: '欠费清除', value: 'ARREARS' },
{ label: '订单数据源同步', value: 'ORDER_SOURCE_SYNC' }
]
const statusOptions = [
@@ -0,0 +1,313 @@
<template>
<el-dialog
v-model="visible"
:title="dialogTitle"
width="520px"
append-to-body
destroy-on-close
:close-on-click-modal="!loading"
:close-on-press-escape="!loading"
:show-close="!loading"
@closed="onClosed"
>
<el-form v-show="!loading && !finished" label-width="96px">
<el-form-item label="客户">
<span>{{ filterText.customers }}</span>
</el-form-item>
<el-form-item label="提货时间">
<span>{{ filterText.time }}</span>
</el-form-item>
<el-form-item v-if="filterText.nos" label="单号">
<span>{{ filterText.nos }}</span>
</el-form-item>
<el-alert
type="info"
:closable="false"
show-icon
title="按当前筛选分页查询上游订单并逐单同步计费,避免一次性加载全量。"
/>
</el-form>
<div v-if="loading || finished" class="filter-retry-progress">
<el-progress
:percentage="progressPercent"
:status="progressStatus"
:stroke-width="14"
striped
:striped-flow="loading"
/>
<div class="filter-retry-progress__msg">{{ progressMessage }}</div>
<div class="filter-retry-progress__text">
总数 {{ progress.total || 0 }}
/ 已处理 {{ progress.processed || 0 }}
/ 成功 {{ progress.ok || 0 }}
/ 失败 {{ progress.fail || 0 }}
</div>
<div v-if="progress.error" class="filter-retry-progress__error">{{ progress.error }}</div>
<p v-if="loading" class="filter-retry-progress__hint">请勿关闭页面,大量订单同步计费可能需要一些时间</p>
</div>
<template #footer>
<template v-if="!loading && !finished">
<el-button @click="visible = false">取消</el-button>
<el-button type="primary" @click="submit">开始同步计费</el-button>
</template>
<template v-else-if="finished">
<el-button type="primary" @click="visible = false">关闭</el-button>
</template>
<template v-else>
<el-button disabled>执行中...</el-button>
</template>
</template>
</el-dialog>
</template>
<script setup>
import { syncOrderSourceByFilter, getOrderSourceSyncProgress } from '@/api/monitor/orderSource'
const emit = defineEmits(['success', 'progress'])
const { proxy } = getCurrentInstance()
const visible = ref(false)
const loading = ref(false)
const finished = ref(false)
const query = ref({})
const progress = ref({
percent: 0,
message: '',
total: 0,
processed: 0,
ok: 0,
fail: 0,
status: '',
error: ''
})
const filterText = computed(() => {
const q = query.value || {}
const codes = Array.isArray(q.customerCodes) ? q.customerCodes.filter(Boolean) : []
const nos = []
if (q.orderId) nos.push('订单号 ' + q.orderId)
if (q.orgOrderId) nos.push('运单号 ' + q.orgOrderId)
if (q.transferOrderId) nos.push('转单号 ' + q.transferOrderId)
return {
customers: codes.join(', ') || '-',
time: (q.beginTime || '-') + ' ~ ' + (q.endTime || '-'),
nos: nos.join(' / ')
}
})
const dialogTitle = computed(() => {
if (loading.value) return '异步同步计费进行中'
if (finished.value) return '异步同步计费完成'
return '条件批量同步计费'
})
const progressPercent = computed(() => Math.min(100, Math.max(0, Number(progress.value?.percent) || 0)))
const progressStatus = computed(() => {
const st = (progress.value?.status || '').toUpperCase()
if (st === 'SUCCESS') return 'success'
if (st === 'FAIL') return 'exception'
return undefined
})
const progressMessage = computed(() => mapProgressMessage(progress.value?.message, progress.value))
function mapProgressMessage(message, data) {
const m = message || ''
if (!m || m === 'queued') return '排队中...'
if (m === 'starting') return '正在统计订单...'
if (m === 'no tasks') return '无匹配订单'
if (m === 'failed') return '执行失败'
if (m.startsWith('processing ')) {
return '正在同步计费 ' + m.slice('processing '.length)
}
if (m.startsWith('done ')) {
return '完成:成功 ' + (data?.ok || 0) + ' / 失败 ' + (data?.fail || 0)
}
return m
}
function emitProgress() {
emit('progress', {
loading: loading.value,
finished: finished.value,
percent: progressPercent.value,
status: progress.value?.status || '',
message: progressMessage.value,
total: progress.value?.total || 0,
processed: progress.value?.processed || 0,
ok: progress.value?.ok || 0,
fail: progress.value?.fail || 0,
error: progress.value?.error || ''
})
}
function open(filterQuery, autoStart) {
if (loading.value || finished.value) {
visible.value = true
return
}
query.value = { ...(filterQuery || {}) }
progress.value = {
percent: 0,
message: '',
total: 0,
processed: 0,
ok: 0,
fail: 0,
status: '',
error: ''
}
loading.value = false
finished.value = false
visible.value = true
emitProgress()
if (autoStart) {
submit(true)
}
}
function onClosed() {
if (!loading.value) {
finished.value = false
}
}
function submit(skipConfirm) {
const q = query.value || {}
const codes = Array.isArray(q.customerCodes) ? q.customerCodes.filter(v => v != null && String(v).trim() !== '') : []
if (!codes.length || !q.beginTime || !q.endTime) {
proxy.$modal.msgError('请先选择客户和提货时间')
return
}
const start = () => {
loading.value = true
finished.value = false
progress.value = {
percent: 0,
message: 'queued',
total: 0,
processed: 0,
ok: 0,
fail: 0,
status: 'PENDING',
error: ''
}
emitProgress()
return syncOrderSourceByFilter({
customerCodes: codes,
beginTime: q.beginTime,
endTime: q.endTime,
orderId: q.orderId || undefined,
orgOrderId: q.orgOrderId || undefined,
transferOrderId: q.transferOrderId || undefined,
batchSize: 50
}).then(res => {
const asyncTaskId = res.data?.asyncTaskId || res.data
if (!asyncTaskId) {
throw new Error('asyncTaskId missing')
}
return poll(asyncTaskId)
}).then((data) => {
finished.value = true
emit('success', data)
proxy.$modal.msgSuccess(
'完成:成功 ' + (data.ok || 0) + ' / 失败 ' + (data.fail || 0)
)
}).catch(() => {
if (loading.value) {
finished.value = true
if (!progress.value?.status || progress.value.status === 'PENDING' || progress.value.status === 'RUNNING') {
progress.value = {
...progress.value,
status: 'FAIL',
percent: 100,
message: 'failed',
error: progress.value?.error || '批量同步计费失败'
}
}
}
}).finally(() => {
loading.value = false
emitProgress()
})
}
if (skipConfirm) {
start()
return
}
proxy.$modal.confirm(
'确认按当前筛选分页查询并异步同步计费?',
'二次确认'
).then(() => start()).catch(() => {})
}
function poll(asyncTaskId) {
return new Promise((resolve, reject) => {
const tick = () => {
getOrderSourceSyncProgress(asyncTaskId).then(res => {
const data = res.data || {}
progress.value = {
percent: Number(data.percent) || 0,
message: data.message || '',
total: data.total || 0,
processed: data.processed || 0,
ok: data.ok || 0,
fail: data.fail || 0,
status: data.status || '',
error: data.error || ''
}
emitProgress()
const st = (data.status || '').toUpperCase()
if (st === 'SUCCESS') {
progress.value.percent = 100
resolve(data)
return
}
if (st === 'FAIL') {
proxy.$modal.msgError(data.error || data.message || '批量同步计费失败')
reject(new Error(data.error || 'fail'))
return
}
setTimeout(tick, 1500)
}).catch(err => reject(err))
}
tick()
})
}
defineExpose({ open })
</script>
<style scoped>
.filter-retry-progress {
padding: 8px 4px 0;
}
.filter-retry-progress__msg {
margin-top: 14px;
font-size: 14px;
font-weight: 600;
color: #303133;
line-height: 1.5;
}
.filter-retry-progress__text {
margin-top: 8px;
font-size: 12px;
color: #606266;
line-height: 1.6;
}
.filter-retry-progress__error {
margin-top: 8px;
font-size: 12px;
color: #f56c6c;
word-break: break-all;
}
.filter-retry-progress__hint {
margin: 12px 0 0;
font-size: 12px;
color: #909399;
line-height: 1.5;
}
</style>
@@ -0,0 +1,158 @@
<template>
<el-drawer v-model="visible" size="860px" :title="'操作记录'" append-to-body destroy-on-close>
<basic-table
v-if="visible"
:columns="columns"
:search-columns="searchColumns"
:query="queryList"
row-key="docId"
:page-size="10"
:selection="false"
:show-add="false"
>
<template #col-actionName="{ row }">
{{ actionLabel(row) }}
</template>
<template #col-status="{ row }">
<el-tag size="small" :type="row.status === 'FAIL' ? 'danger' : 'success'">{{ row.status === 'FAIL' ? '失败' : '成功' }}</el-tag>
</template>
<template #col-action="{ row }">
<el-button link type="primary" icon="View" @click="handleView(row)">查看</el-button>
</template>
</basic-table>
<el-drawer v-model="detailOpen" size="640px" :title="'操作详情'" append-to-body destroy-on-close>
<div v-loading="detailLoading">
<el-empty v-if="!detail" description="暂无详情" />
<div v-else>
<el-descriptions :column="2" border size="small" class="mb12">
<el-descriptions-item label="业务ID">{{ detail.bizId || '-' }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag size="small" :type="detail.status === 'FAIL' ? 'danger' : 'success'">{{ detail.status === 'FAIL' ? '失败' : '成功' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="写入时间" :span="2">{{ detail.updateTime || '-' }}</el-descriptions-item>
<el-descriptions-item v-if="detail.errorMessage" label="错误信息" :span="2">
<span class="err-text">{{ detail.errorMessage }}</span>
</el-descriptions-item>
</el-descriptions>
<div class="json-panel">
<div class="json-panel__head"><span>请求报文</span></div>
<pre class="code-pre">{{ formatJson(detail.requestJson) }}</pre>
</div>
<div class="json-panel">
<div class="json-panel__head"><span>返回报文</span></div>
<pre class="code-pre">{{ formatJson(detail.responseJson) }}</pre>
</div>
</div>
</div>
</el-drawer>
</el-drawer>
</template>
<script setup>
import { listOrderSourceSyncLog, getOrderSourceSyncLog } from '@/api/monitor/orderSource'
const visible = ref(false)
const detailOpen = ref(false)
const detailLoading = ref(false)
const detail = ref(null)
const searchColumns = [
{ label: '业务ID', prop: 'bizId', component: 'input', placeholder: '业务ID', width: '220px' },
{
label: '状态',
prop: 'status',
component: 'select',
options: [
{ label: '成功', value: 'SUCCESS' },
{ label: '失败', value: 'FAIL' }
],
placeholder: '请选择',
width: '140px',
props: { clearable: true }
}
]
const columns = [
{ label: '操作', prop: 'actionName', width: 120, slot: 'actionName' },
{ label: '业务ID', prop: 'bizId', minWidth: 180, showOverflowTooltip: true },
{ label: '状态', prop: 'status', align: 'center', width: 90, slot: 'status' },
{ label: '写入时间', prop: 'updateTime', align: 'center', width: 170 },
{ label: '操作', prop: 'action', align: 'center', width: 80, fixed: 'right', slot: 'action', showInToolbar: false }
]
function parseReq(row) {
if (!row || !row.requestJson) return {}
try {
return typeof row.requestJson === 'object' ? row.requestJson : JSON.parse(row.requestJson)
} catch (e) {
return {}
}
}
function actionLabel(row) {
const action = parseReq(row).action
if (action === 'FILTER') return '筛选同步'
if (action === 'FILTER_DONE') return '筛选完成'
if (action === 'SELECTED') return '同步选中'
if (action === 'ONE') return '单条同步'
return action || '-'
}
function queryList(params) {
return listOrderSourceSyncLog(params)
}
function formatJson(raw) {
if (raw == null || raw === '') return '-'
try {
const obj = typeof raw === 'string' ? JSON.parse(raw) : raw
return JSON.stringify(obj, null, 2)
} catch (e) {
return String(raw)
}
}
function handleView(row) {
detailOpen.value = true
detail.value = null
detailLoading.value = true
getOrderSourceSyncLog(row.docId).then(res => {
detail.value = res.data || row
}).finally(() => {
detailLoading.value = false
})
}
function open() {
visible.value = true
}
defineExpose({ open })
</script>
<style scoped>
.err-text {
color: #f56c6c;
word-break: break-all;
}
.json-panel {
margin-top: 12px;
}
.json-panel__head {
margin-bottom: 6px;
font-weight: 600;
}
.code-pre {
margin: 0;
padding: 10px 12px;
max-height: 240px;
overflow: auto;
background: #f5f7fa;
border-radius: 4px;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-all;
}
</style>
@@ -1,5 +1,19 @@
<template>
<div class="app-container">
<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"
@@ -7,6 +21,7 @@
:query="queryList"
row-key="orderId"
:auto-load="false"
selection
search-action-align="center"
:search-per-row="4"
>
@@ -21,6 +36,36 @@
multiple
/>
</template>
<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="info"
plain
icon="Document"
@click="logDrawerRef.open()"
>操作记录</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>
@@ -30,15 +75,40 @@
<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" />
</div>
</template>
<script setup name="OrderSource">
import { ElMessage } from 'element-plus'
import { listOrderSource } from '@/api/monitor/orderSource'
import { listOrderSource, syncOrderSourceSelected } from '@/api/monitor/orderSource'
import OrderSourceSyncDialog from './components/OrderSourceSyncDialog.vue'
import OrderSourceSyncLogDrawer from './components/OrderSourceSyncLogDrawer.vue'
const { proxy } = getCurrentInstance()
const tableRef = ref()
const syncDialogRef = ref()
const logDrawerRef = ref()
const syncingKey = ref(null)
const batchLoading = ref(false)
const syncFloat = ref(null)
const searchColumns = [
{ label: '客户', prop: 'customerCodes', placeholder: '客户代码' },
@@ -72,7 +142,8 @@ const columns = [
{ 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: 'pickupDate', align: 'center', width: 170 },
{ label: '操作', prop: 'action', align: 'center', width: 120, fixed: 'right', slot: 'action', showInToolbar: false }
]
const orderStatusMap = {
@@ -89,6 +160,25 @@ const orderStatusMap = {
10: '已删除'
}
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 || '点击查看详情')
function orderStatusLabel(status) {
if (status === null || status === undefined || status === '') return '-'
const key = Number(status)
@@ -98,6 +188,10 @@ function orderStatusLabel(status) {
return String(status)
}
function rowKey(row) {
return (row && (row.orderId || row.transferOrderId || row.orgOrderId)) || ''
}
function hasRequiredFilters(params) {
const codes = params && params.customerCodes
const hasCustomer = Array.isArray(codes) && codes.some(v => v != null && String(v).trim() !== '')
@@ -106,17 +200,168 @@ function hasRequiredFilters(params) {
return hasCustomer && hasTime
}
function queryList(params) {
if (!hasRequiredFilters(params)) {
ElMessage.warning('请先选择客户和提货时间')
return Promise.resolve({ rows: [], total: 0 })
}
const { dateRange, ...rest } = params
function buildFilterQuery(params) {
const src = params || tableRef.value?.getQueryParams?.() || {}
const { dateRange, pageNum, pageSize, ...rest } = src
const query = { ...rest }
if (Array.isArray(dateRange) && dateRange[0] && dateRange[1]) {
query.beginTime = dateRange[0]
query.endTime = dateRange[1]
}
return query
}
function queryList(params) {
if (!hasRequiredFilters(params)) {
ElMessage.warning('请先选择客户和提货时间')
return Promise.resolve({ rows: [], total: 0 })
}
const query = buildFilterQuery(params)
query.pageNum = params.pageNum
query.pageSize = params.pageSize
return listOrderSource(query)
}
function openFilterSync() {
const q = buildFilterQuery()
if (!q.customerCodes || !q.beginTime || !q.endTime) {
ElMessage.warning('请先选择客户和提货时间')
return
}
const codes = Array.isArray(q.customerCodes) ? q.customerCodes.filter(v => v != null && String(v).trim() !== '') : []
if (!codes.length) {
ElMessage.warning('请先选择客户和提货时间')
return
}
q.customerCodes = codes
proxy.$modal.confirm('确认按当前筛选分页查询并异步同步计费?', '二次确认').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
}
}
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 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))
tableRef.value.refresh()
}).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>