✨ 支持按单号批量排除账单订单

This commit is contained in:
caojia
2026-10-20 15:11:27 +08:00
parent 29e804dae2
commit b772c95813
9 changed files with 159 additions and 107 deletions
+9
View File
@@ -50,6 +50,15 @@ export function previewArBill(query) {
})
}
export function previewExcludedArBill(query) {
return request({
url: '/ar/bill/generate/exclusion-preview',
method: 'post',
data: query,
timeout: 60000
})
}
export function confirmArBill(arBillId, data = {}) {
return request({ url: '/ar/bill/' + arBillId + '/confirm', method: 'put', data })
}
@@ -51,17 +51,16 @@
<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-badge v-if="allSelected" :value="confirmedExcludeCount" :hidden="!confirmedExcludeKeyword" 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 v-if="allSelected && confirmedExcludeKeyword" class="excluded-order-preview">
<span>已排除 {{ confirmedExcludeCount }} 条订单,应收 {{ money(confirmedExcludeAmount) }}(按单号条件)</span>
<el-button link type="primary" @click="openExcludeDialog">修改</el-button>
<el-button link type="danger" @click="clearConfirmedExclusions">清除</el-button>
</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" :selectable="isMainRowSelectable" />
@@ -96,13 +95,13 @@
<el-dialog v-model="excludeDialogOpen" title="选择排除订单" width="1100px" append-to-body destroy-on-close @close="handleExcludeDialogClose">
<div class="exclude-toolbar">
<div class="order-identifier-input exclude-search-input">
<el-input v-model="excludeKeyword" type="textarea" :rows="4" resize="vertical" clearable placeholder="请输入订单号、运单号或转单号" />
<el-input v-model="excludeKeyword" type="textarea" :rows="4" resize="vertical" clearable placeholder="请输入订单号、运单号或转单号" @input="invalidateExcludeSearch" />
<el-button v-if="excludeKeyword" class="order-identifier-clear" link type="primary" @click="clearExcludeIdentifiers">清空</el-button>
</div>
<el-button type="primary" :loading="excludeLoading" @click="queryExcludeRows">查询</el-button>
<el-button type="primary" :loading="excludeLoading" :disabled="!excludeKeyword.trim()" @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" />
<div v-if="!excludeSearchSubmitted && !excludeLoading" class="exclude-empty-hint">输入单号并点击查询后显示匹配订单</div>
<el-table v-if="excludeSearchSubmitted || excludeLoading" v-loading="excludeLoading" :data="excludeRowsPage" row-key="billId" border stripe max-height="420" empty-text="没有匹配的订单">
<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="transferNo" label="转单号" min-width="180" show-overflow-tooltip />
@@ -112,7 +111,7 @@
<el-table-column prop="currency" label="币种" width="80" />
</el-table>
<el-pagination
v-if="excludeTotal > 0"
v-if="excludeSearchSubmitted && excludeTotal > 0"
class="exclude-pagination"
:current-page="excludePageNum"
:page-size="excludePageSize"
@@ -121,9 +120,9 @@
@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>
<span class="generate-footer-summary">{{ excludeSearchSubmitted ? `命中 ${excludeTotal} 条 · 应收合计 ${money(excludeTotalAmount)},确认后全部排除` : '查询后将把所有命中订单从本次账单中排除' }}</span>
<el-button @click="excludeDialogOpen = false">取消</el-button>
<el-button type="primary" :disabled="!excludeSearchSubmitted || excludeLoading" @click="confirmExcludeSelection">确定</el-button>
</template>
</el-dialog>
@@ -143,7 +142,7 @@
</template>
<script setup name="GenerateBillDialog">
import { getDefaultArBankAccount, previewArBill, generateArBill } from '@/api/ar/bill'
import { getDefaultArBankAccount, previewArBill, previewExcludedArBill, generateArBill } from '@/api/ar/bill'
import { Refresh, Search, Select } from '@element-plus/icons-vue'
import { AR_BILL_SELECTION_MODE } from '@/constants/ar/enums'
@@ -175,21 +174,21 @@ 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 excludeSearchKeyword = ref('')
const excludeSearchSubmitted = ref(false)
const excludeRowsPage = ref([])
const excludeTotal = ref(0)
const excludeTotalAmount = 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
// 排除弹窗每页固定 20 条;输入改变后必须重新查询才能确认。
const excludePageSize = 20
const confirmedExcludeKeyword = ref('')
const confirmedExcludeCount = ref(0)
const confirmedExcludeAmount = ref(0)
let excludeRequestSeq = 0
const previewQuery = ref(null)
let syncingPageSelection = false
const bankAccountLoading = ref(false)
@@ -206,12 +205,11 @@ 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)
? Math.max(previewTotal.value - confirmedExcludeCount.value, 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)
? Math.max(Number(previewTotalAmount.value || 0) - confirmedExcludeAmount.value, 0) : selectedAmount.value)
watch(() => props.modelValue, visible => {
if (visible) {
@@ -249,6 +247,7 @@ function clearOrderIdentifiers() {
// 只清空排除输入内容,不自动刷新排除结果;用户点击“查询”后再按新条件筛选。
function clearExcludeIdentifiers() {
excludeKeyword.value = ''
invalidateExcludeSearch()
}
function loadDefaultBankAccount() {
@@ -270,10 +269,8 @@ function resetPreview() {
selectedRows.value = []
selectedRowMap.clear()
selectionMode.value = AR_BILL_SELECTION_MODE.EXPLICIT
excludedRows.value = []
excludedRowMap.clear()
excludeDraftRows.value = []
excludeDraftMap = new Map()
clearConfirmedExclusionState()
invalidateExcludeSearch()
excludeDialogOpen.value = false
previewQuery.value = null
previewSearched.value = false
@@ -292,14 +289,16 @@ function buildGenerateQuery() {
}
}
// 任务请求携带完整筛选快照;全选模式只提交排除 ID,后台按条件游标扫描其余订单。
// 任务请求携带完整筛选快照;全选模式将排除单号条件交给后台按批次过滤。
function buildGenerateRequest(requestId) {
return {
...(previewQuery.value || buildGenerateQuery()),
billName: billNameForm.billName.trim(),
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()) : [],
excludedSourceBillIds: [],
excludedOrderOrTrackingNo: selectionMode.value === AR_BILL_SELECTION_MODE.ALL_MATCHED
? confirmedExcludeKeyword.value || undefined : undefined,
requestId
}
}
@@ -359,8 +358,7 @@ function handleSelectAllChange(checked) {
return
}
selectionMode.value = AR_BILL_SELECTION_MODE.ALL_MATCHED
excludedRows.value = []
excludedRowMap.clear()
clearConfirmedExclusionState()
selectedRowMap.clear()
selectedRows.value = []
if (!generateLoading.value) generateRequestId.value = ''
@@ -374,8 +372,7 @@ function handleManualModeChange(checked) {
// 从全选切回手动选择时清除排除集合,恢复主表跨页勾选流程。
function switchToManualMode() {
selectionMode.value = AR_BILL_SELECTION_MODE.EXPLICIT
excludedRows.value = []
excludedRowMap.clear()
clearConfirmedExclusionState()
selectedRowMap.clear()
selectedRows.value = []
if (!generateLoading.value) generateRequestId.value = ''
@@ -386,52 +383,64 @@ 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 = ''
excludeKeyword.value = confirmedExcludeKeyword.value
excludeSearchKeyword.value = ''
excludeSearchSubmitted.value = false
excludePageNum.value = 1
excludeRowsPage.value = []
excludeTotal.value = 0
excludeTotalAmount.value = 0
excludeLoading.value = false
excludeRequestSeq += 1
excludeDialogOpen.value = true
fetchExcludeRows()
}
function buildExcludeQuery() {
return {
...(previewQuery.value || {}),
excludeOrderOrTrackingNo: excludeKeyword.value?.trim() || undefined,
excludeOrderOrTrackingNo: excludeSearchKeyword.value,
pageNum: excludePageNum.value,
pageSize: excludePageSize
}
}
function queryExcludeRows() {
const keyword = excludeKeyword.value?.trim()
if (!keyword) {
proxy.$modal.msgWarning('请输入订单号、运单号或转单号后再查询')
invalidateExcludeSearch()
return
}
excludeSearchKeyword.value = keyword
excludePageNum.value = 1
fetchExcludeRows()
}
function invalidateExcludeSearch() {
excludeRequestSeq += 1
excludeSearchSubmitted.value = false
excludeRowsPage.value = []
excludeTotal.value = 0
excludeTotalAmount.value = 0
excludePageNum.value = 1
excludeLoading.value = false
}
function fetchExcludeRows() {
if (!previewQuery.value) return
if (!previewQuery.value || !excludeSearchKeyword.value) return
const requestSeq = ++excludeRequestSeq
excludeLoading.value = true
previewArBill(buildExcludeQuery()).then(res => {
syncingExcludeSelection = true
previewExcludedArBill(buildExcludeQuery()).then(res => {
if (requestSeq !== excludeRequestSeq) return
excludeRowsPage.value = res.rows || []
excludeTotal.value = Number(res.total) || 0
nextTick(syncExcludePageSelection)
excludeTotalAmount.value = Number(res.totalAmount) || 0
excludeSearchSubmitted.value = true
}).finally(() => {
excludeLoading.value = false
if (requestSeq === excludeRequestSeq) excludeLoading.value = false
})
}
@@ -440,56 +449,28 @@ function handleExcludePageChange(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 (!excludeSearchSubmitted.value || excludeLoading.value) return
confirmedExcludeKeyword.value = excludeSearchKeyword.value
confirmedExcludeCount.value = excludeTotal.value
confirmedExcludeAmount.value = excludeTotalAmount.value
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 clearConfirmedExclusionState() {
confirmedExcludeKeyword.value = ''
confirmedExcludeCount.value = 0
confirmedExcludeAmount.value = 0
}
function cancelExcludeSelection() {
excludeDialogConfirmed = false
excludeDialogOpen.value = false
function clearConfirmedExclusions() {
clearConfirmedExclusionState()
if (!generateLoading.value) generateRequestId.value = ''
}
function handleExcludeDialogClose() {
if (!excludeDialogConfirmed) {
excludeDraftMap = new Map(excludedRowMap)
syncExcludeDraftRows()
}
excludeDialogConfirmed = false
invalidateExcludeSearch()
}
// 翻页后把 Map 中的选择同步回 Element Plus 当前页复选框。
@@ -561,8 +542,8 @@ function createRequestId() { return globalThis.crypto?.randomUUID?.() || `ar-${D
.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; }
.excluded-order-preview { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; min-height: 34px; margin: 0 0 8px; padding: 4px 8px; border-radius: 4px; background: var(--el-fill-color-light); color: var(--el-text-color-secondary); font-size: 13px; }
.exclude-empty-hint { display: flex; align-items: center; justify-content: center; min-height: 180px; margin-top: 12px; border: 1px solid var(--el-border-color-lighter); color: var(--el-text-color-secondary); }
.exclude-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.exclude-search-input { width: 540px; }
.exclude-pagination { justify-content: flex-end; margin: 12px 0 4px; }