账单异步生成优化

This commit is contained in:
caojia
2026-09-20 16:42:11 +08:00
parent 8d1abf0eb1
commit 85b37bc07e
52 changed files with 3948 additions and 790 deletions
@@ -6,6 +6,7 @@
<el-col :span="8"><el-form-item label="客户" prop="customerId"><customer-select v-model="generateForm.customerId" placeholder="请选择客户" @change="handleCustomerChange" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="币种" prop="currency"><el-select v-model="generateForm.currency" placeholder="请选择币种" style="width: 100%;" @change="handleCurrencyChange"><el-option label="人民币 CNY" value="CNY" /><el-option label="美元 USD" value="USD" /></el-select></el-form-item></el-col>
<el-col :span="8"><el-form-item label="账单截止日" prop="cutoffDate"><el-date-picker v-model="generateForm.cutoffDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择账单截止日" style="width: 100%;" @change="resetPreview" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="订单/运单号"><el-input v-model="generateForm.orderOrTrackingNo" placeholder="请输入订单号或运单号" clearable /></el-form-item></el-col>
</el-row>
</el-form>
<div class="payee-card" v-loading="bankAccountLoading">
@@ -18,14 +19,47 @@
<span v-else class="payee-empty">当前币种尚未配置公司默认收款账户</span>
</div>
<div class="preview-toolbar">
<span v-if="previewSearched" class="preview-summary">可选 {{ previewRows.length }} 条订单,已选 <b>{{ selectedRows.length }}</b> 条,应收合计 <b>{{ money(selectedAmount) }}</b></span>
<span v-if="previewSearched" class="preview-summary">可选 {{ previewTotal }} 条订单,已选 <b>{{ displaySelectedCount }}</b> 条,应收合计 <b>{{ money(displaySelectedAmount) }}</b></span>
<span v-else class="preview-summary">请选择客户、币种和账单截止日后查询可生成的计费订单</span>
<el-button type="primary" plain :loading="previewLoading" :disabled="!bankAccount" @click="queryGeneratePreview">查询</el-button>
</div>
<div v-if="previewSearched" class="preview-selection-toolbar">
<el-tooltip content="全选全部查询结果" placement="top">
<el-checkbox
:model-value="allSelected"
:disabled="!previewTotal"
aria-label="全选全部查询结果"
@change="handleSelectAllChange"
>
<el-icon><Select /></el-icon>
</el-checkbox>
</el-tooltip>
<el-tooltip content="切换手动选择" placement="top">
<el-checkbox
:model-value="selectionMode === AR_BILL_SELECTION_MODE.EXPLICIT"
:disabled="!previewTotal || selectionMode === AR_BILL_SELECTION_MODE.EXPLICIT"
aria-label="切换手动选择"
@change="handleManualModeChange"
>
<el-icon><Refresh /></el-icon>
</el-checkbox>
</el-tooltip>
<el-badge v-if="allSelected" :value="excludedRows.length" :hidden="!excludedRows.length" class="exclude-order-badge">
<el-tooltip content="选择排除订单" placement="top">
<el-button circle text :icon="Search" aria-label="选择排除订单" @click="openExcludeDialog" />
</el-tooltip>
</el-badge>
</div>
<div v-if="allSelected && excludedRows.length" class="excluded-order-preview">
<span class="excluded-order-title">排除订单:</span>
<el-tag v-for="row in excludedRows" :key="row.billId" closable size="small" @close="removeExcludedRow(row.billId)">
{{ row.orderNo || row.trackingNo || row.billId }}
</el-tag>
</div>
<el-table ref="previewTableRef" v-loading="previewLoading" :data="previewRows" row-key="billId" border stripe max-height="400" empty-text="暂无符合条件的计费订单" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="50" fixed="left" />
<el-table-column type="selection" width="50" fixed="left" :selectable="isMainRowSelectable" />
<el-table-column prop="orderNo" label="订单号" min-width="150" show-overflow-tooltip />
<el-table-column prop="trackingNo" label="跟踪号" min-width="140" show-overflow-tooltip />
<el-table-column prop="trackingNo" label="运单号" min-width="140" show-overflow-tooltip />
<el-table-column prop="customerName" label="客户" min-width="130" show-overflow-tooltip />
<el-table-column prop="bizDate" label="业务日期" width="105" :formatter="formatDateColumn" />
<el-table-column prop="productName" label="产品" min-width="140" show-overflow-tooltip />
@@ -35,20 +69,59 @@
<el-table-column prop="incomeTotal" label="应收金额" width="110" align="right" :formatter="formatNumberColumn" />
<el-table-column prop="currency" label="币种" width="75" />
</el-table>
<el-pagination
v-if="previewTotal > 0"
class="preview-pagination"
:current-page="previewPageNum"
:page-size="previewPageSize"
layout="total, prev, pager, next, jumper"
:total="previewTotal"
@current-change="handlePreviewPageChange"
/>
<template #footer>
<span class="generate-footer-summary">已选 {{ selectedRows.length }} 条订单 · {{ money(selectedAmount) }}</span>
<el-button type="primary" :loading="generateLoading" :disabled="!bankAccount || !previewSearched || !selectedRows.length" @click="submitGenerate">生成账单</el-button>
<span class="generate-footer-summary">已选 {{ displaySelectedCount }} 条订单 · {{ money(displaySelectedAmount) }}</span>
<el-button type="primary" :loading="generateLoading" :disabled="!bankAccount || !previewSearched || !displaySelectedCount" @click="submitGenerate">生成账单</el-button>
<el-button @click="dialogOpen = false">取消</el-button>
</template>
</el-dialog>
<el-dialog v-model="excludeDialogOpen" title="选择排除订单" width="1100px" append-to-body destroy-on-close @close="handleExcludeDialogClose">
<div class="exclude-toolbar">
<el-input v-model="excludeKeyword" clearable placeholder="请输入订单号或运单号" class="exclude-search-input" />
<el-button type="primary" :loading="excludeLoading" @click="queryExcludeRows">查询</el-button>
</div>
<el-table ref="excludeTableRef" v-loading="excludeLoading" :data="excludeRowsPage" row-key="billId" border stripe max-height="420" empty-text="暂无符合条件的订单" @selection-change="handleExcludeSelectionChange">
<el-table-column type="selection" width="50" fixed="left" />
<el-table-column prop="orderNo" label="订单号" min-width="220" show-overflow-tooltip />
<el-table-column prop="trackingNo" label="运单号" min-width="180" show-overflow-tooltip />
<el-table-column prop="customerName" label="客户" min-width="150" show-overflow-tooltip />
<el-table-column prop="bizDate" label="业务日期" width="120" :formatter="formatDateColumn" />
<el-table-column prop="incomeTotal" label="应收金额" width="130" align="right" :formatter="formatNumberColumn" />
<el-table-column prop="currency" label="币种" width="80" />
</el-table>
<el-pagination
v-if="excludeTotal > 0"
class="exclude-pagination"
:current-page="excludePageNum"
:page-size="excludePageSize"
layout="total, prev, pager, next, jumper"
:total="excludeTotal"
@current-change="handleExcludePageChange"
/>
<template #footer>
<span class="generate-footer-summary">已选择 {{ excludeDraftRows.length }} 条排除订单</span>
<el-button @click="cancelExcludeSelection">取消</el-button>
<el-button type="primary" @click="confirmExcludeSelection">确定</el-button>
</template>
</el-dialog>
<el-dialog v-model="billNameOpen" title="填写账单名称" width="520px" append-to-body destroy-on-close>
<el-form ref="billNameFormRef" :model="billNameForm" :rules="billNameRules" label-width="90px">
<el-form-item label="账单名称" prop="billName">
<el-input v-model="billNameForm.billName" maxlength="100" show-word-limit placeholder="请输入账单名称" clearable @keyup.enter="confirmGenerate" />
</el-form-item>
</el-form>
<div class="bill-name-summary">已选择 {{ selectedRows.length }} 条订单,应收合计 {{ money(selectedAmount) }}</div>
<div class="bill-name-summary">已选择 {{ displaySelectedCount }} 条订单,应收合计 {{ money(displaySelectedAmount) }}</div>
<template #footer>
<el-button @click="billNameOpen = false">取消</el-button>
<el-button type="primary" :loading="generateLoading" @click="confirmGenerate">确认生成</el-button>
@@ -59,6 +132,8 @@
<script setup name="GenerateBillDialog">
import { getDefaultArBankAccount, previewArBill, generateArBill } from '@/api/ar/bill'
import { Refresh, Search, Select } from '@element-plus/icons-vue'
import { AR_BILL_SELECTION_MODE } from '@/constants/ar/enums'
const props = defineProps({
modelValue: {
@@ -78,14 +153,39 @@ const generateLoading = ref(false)
const previewLoading = ref(false)
const previewSearched = ref(false)
const previewRows = ref([])
const previewTotal = ref(0)
const previewTotalAmount = ref(0)
const previewPageNum = ref(1)
// 主预览列表固定每页 5 条;Map 负责保存跨页勾选结果。
const previewPageSize = 5
const previewTableRef = ref()
const selectedRows = ref([])
const selectedRowMap = new Map()
// 选择模式只保存编码,提交时由后端枚举再次校验。
const selectionMode = ref(AR_BILL_SELECTION_MODE.EXPLICIT)
const excludedRows = ref([])
const excludedRowMap = new Map()
const excludeDialogOpen = ref(false)
const excludeLoading = ref(false)
const excludeKeyword = ref('')
const excludeRowsPage = ref([])
const excludeTotal = ref(0)
const excludePageNum = ref(1)
// 全选后的排除弹窗也按 5 条分页,查询条件固定继承主弹窗条件。
const excludePageSize = 5
const excludeTableRef = ref()
const excludeDraftRows = ref([])
let excludeDraftMap = new Map()
let excludeDialogConfirmed = false
let syncingExcludeSelection = false
const previewQuery = ref(null)
let syncingPageSelection = false
const bankAccountLoading = ref(false)
const bankAccount = ref(null)
const generateRequestId = ref('')
const generateFormRef = ref()
const billNameFormRef = ref()
const generateForm = reactive({ customerId: '', customerCode: '', cutoffDate: '', currency: 'CNY' })
const generateForm = reactive({ customerId: '', customerCode: '', customerName: '', cutoffDate: '', currency: 'CNY', orderOrTrackingNo: '' })
const billNameForm = reactive({ billName: '' })
const generateRules = {
customerId: [{ required: true, message: '客户不能为空', trigger: 'change' }],
@@ -94,6 +194,12 @@ const generateRules = {
}
const billNameRules = { billName: [{ required: true, message: '请输入账单名称', trigger: 'blur' }] }
const selectedAmount = computed(() => selectedRows.value.reduce((sum, row) => sum + (Number(row.incomeTotal) || 0), 0))
const excludedAmount = computed(() => excludedRows.value.reduce((sum, row) => sum + (Number(row.incomeTotal) || 0), 0))
const allSelected = computed(() => selectionMode.value === AR_BILL_SELECTION_MODE.ALL_MATCHED)
const displaySelectedCount = computed(() => selectionMode.value === AR_BILL_SELECTION_MODE.ALL_MATCHED
? Math.max(previewTotal.value - excludedRows.value.length, 0) : selectedRows.value.length)
const displaySelectedAmount = computed(() => selectionMode.value === AR_BILL_SELECTION_MODE.ALL_MATCHED
? Math.max(Number(previewTotalAmount.value || 0) - excludedAmount.value, 0) : selectedAmount.value)
watch(() => props.modelValue, visible => {
if (visible) {
@@ -101,8 +207,9 @@ watch(() => props.modelValue, visible => {
}
})
// 每次打开生成弹窗都重新建立一次查询快照,避免复用上一次的选中订单。
function openGenerate() {
Object.assign(generateForm, { customerId: '', customerCode: '', cutoffDate: '', currency: 'CNY' })
Object.assign(generateForm, { customerId: '', customerCode: '', customerName: '', cutoffDate: '', currency: 'CNY', orderOrTrackingNo: '' })
Object.assign(billNameForm, { billName: '' })
billNameOpen.value = false
resetPreview()
@@ -113,6 +220,7 @@ function openGenerate() {
function handleCustomerChange(_, item) {
generateForm.customerCode = item?.customerCode || ''
generateForm.customerName = item?.customerName || ''
resetPreview()
}
@@ -131,9 +239,21 @@ function loadDefaultBankAccount() {
})
}
// 条件变化后清理预览和跨页选中状态;用户点击查询时重新加载第一页。
function resetPreview() {
previewRows.value = []
previewTotal.value = 0
previewTotalAmount.value = 0
previewPageNum.value = 1
selectedRows.value = []
selectedRowMap.clear()
selectionMode.value = AR_BILL_SELECTION_MODE.EXPLICIT
excludedRows.value = []
excludedRowMap.clear()
excludeDraftRows.value = []
excludeDraftMap = new Map()
excludeDialogOpen.value = false
previewQuery.value = null
previewSearched.value = false
generateRequestId.value = ''
nextTick(() => previewTableRef.value?.clearSelection())
@@ -143,41 +263,225 @@ function buildGenerateQuery() {
return {
customerId: Number(generateForm.customerId),
customerCode: generateForm.customerCode?.trim() || undefined,
customerName: generateForm.customerName?.trim() || undefined,
billPeriodEnd: generateForm.cutoffDate,
currency: generateForm.currency || undefined
currency: generateForm.currency || undefined,
orderOrTrackingNo: generateForm.orderOrTrackingNo?.trim() || undefined
}
}
// 任务请求携带完整筛选快照;全选模式只提交排除 ID,后台按条件游标扫描其余订单。
function buildGenerateRequest(requestId) {
return {
...buildGenerateQuery(),
...(previewQuery.value || buildGenerateQuery()),
billName: billNameForm.billName.trim(),
sourceBillIds: selectedRows.value.map(row => row.billId),
selectionMode: selectionMode.value,
sourceBillIds: selectionMode.value === AR_BILL_SELECTION_MODE.ALL_MATCHED ? [] : selectedRows.value.map(row => row.billId),
excludedSourceBillIds: selectionMode.value === AR_BILL_SELECTION_MODE.ALL_MATCHED ? Array.from(excludedRowMap.keys()) : [],
requestId
}
}
function buildPreviewQuery(pageNum = previewPageNum.value, pageSize = previewPageSize) {
return { ...(previewQuery.value || buildGenerateQuery()), pageNum, pageSize }
}
// 查询按钮是预览入口,后端返回当前页、总数和全量金额合计。
function queryGeneratePreview() {
generateFormRef.value.validate(valid => {
if (!valid) return
const query = buildGenerateQuery()
resetPreview()
previewLoading.value = true
previewArBill(buildGenerateQuery()).then(res => {
previewRows.value = res.rows || []
previewSearched.value = true
}).finally(() => {
previewLoading.value = false
})
previewQuery.value = query
fetchPreview()
})
}
function fetchPreview() {
previewLoading.value = true
previewArBill(buildPreviewQuery()).then(res => {
syncingPageSelection = true
previewRows.value = res.rows || []
previewTotal.value = Number(res.total) || 0
previewTotalAmount.value = Number(res.totalAmount) || 0
previewSearched.value = true
nextTick(syncPageSelection)
}).finally(() => {
previewLoading.value = false
})
}
function handlePreviewPageChange(page) {
previewPageNum.value = page
fetchPreview()
}
// 当前页变化只更新当前页 ID,selectedRowMap 因此可以跨页保留手动选择。
function handleSelectionChange(rows) {
selectedRows.value = rows
if (syncingPageSelection || selectionMode.value === AR_BILL_SELECTION_MODE.ALL_MATCHED) return
const currentPageIds = new Set(previewRows.value.map(row => row.billId))
currentPageIds.forEach(billId => selectedRowMap.delete(billId))
rows.forEach(row => selectedRowMap.set(row.billId, row))
selectedRows.value = Array.from(selectedRowMap.values())
if (!generateLoading.value) generateRequestId.value = ''
}
// 全选后主表复选框全部禁用,用户通过排除弹窗维护“不生成”的订单。
function handleSelectAllChange(checked) {
if (!checked) {
switchToManualMode()
return
}
selectionMode.value = AR_BILL_SELECTION_MODE.ALL_MATCHED
excludedRows.value = []
excludedRowMap.clear()
selectedRowMap.clear()
selectedRows.value = []
if (!generateLoading.value) generateRequestId.value = ''
nextTick(syncPageSelection)
}
function handleManualModeChange(checked) {
if (checked) switchToManualMode()
}
// 从全选切回手动选择时清除排除集合,恢复主表跨页勾选流程。
function switchToManualMode() {
selectionMode.value = AR_BILL_SELECTION_MODE.EXPLICIT
excludedRows.value = []
excludedRowMap.clear()
selectedRowMap.clear()
selectedRows.value = []
if (!generateLoading.value) generateRequestId.value = ''
nextTick(syncPageSelection)
}
function isMainRowSelectable() {
return selectionMode.value !== AR_BILL_SELECTION_MODE.ALL_MATCHED
}
function syncExcludedRows() {
excludedRows.value = Array.from(excludedRowMap.values())
}
function syncExcludeDraftRows() {
excludeDraftRows.value = Array.from(excludeDraftMap.values())
}
// 排除弹窗继承客户、币种、截止日和主关键字,只额外允许订单号/运单号 OR 查询。
function openExcludeDialog() {
if (!allSelected.value) return
excludeDialogConfirmed = false
excludeDraftMap = new Map(excludedRowMap)
syncExcludeDraftRows()
excludeKeyword.value = ''
excludePageNum.value = 1
excludeRowsPage.value = []
excludeTotal.value = 0
excludeDialogOpen.value = true
fetchExcludeRows()
}
function buildExcludeQuery() {
return {
...(previewQuery.value || {}),
excludeOrderOrTrackingNo: excludeKeyword.value?.trim() || undefined,
pageNum: excludePageNum.value,
pageSize: excludePageSize
}
}
function queryExcludeRows() {
excludePageNum.value = 1
fetchExcludeRows()
}
function fetchExcludeRows() {
if (!previewQuery.value) return
excludeLoading.value = true
previewArBill(buildExcludeQuery()).then(res => {
syncingExcludeSelection = true
excludeRowsPage.value = res.rows || []
excludeTotal.value = Number(res.total) || 0
nextTick(syncExcludePageSelection)
}).finally(() => {
excludeLoading.value = false
})
}
function handleExcludePageChange(page) {
excludePageNum.value = page
fetchExcludeRows()
}
// 排除弹窗使用独立 Map 保存跨页选择,关闭或翻页不会丢失已勾选订单。
function handleExcludeSelectionChange(rows) {
if (syncingExcludeSelection) return
const currentPageIds = new Set(excludeRowsPage.value.map(row => row.billId))
currentPageIds.forEach(billId => excludeDraftMap.delete(billId))
rows.forEach(row => excludeDraftMap.set(row.billId, row))
syncExcludeDraftRows()
}
function syncExcludePageSelection() {
if (!excludeTableRef.value) {
syncingExcludeSelection = false
return
}
syncingExcludeSelection = true
excludeTableRef.value.clearSelection()
excludeRowsPage.value.forEach(row => {
if (excludeDraftMap.has(row.billId)) excludeTableRef.value.toggleRowSelection(row, true)
})
syncingExcludeSelection = false
}
function confirmExcludeSelection() {
excludedRowMap.clear()
excludeDraftMap.forEach((row, billId) => excludedRowMap.set(billId, row))
syncExcludedRows()
if (!generateLoading.value) generateRequestId.value = ''
excludeDialogConfirmed = true
excludeDialogOpen.value = false
}
function removeExcludedRow(billId) {
excludedRowMap.delete(billId)
excludeDraftMap.delete(billId)
syncExcludeDraftRows()
syncExcludedRows()
if (!generateLoading.value) generateRequestId.value = ''
}
function cancelExcludeSelection() {
excludeDialogConfirmed = false
excludeDialogOpen.value = false
}
function handleExcludeDialogClose() {
if (!excludeDialogConfirmed) {
excludeDraftMap = new Map(excludedRowMap)
syncExcludeDraftRows()
}
excludeDialogConfirmed = false
}
// 翻页后把 Map 中的选择同步回 Element Plus 当前页复选框。
function syncPageSelection() {
if (!previewTableRef.value) {
syncingPageSelection = false
return
}
syncingPageSelection = true
previewTableRef.value.clearSelection()
previewRows.value.forEach(row => {
if (selectedRowMap.has(row.billId)) previewTableRef.value.toggleRowSelection(row, true)
})
syncingPageSelection = false
}
function submitGenerate() {
if (!previewSearched.value || !selectedRows.value.length) {
if (!previewSearched.value || !displaySelectedCount.value) {
proxy.$modal.msgError('请至少选择一条计费订单')
return
}
@@ -186,13 +490,14 @@ function submitGenerate() {
nextTick(() => billNameFormRef.value?.clearValidate())
}
// 最终只提交异步任务,不在浏览器等待几十万条订单明细生成完成。
function confirmGenerate() {
billNameFormRef.value.validate(valid => {
if (!valid) return
if (!generateRequestId.value) generateRequestId.value = createRequestId()
generateLoading.value = true
generateArBill(buildGenerateRequest(generateRequestId.value)).then(res => {
proxy.$modal.msgSuccess('账单生成成功')
proxy.$modal.msgSuccess('账单生成任务已提交,后台正在生成')
billNameOpen.value = false
dialogOpen.value = false
emit('success', res.data)
@@ -219,6 +524,16 @@ function createRequestId() { return globalThis.crypto?.randomUUID?.() || `ar-${D
.preview-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: 18px 0 10px; padding: 10px 14px; border-radius: 6px; background: var(--el-fill-color-light); color: var(--el-text-color-regular); }
.preview-summary { flex: 1; font-size: 14px; }
.preview-summary b { color: var(--el-color-primary); }
.preview-selection-toolbar { display: flex; align-items: center; gap: 6px; min-height: 32px; padding-left: 8px; }
.preview-selection-toolbar :deep(.el-checkbox) { margin-right: 6px; }
.preview-selection-toolbar :deep(.el-checkbox__label) { display: inline-flex; align-items: center; padding-left: 4px; font-size: 16px; }
.exclude-order-badge { margin-left: 4px; }
.excluded-order-preview { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; max-height: 64px; margin: 0 0 8px; padding: 4px 8px; overflow-y: auto; border-radius: 4px; background: var(--el-fill-color-light); }
.excluded-order-title { color: var(--el-text-color-secondary); font-size: 13px; }
.exclude-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.exclude-search-input { width: 360px; }
.exclude-pagination { justify-content: flex-end; margin: 12px 0 4px; }
.preview-pagination { justify-content: flex-end; margin: 12px 0 4px; }
.bill-name-summary { margin: -4px 0 8px 90px; color: var(--el-text-color-secondary); font-size: 13px; }
.generate-footer-summary { margin-right: auto; color: var(--el-text-color-secondary); font-size: 13px; }
</style>