账单异步生成优化
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 应收账单接口统一放在这里;生成和导出均为异步任务,提交接口不等待后台处理完成。
|
||||
export function listArBill(query) {
|
||||
return request({ url: '/ar/bill/list', method: 'get', params: query })
|
||||
}
|
||||
@@ -8,12 +9,36 @@ export function getArBill(arBillId) {
|
||||
return request({ url: '/ar/bill/' + arBillId, method: 'get', timeout: 30000 })
|
||||
}
|
||||
|
||||
export function listArBillDetails(arBillId, query = {}) {
|
||||
return request({ url: '/ar/bill/' + arBillId + '/details', method: 'get', params: query })
|
||||
}
|
||||
|
||||
export function listArBillDetailFees(arBillId, detailId) {
|
||||
return request({ url: '/ar/bill/' + arBillId + '/details/' + detailId + '/fees', method: 'get' })
|
||||
}
|
||||
|
||||
export function getDefaultArBankAccount(currency = 'CNY') {
|
||||
return request({ url: '/ar/bill/default-bank-account', method: 'get', params: { currency } })
|
||||
}
|
||||
|
||||
export function generateArBill(data) {
|
||||
return request({ url: '/ar/bill/generate', method: 'post', data, timeout: 60000 })
|
||||
return request({ url: '/ar/bill/generate', method: 'post', data, timeout: 30000 })
|
||||
}
|
||||
|
||||
export function getArBillGenerateTask(taskId) {
|
||||
return request({ url: '/ar/bill/generate/task/' + taskId, method: 'get' })
|
||||
}
|
||||
|
||||
export function listActiveArBillGenerateTasks() {
|
||||
return request({ url: '/ar/bill/generate/tasks/active', method: 'get' })
|
||||
}
|
||||
|
||||
export function cancelArBillGenerateTask(taskId) {
|
||||
return request({ url: '/ar/bill/generate/task/' + taskId + '/cancel', method: 'post' })
|
||||
}
|
||||
|
||||
export function retryArBillGenerateTask(taskId) {
|
||||
return request({ url: '/ar/bill/generate/task/' + taskId + '/retry', method: 'post' })
|
||||
}
|
||||
|
||||
export function previewArBill(query) {
|
||||
@@ -36,6 +61,10 @@ export function listArBillExports(arBillId) {
|
||||
return request({ url: '/ar/bill/' + arBillId + '/exports', method: 'get' })
|
||||
}
|
||||
|
||||
export function getArBillExport(exportId) {
|
||||
return request({ url: '/ar/bill/export/' + exportId, method: 'get' })
|
||||
}
|
||||
|
||||
export function downloadArBillExport(exportId) {
|
||||
return request({ url: '/ar/bill/export/' + exportId + '/download', method: 'get', responseType: 'blob' })
|
||||
}
|
||||
|
||||
@@ -30,6 +30,12 @@ export const AR_BILL_STATUS_TYPES = Object.freeze({
|
||||
[AR_BILL_STATUS.VOID]: 'danger'
|
||||
})
|
||||
|
||||
/** 账单生成时的订单选择模式,value 与后端枚举和数据库 code 保持一致。 */
|
||||
export const AR_BILL_SELECTION_MODE = Object.freeze({
|
||||
EXPLICIT: 'EXPLICIT', // 手动选择当前预览结果
|
||||
ALL_MATCHED: 'ALL_MATCHED' // 全选查询结果,再排除指定订单
|
||||
})
|
||||
|
||||
/** 日志状态变化的兼容展示。日志是通用表,除账单状态外还可能保留历史收款状态。 */
|
||||
export const AR_LOG_STATUS_OPTIONS = Object.freeze([
|
||||
...AR_BILL_STATUS_OPTIONS,
|
||||
|
||||
@@ -17,17 +17,21 @@
|
||||
<el-descriptions-item label="收款账号">{{ mask(detail.bankAccountNo) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div class="section-title">订单快照({{ detail.orderCount || 0 }})</div>
|
||||
<el-table :data="detail.details || []" border size="small">
|
||||
<!-- 账单明细独立分页;费用明细在展开行时再懒加载。 -->
|
||||
<div class="section-title">账单明细({{ detail.orderCount || 0 }})</div>
|
||||
<el-table v-loading="detailListLoading" :data="detailRows" row-key="detailId" border size="small" @expand-change="handleExpandChange">
|
||||
<el-table-column type="expand">
|
||||
<template #default="{ row }">
|
||||
<el-table :data="row.fees || []" border size="small" class="fee-table">
|
||||
<el-table-column prop="feeType" label="费用类型" width="140" />
|
||||
<el-table-column prop="feeName" label="费用名称" min-width="160" />
|
||||
<el-table-column prop="amount" label="金额" width="120" align="right"><template #default="{ row: fee }">{{ money(fee.amount) }}</template></el-table-column>
|
||||
<el-table-column prop="currency" label="币种" width="80" />
|
||||
<el-table-column prop="remark" label="说明" show-overflow-tooltip />
|
||||
</el-table>
|
||||
<div v-loading="row.feeLoading" class="fee-panel">
|
||||
<el-empty v-if="row.feesLoaded && !row.fees?.length" description="暂无费用明细" :image-size="60" />
|
||||
<el-table v-else :data="row.fees || []" border size="small" class="fee-table">
|
||||
<el-table-column prop="feeType" label="费用类型" width="140" />
|
||||
<el-table-column prop="feeName" label="费用名称" min-width="160" />
|
||||
<el-table-column prop="amount" label="金额" width="120" align="right"><template #default="{ row: fee }">{{ money(fee.amount) }}</template></el-table-column>
|
||||
<el-table-column prop="currency" label="币种" width="80" />
|
||||
<el-table-column prop="remark" label="说明" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="detailNo" label="#" width="55" align="center" />
|
||||
@@ -38,12 +42,24 @@
|
||||
<el-table-column label="重量/尺寸" min-width="170"><template #default="{ row }">{{ row.chargeWeight || '-' }} kg / {{ dimensions(row) }}</template></el-table-column>
|
||||
<el-table-column prop="totalAmount" label="订单金额" width="120" align="right"><template #default="{ row }">{{ money(row.totalAmount) }}</template></el-table-column>
|
||||
</el-table>
|
||||
<el-pagination
|
||||
v-if="detailTotal > 0"
|
||||
class="detail-pagination"
|
||||
v-model:current-page="detailPageNum"
|
||||
v-model:page-size="detailPageSize"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="detailTotal"
|
||||
@current-change="loadDetails"
|
||||
@size-change="handleDetailSizeChange"
|
||||
/>
|
||||
|
||||
<div class="section-title export-title"><span>导出历史</span><el-button size="small" type="primary" plain icon="Download" @click="$emit('export')">生成新文件</el-button></div>
|
||||
<div class="section-title export-title"><span>导出历史</span><el-button size="small" type="primary" plain icon="Download" :loading="exporting" :disabled="exporting" @click="$emit('export')">{{ exporting ? '生成中' : '生成新文件' }}</el-button></div>
|
||||
<el-table :data="detail.exports || []" border size="small">
|
||||
<el-table-column prop="fileName" label="文件名" min-width="250" show-overflow-tooltip />
|
||||
<el-table-column prop="templateVersion" label="模板版本" width="100" />
|
||||
<el-table-column prop="exportStatus" label="状态" width="100"><template #default="{ row }"><el-tag :type="exportStatusType(row.exportStatus)">{{ exportStatusText(row.exportStatus) }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="remark" label="失败原因" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="exportBy" label="导出人" width="100" />
|
||||
<el-table-column prop="exportTime" label="导出时间" width="170" />
|
||||
<el-table-column label="操作" width="90"><template #default="{ row }"><el-button v-if="row.exportStatus === AR_EXPORT_STATUS.SUCCESS" link type="primary" @click="$emit('download', row)">下载</el-button></template></el-table-column>
|
||||
@@ -54,10 +70,64 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { listArBillDetails, listArBillDetailFees } from '@/api/ar/bill'
|
||||
import { AR_BILL_STATUS_OPTIONS, AR_BILL_STATUS_TYPES, AR_EXPORT_STATUS, AR_EXPORT_STATUS_OPTIONS, AR_EXPORT_STATUS_TYPES, arEnumLabel } from '@/constants/ar/enums'
|
||||
|
||||
defineProps({ modelValue: Boolean, detail: { type: Object, default: null }, loading: Boolean })
|
||||
const props = defineProps({ modelValue: Boolean, detail: { type: Object, default: null }, loading: Boolean })
|
||||
defineEmits(['update:modelValue', 'download', 'export'])
|
||||
const detailRows = ref([])
|
||||
const detailTotal = ref(0)
|
||||
const detailPageNum = ref(1)
|
||||
// 详情默认每页 10 条,避免几十万条账单明细一次性进入浏览器内存。
|
||||
const detailPageSize = ref(10)
|
||||
const detailListLoading = ref(false)
|
||||
let detailRequestSerial = 0
|
||||
const exporting = computed(() => props.detail?.exports?.some(item => item.exportStatus === AR_EXPORT_STATUS.PROCESSING))
|
||||
|
||||
// 切换账单时重置分页和请求序号,旧账单的慢响应不能覆盖新账单。
|
||||
watch(() => props.detail?.arBillId, billId => {
|
||||
detailRequestSerial += 1
|
||||
detailPageNum.value = 1
|
||||
detailTotal.value = 0
|
||||
detailRows.value = []
|
||||
if (billId) loadDetails()
|
||||
}, { immediate: true })
|
||||
|
||||
// 账单明细接口只返回当前页;分页变化时复用同一账单 ID 重新查询。
|
||||
function loadDetails() {
|
||||
const billId = props.detail?.arBillId
|
||||
if (!billId) return
|
||||
const serial = ++detailRequestSerial
|
||||
detailListLoading.value = true
|
||||
listArBillDetails(billId, { pageNum: detailPageNum.value, pageSize: detailPageSize.value }).then(res => {
|
||||
if (serial !== detailRequestSerial) return
|
||||
detailRows.value = (res.rows || []).map(row => ({ ...row, fees: [], feesLoaded: false, feeLoading: false }))
|
||||
detailTotal.value = Number(res.total) || 0
|
||||
}).finally(() => {
|
||||
if (serial === detailRequestSerial) detailListLoading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
function handleDetailSizeChange(size) {
|
||||
detailPageSize.value = size
|
||||
detailPageNum.value = 1
|
||||
loadDetails()
|
||||
}
|
||||
|
||||
// 费用明细按行懒加载,并用标记避免同一行重复请求。
|
||||
function handleExpandChange(row, expandedRows) {
|
||||
if (!expandedRows.includes(row) || row.feesLoaded || row.feeLoading) return
|
||||
const billId = props.detail?.arBillId
|
||||
if (!billId || !row.detailId) return
|
||||
row.feeLoading = true
|
||||
listArBillDetailFees(billId, row.detailId).then(res => {
|
||||
row.fees = res.data || []
|
||||
row.feesLoaded = true
|
||||
}).finally(() => {
|
||||
row.feeLoading = false
|
||||
})
|
||||
}
|
||||
|
||||
function date(value) { return value ? String(value).slice(0, 10) : '-' }
|
||||
function money(value) { const num = Number(value); return Number.isNaN(num) ? '-' : num.toFixed(2) }
|
||||
function statusText(value) { return arEnumLabel(AR_BILL_STATUS_OPTIONS, value) }
|
||||
@@ -75,5 +145,7 @@ function mask(value) { if (!value) return '-'; return value.length <= 4 ? '****'
|
||||
.section-title::before { content: ''; width: 3px; height: 15px; border-radius: 2px; background: var(--el-color-primary); }
|
||||
.export-title { justify-content: space-between; }
|
||||
.export-title::before { margin-right: auto; }
|
||||
.detail-pagination { justify-content: flex-end; margin: 12px 0 4px; }
|
||||
.fee-panel { min-height: 54px; padding: 4px 12px 8px 42px; background: #fafafa; }
|
||||
.fee-table { background: #fafafa; }
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,5 +1,32 @@
|
||||
<template>
|
||||
<div class="app-container ar-bill-page">
|
||||
<div v-if="generateStatusVisible" class="generate-status-float">
|
||||
<el-tooltip :content="generateStatusTooltip" placement="bottom">
|
||||
<el-button class="generate-status-button" type="primary" plain size="small"
|
||||
:disabled="activeGenerateTask?.status === 'CANCEL_REQUESTED'" @click="cancelActiveGenerateTask">
|
||||
<span class="generate-progress-ring" :style="{ '--generate-progress': `${generateProgressDegree}deg` }" aria-hidden="true">
|
||||
<span class="generate-progress-value">{{ generateProgressText }}</span>
|
||||
</span>
|
||||
<span class="generate-status-text">
|
||||
<strong>{{ generateStatusTitle }}</strong>
|
||||
<small>{{ generateStatusSummary }}</small>
|
||||
</span>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<div v-if="exportStatusVisible" class="export-status-float">
|
||||
<el-tooltip content="点击查看正在生成的导出任务" placement="bottom">
|
||||
<el-button class="export-status-button" type="primary" plain size="small" @click="openExportTask(activeExportTasks[0])">
|
||||
<span class="export-progress-ring" aria-hidden="true">
|
||||
<el-icon class="export-download-icon"><Download /></el-icon>
|
||||
</span>
|
||||
<span class="export-status-text">
|
||||
<strong>导出中</strong>
|
||||
<small>后台生成中{{ activeExportTasks.length > 1 ? ` · ${activeExportTasks.length}个任务` : '' }}</small>
|
||||
</span>
|
||||
</el-button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<basic-table ref="tableRef" :columns="columns" :search-columns="searchColumns" :search-label-width="82" :query="queryList" :default-params="defaultSearchParams" row-key="arBillId" :selection="false">
|
||||
<template #toolbar>
|
||||
<el-col :span="1.5"><el-button type="primary" plain icon="Plus" v-hasPermi="['ar:bill:generate']" @click="generateOpen = true">生成账单</el-button></el-col>
|
||||
@@ -28,7 +55,7 @@
|
||||
|
||||
<script setup name="ArBill">
|
||||
import { saveAs } from 'file-saver'
|
||||
import { listArBill, getArBill, confirmArBill, voidArBill, exportArBill, downloadArBillExport } from '@/api/ar/bill'
|
||||
import { listArBill, getArBill, confirmArBill, voidArBill, exportArBill, getArBillExport, downloadArBillExport, getArBillGenerateTask, listActiveArBillGenerateTasks, cancelArBillGenerateTask, retryArBillGenerateTask } from '@/api/ar/bill'
|
||||
import BillDetail from './components/BillDetail.vue'
|
||||
import GenerateBillDialog from './components/GenerateBillDialog.vue'
|
||||
import { AR_BILL_STATUS, AR_BILL_STATUS_OPTIONS, AR_BILL_STATUS_TYPES } from '@/constants/ar/bill'
|
||||
@@ -44,7 +71,52 @@ const defaultSearchParams = computed(() => ({
|
||||
const detailOpen = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detail = ref(null)
|
||||
const openedBillId = ref()
|
||||
const generateOpen = ref(false)
|
||||
// 任务可能在首次轮询前就完成,至少展示一段时间让用户感知到“正在生成”。
|
||||
const MIN_GENERATE_STATUS_VISIBLE_MS = 1800
|
||||
// 每个任务独立保存轮询、收尾定时器和开始展示时间,避免多个账单任务互相覆盖。
|
||||
const generatePollTimers = new Map()
|
||||
const generateFinishTimers = new Map()
|
||||
const generateVisibleSince = new Map()
|
||||
const generateTasks = reactive(new Map())
|
||||
const activeGenerateTasks = computed(() => Array.from(generateTasks.values()))
|
||||
const activeGenerateTask = computed(() => activeGenerateTasks.value[0])
|
||||
const generateStatusVisible = computed(() => activeGenerateTasks.value.length > 0)
|
||||
const generateStatusTitle = computed(() => {
|
||||
const status = activeGenerateTask.value?.status
|
||||
if (status === 'WAITING') return '账单排队中'
|
||||
if (status === 'CANCEL_REQUESTED') return '账单取消中'
|
||||
return '账单生成中'
|
||||
})
|
||||
const generateStatusTooltip = computed(() => {
|
||||
return activeGenerateTask.value?.status === 'CANCEL_REQUESTED'
|
||||
? '正在清理已生成的账单数据,请稍候'
|
||||
: '点击取消当前账单生成任务'
|
||||
})
|
||||
const generateProgressText = computed(() => {
|
||||
const task = activeGenerateTask.value
|
||||
if (!task || Number(task.totalCount) <= 0) return '…'
|
||||
return `${Math.min(100, Math.floor(Number(task.processedCount || 0) * 100 / Number(task.totalCount)))}%`
|
||||
})
|
||||
const generateProgressDegree = computed(() => {
|
||||
const task = activeGenerateTask.value
|
||||
if (!task || Number(task.totalCount) <= 0) return 45
|
||||
return Math.max(0, Math.min(360, Number(task.processedCount || 0) * 360 / Number(task.totalCount)))
|
||||
})
|
||||
const generateStatusSummary = computed(() => {
|
||||
const task = activeGenerateTask.value
|
||||
if (!task) return '任务提交中'
|
||||
if (task.status === 'CANCEL_REQUESTED') return '正在清理数据'
|
||||
if (task.status === 'WAITING') return '等待后台处理'
|
||||
if (Number(task.totalCount) > 0) return `${Number(task.processedCount || 0)} / ${Number(task.totalCount)} 条`
|
||||
return '正在准备数据'
|
||||
})
|
||||
const exportPollTimers = new Map()
|
||||
const exportTasks = reactive(new Map())
|
||||
const activeExportTasks = computed(() => Array.from(exportTasks.values()))
|
||||
const exportSubmittingCount = ref(0)
|
||||
const exportStatusVisible = computed(() => exportSubmittingCount.value > 0 || activeExportTasks.value.length > 0)
|
||||
const statusOptions = AR_BILL_STATUS_OPTIONS
|
||||
const customerScoped = computed(() => Boolean(route.query.customerId))
|
||||
const columns = [
|
||||
@@ -59,12 +131,167 @@ const searchColumns = [
|
||||
{ label: '状态', prop: 'status', component: 'select', options: statusOptions, placeholder: '全部状态', width: '150px' }, { label: '账单日期', prop: 'billDateRange', component: 'daterange', width: '300px' }, { label: '账单截止日', prop: 'billPeriodEnd', component: 'date', width: '160px' }
|
||||
]
|
||||
|
||||
// 将页面日期范围转换为后端查询字段,其他筛选条件原样透传。
|
||||
function queryList(params) { const query = { ...params }; if (query.billDateRange?.length === 2) { query.billDateStart = query.billDateRange[0]; query.billDateEnd = query.billDateRange[1] }; delete query.billDateRange; return listArBill(query) }
|
||||
function handleGenerateSuccess(bill) { tableRef.value.refresh(); viewBill(bill) }
|
||||
function viewBill(row) { if (!row?.arBillId) return; detailOpen.value = true; detailLoading.value = true; detail.value = null; getArBill(row.arBillId).then(res => { detail.value = res.data || null }).finally(() => { detailLoading.value = false }) }
|
||||
// 生成弹窗只提交任务,账单明细由后台异步批量写入;这里负责刷新列表并开始轮询。
|
||||
function handleGenerateSuccess(task) {
|
||||
tableRef.value?.refresh()
|
||||
if (!task?.taskId) return
|
||||
generateVisibleSince.set(task.taskId, Date.now())
|
||||
generateTasks.set(task.taskId, task)
|
||||
startGeneratePolling(task.taskId)
|
||||
}
|
||||
|
||||
function startGeneratePolling(taskId) {
|
||||
stopGeneratePolling(taskId)
|
||||
// WAITING/RUNNING/CANCEL_REQUESTED 都属于前端持续关注的中间状态。
|
||||
const poll = () => {
|
||||
getArBillGenerateTask(taskId).then(res => {
|
||||
const task = res.data
|
||||
if (!task) return
|
||||
generateTasks.set(taskId, task)
|
||||
if (['SUCCESS', 'FAILED', 'CANCELED'].includes(task.status)) {
|
||||
finishGenerateTask(taskId, task)
|
||||
return
|
||||
}
|
||||
generatePollTimers.set(taskId, window.setTimeout(poll, 2000))
|
||||
}).catch(() => {
|
||||
generatePollTimers.set(taskId, window.setTimeout(poll, 3000))
|
||||
})
|
||||
}
|
||||
generatePollTimers.set(taskId, window.setTimeout(poll, 1000))
|
||||
}
|
||||
|
||||
function finishGenerateTask(taskId, task) {
|
||||
stopGeneratePolling(taskId)
|
||||
if (generateFinishTimers.has(taskId)) return
|
||||
// 即使后台很快返回 SUCCESS,也延迟清理浮层,保证用户能看到结果反馈。
|
||||
const visibleSince = generateVisibleSince.get(taskId) || Date.now()
|
||||
const remaining = Math.max(0, MIN_GENERATE_STATUS_VISIBLE_MS - (Date.now() - visibleSince))
|
||||
const timer = window.setTimeout(() => {
|
||||
generateFinishTimers.delete(taskId)
|
||||
generateVisibleSince.delete(taskId)
|
||||
generateTasks.delete(taskId)
|
||||
tableRef.value?.refresh()
|
||||
if (task.status === 'SUCCESS') {
|
||||
proxy.$modal.msgSuccess('账单生成成功')
|
||||
if (task.arBillId) viewBill({ arBillId: task.arBillId })
|
||||
} else if (task.status === 'FAILED') {
|
||||
proxy.$modal.confirm(`账单生成失败:${task.errorMessage || '未知错误'},是否重新生成?`).then(() => retryGenerateTask(task.taskId)).then(res => {
|
||||
const retryTask = res.data
|
||||
if (retryTask?.taskId) {
|
||||
generateVisibleSince.set(retryTask.taskId, Date.now())
|
||||
generateTasks.set(retryTask.taskId, retryTask)
|
||||
startGeneratePolling(retryTask.taskId)
|
||||
}
|
||||
}).catch(() => {})
|
||||
} else {
|
||||
proxy.$modal.msgWarning('账单生成任务已取消')
|
||||
}
|
||||
}, remaining)
|
||||
generateFinishTimers.set(taskId, timer)
|
||||
}
|
||||
|
||||
function stopGeneratePolling(taskId) {
|
||||
const timer = generatePollTimers.get(taskId)
|
||||
if (timer) window.clearTimeout(timer)
|
||||
generatePollTimers.delete(taskId)
|
||||
}
|
||||
|
||||
function cancelActiveGenerateTask() {
|
||||
const task = activeGenerateTask.value
|
||||
if (!task?.taskId) return
|
||||
proxy.$modal.confirm('账单正在后台生成,确定取消当前任务吗?').then(() => cancelArBillGenerateTask(task.taskId)).then(res => {
|
||||
const updated = res.data
|
||||
if (updated) generateTasks.set(updated.taskId, updated)
|
||||
proxy.$modal.msgSuccess('已提交取消请求')
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
function loadActiveGenerateTasks() {
|
||||
listActiveArBillGenerateTasks().then(res => {
|
||||
;(res.data || []).forEach(task => {
|
||||
generateTasks.set(task.taskId, task)
|
||||
if (!generateVisibleSince.has(task.taskId)) generateVisibleSince.set(task.taskId, Date.now())
|
||||
startGeneratePolling(task.taskId)
|
||||
})
|
||||
}).catch(() => {})
|
||||
}
|
||||
let detailRequestSerial = 0
|
||||
function viewBill(row) {
|
||||
if (!row?.arBillId) return
|
||||
const serial = ++detailRequestSerial
|
||||
openedBillId.value = row.arBillId
|
||||
detailOpen.value = true
|
||||
detailLoading.value = true
|
||||
detail.value = null
|
||||
getArBill(row.arBillId).then(res => {
|
||||
if (serial === detailRequestSerial) detail.value = res.data || null
|
||||
}).finally(() => {
|
||||
if (serial === detailRequestSerial) detailLoading.value = false
|
||||
})
|
||||
}
|
||||
function confirmBill(row) { proxy.$modal.confirm(`确认账单「${row.billNo}」?确认后将进入正式账单状态,请确认订单及费用金额无误后继续。`).then(() => confirmArBill(row.arBillId)).then(() => { proxy.$modal.msgSuccess('账单已确认'); tableRef.value.refresh(); viewBill(row) }).catch(() => {}) }
|
||||
function voidBill(row) { proxy.$prompt(`请输入账单「${row.billNo}」的作废原因`, '作废账单', { inputPattern: /\S+/, inputErrorMessage: '作废原因不能为空' }).then(({ value }) => voidArBill(row.arBillId, { remark: value })).then(() => { proxy.$modal.msgSuccess('账单已作废'); tableRef.value.refresh(); viewBill(row) }).catch(() => {}) }
|
||||
function exportBill(row) { if (!row?.arBillId) return; exportArBill(row.arBillId).then(res => { proxy.$modal.msgSuccess('导出成功'); tableRef.value.refresh(); if (detailOpen.value) viewBill(row); if (res.data?.exportId) downloadExport(res.data) }) }
|
||||
function exportBill(row) {
|
||||
if (!row?.arBillId) return
|
||||
exportSubmittingCount.value += 1
|
||||
exportArBill(row.arBillId).then(res => {
|
||||
const exportTask = res.data
|
||||
tableRef.value?.refresh()
|
||||
// 导出同样是异步任务;打开详情展示“生成中”状态及完成后的下载入口。
|
||||
viewBill(row)
|
||||
if (exportTask?.exportId) {
|
||||
exportTasks.set(exportTask.exportId, exportTask)
|
||||
startExportPolling(exportTask.exportId)
|
||||
}
|
||||
}).finally(() => {
|
||||
exportSubmittingCount.value = Math.max(0, exportSubmittingCount.value - 1)
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(loadActiveGenerateTasks)
|
||||
|
||||
function startExportPolling(exportId) {
|
||||
stopExportPolling(exportId)
|
||||
// 导出文件在后台临时目录生成,前端只轮询状态,不等待文件内容返回。
|
||||
const poll = () => {
|
||||
getArBillExport(exportId).then(res => {
|
||||
const exportTask = res.data
|
||||
if (!exportTask || exportTask.exportStatus === 'PROCESSING') {
|
||||
if (exportTask) exportTasks.set(exportId, exportTask)
|
||||
exportPollTimers.set(exportId, window.setTimeout(poll, 1500))
|
||||
return
|
||||
}
|
||||
stopExportPolling(exportId)
|
||||
exportTasks.delete(exportId)
|
||||
tableRef.value?.refresh()
|
||||
if (detailOpen.value && openedBillId.value === exportTask.arBillId) viewBill(exportTask)
|
||||
if (exportTask.exportStatus === 'SUCCESS') {
|
||||
proxy.$modal.msgSuccess('导出文件已生成,可在导出历史中下载')
|
||||
} else {
|
||||
proxy.$modal.msgError(`账单导出失败:${exportTask.remark || '请重新发起导出'}`)
|
||||
}
|
||||
}).catch(() => {
|
||||
stopExportPolling(exportId)
|
||||
exportTasks.delete(exportId)
|
||||
})
|
||||
}
|
||||
exportPollTimers.set(exportId, window.setTimeout(poll, 1500))
|
||||
}
|
||||
|
||||
function stopExportPolling(exportId) {
|
||||
const timer = exportPollTimers.get(exportId)
|
||||
if (timer) window.clearTimeout(timer)
|
||||
exportPollTimers.delete(exportId)
|
||||
}
|
||||
|
||||
function openExportTask(task) {
|
||||
if (!task?.arBillId) return
|
||||
viewBill({ arBillId: task.arBillId })
|
||||
}
|
||||
|
||||
// 仅下载 SUCCESS 状态的导出任务,文件名优先使用后端保存的名称。
|
||||
function downloadExport(row) { downloadArBillExport(row.exportId).then(blob => saveAs(blob, row.fileName || `账单_${row.exportId}.xlsx`)) }
|
||||
function clearCustomerFilter() {
|
||||
const query = { ...route.query }
|
||||
@@ -72,7 +299,7 @@ function clearCustomerFilter() {
|
||||
router.replace({ path: route.path, query })
|
||||
}
|
||||
|
||||
// 从客户余额页重复进入不同客户时,路由组件会被复用,需同步更新表格内部查询条件
|
||||
// 从客户余额页重复进入不同客户时,路由组件会被复用,需同步更新表格内部查询条件。
|
||||
watch(() => route.query.customerId, (newId, oldId) => {
|
||||
if (String(newId || '') === String(oldId || '')) return
|
||||
tableRef.value?.setQueryParams({
|
||||
@@ -82,6 +309,18 @@ watch(() => route.query.customerId, (newId, oldId) => {
|
||||
tableRef.value?.handleQuery()
|
||||
})
|
||||
|
||||
// 页面销毁时清理所有轮询定时器,避免切换菜单后仍访问旧页面状态。
|
||||
onBeforeUnmount(() => {
|
||||
generatePollTimers.forEach(timer => window.clearTimeout(timer))
|
||||
generatePollTimers.clear()
|
||||
generateFinishTimers.forEach(timer => window.clearTimeout(timer))
|
||||
generateFinishTimers.clear()
|
||||
generateVisibleSince.clear()
|
||||
exportPollTimers.forEach(timer => window.clearTimeout(timer))
|
||||
exportPollTimers.clear()
|
||||
exportTasks.clear()
|
||||
})
|
||||
|
||||
function canGoWriteoff(row) {
|
||||
if (!row?.customerId) return false
|
||||
const canWriteoffStatus = row.status === AR_BILL_STATUS.CONFIRMED
|
||||
@@ -152,6 +391,136 @@ function statusType(value) { return AR_BILL_STATUS_TYPES[value] || 'info' }
|
||||
.ar-bill-page .written { color: #67c23a; }
|
||||
.ar-bill-page .unwritten { color: #e6a23c; }
|
||||
|
||||
.ar-bill-page .generate-status-float {
|
||||
position: fixed;
|
||||
top: 92px;
|
||||
right: 24px;
|
||||
z-index: 3001;
|
||||
}
|
||||
|
||||
.ar-bill-page .generate-status-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 184px;
|
||||
min-height: 68px;
|
||||
padding: 6px 16px 6px 7px;
|
||||
border-radius: 36px;
|
||||
}
|
||||
|
||||
.ar-bill-page .generate-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(--generate-progress, 45deg),
|
||||
var(--el-color-primary-light-8) var(--generate-progress, 45deg) 360deg
|
||||
);
|
||||
}
|
||||
|
||||
.ar-bill-page .generate-progress-ring::after {
|
||||
position: absolute;
|
||||
inset: 5px;
|
||||
content: '';
|
||||
border-radius: 50%;
|
||||
background: var(--el-bg-color);
|
||||
}
|
||||
|
||||
.ar-bill-page .generate-progress-value {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
color: var(--el-color-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.ar-bill-page .generate-status-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 3px;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ar-bill-page .generate-status-text strong { font-size: 14px; font-weight: 600; }
|
||||
.ar-bill-page .generate-status-text small { color: var(--el-text-color-secondary); font-size: 11px; }
|
||||
|
||||
.ar-bill-page .export-status-float {
|
||||
position: fixed;
|
||||
top: 170px;
|
||||
right: 24px;
|
||||
z-index: 3001;
|
||||
}
|
||||
|
||||
.ar-bill-page .export-status-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 164px;
|
||||
min-height: 62px;
|
||||
border-radius: 32px;
|
||||
padding: 6px 16px 6px 7px;
|
||||
}
|
||||
|
||||
.ar-bill-page .export-progress-ring {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 48px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: conic-gradient(var(--el-color-primary) 0deg 285deg, var(--el-color-primary-light-8) 285deg 360deg);
|
||||
animation: ar-bill-export-spin 1.2s linear infinite;
|
||||
}
|
||||
|
||||
.ar-bill-page .export-progress-ring::after {
|
||||
position: absolute;
|
||||
inset: 5px;
|
||||
content: '';
|
||||
border-radius: 50%;
|
||||
background: var(--el-bg-color);
|
||||
}
|
||||
|
||||
.ar-bill-page .export-download-icon {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
color: var(--el-color-primary);
|
||||
font-size: 21px;
|
||||
}
|
||||
|
||||
.ar-bill-page .export-status-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ar-bill-page .export-status-text strong {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ar-bill-page .export-status-text small {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
@keyframes ar-bill-export-spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.ar-bill-page :deep(.basic-table > .basic-form) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
|
||||
@@ -19,12 +19,14 @@ import {
|
||||
} from '@/constants/ar/enums'
|
||||
|
||||
const tableRef = ref()
|
||||
// 日志筛选值与后端枚举保持一致,展示文案统一由枚举工具转换。
|
||||
const searchColumns = [
|
||||
{ label: '对象类型', prop: 'objectType', component: 'select', options: AR_LOG_OBJECT_TYPE_OPTIONS, width: '130px' },
|
||||
{ label: '操作类型', prop: 'operationType', component: 'select', options: AR_LOG_OPERATION_TYPE_OPTIONS, width: '150px' },
|
||||
{ label: '操作来源', prop: 'operationSource', component: 'select', options: AR_LOG_OPERATION_SOURCE_OPTIONS, width: '130px' },
|
||||
{ label: '结果', prop: 'result', component: 'select', options: AR_LOG_RESULT_OPTIONS, width: '120px' }
|
||||
]
|
||||
// 状态变化列同时展示前后状态,便于追踪确认、作废和核销等生命周期操作。
|
||||
const columns = [
|
||||
{ label: '对象类型', prop: 'objectType', width: 110, formatter: row => objectTypeText(row.objectType) }, { label: '对象', prop: 'objectDisplay', minWidth: 220, showOverflowTooltip: true, formatter: row => row.objectDisplay || '-' },
|
||||
{ label: '操作', prop: 'operationType', width: 150, formatter: row => operationText(row.operationType) }, { label: '说明', prop: 'operationDesc', minWidth: 220, showOverflowTooltip: true },
|
||||
|
||||
Reference in New Issue
Block a user