⚡ 优化应收合计查询并补充导出汇总

This commit is contained in:
caojia
2026-11-12 15:58:25 +08:00
parent fb9a4e1b29
commit 621128530a
5 changed files with 173 additions and 9 deletions
@@ -173,10 +173,16 @@ public class ArBillController extends BaseController {
AjaxResult result = success();
result.put("rows", pageData.getRows());
result.put("total", pageData.getTotal());
result.put("totalAmount", billService.sumPreviewBill(query));
return result;
}
/** 按需汇总生成预览范围内的应收金额。 */
@PreAuthorize("@ss.hasPermi('ar:bill:generate')")
@PostMapping("/generate/preview/amount")
public AjaxResult previewAmount(@RequestBody ArBillGenerateQuery query) {
return success(billService.sumPreviewBill(query));
}
/** 按生成预览使用的相同筛选条件提交异步导出任务。 */
@Log(title = "应收账单生成订单", businessType = BusinessType.EXPORT)
@PreAuthorize("@ss.hasPermi('ar:bill:generate')")
@@ -0,0 +1,112 @@
package com.bms.receivable;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.ArgumentMatchers.same;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.InputStream;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.usermodel.WorkbookFactory;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.test.util.ReflectionTestUtils;
import com.github.pagehelper.PageHelper;
import com.bms.common.config.BmsConfig;
import com.bms.common.core.domain.AjaxResult;
import com.bms.common.core.redis.RedisCache;
import com.bms.price.domain.PrcOrderBill;
import com.bms.receivable.domain.vo.ArBillGenerateQuery;
import com.bms.receivable.security.ArBillScopeResolver;
import com.bms.receivable.service.IArBillService;
import com.bms.receivable.task.ArBillGeneratePreviewExportRunner;
import com.bms.web.controller.receivable.ArBillController;
class ArBillGeneratePreviewTest {
@TempDir
Path tempDir;
@Test
void previewDoesNotCalculateAmountUntilAmountEndpointIsCalled() {
IArBillService service = mock(IArBillService.class);
ArBillController controller = new ArBillController();
ReflectionTestUtils.setField(controller, "billService", service);
ArBillGenerateQuery query = new ArBillGenerateQuery();
when(service.previewBill(query)).thenReturn(List.of(new PrcOrderBill()));
when(service.sumPreviewBill(query)).thenReturn(new BigDecimal("12.34"));
try {
AjaxResult preview = controller.preview(query);
assertFalse(preview.containsKey("totalAmount"));
verify(service, never()).sumPreviewBill(query);
} finally {
PageHelper.clearPage();
}
assertEquals(new BigDecimal("12.34"), controller.previewAmount(query).get("data"));
verify(service).sumPreviewBill(query);
}
@Test
void exportEndsWithTheSumOfTheExportedAmounts() throws Exception {
String previousProfile = BmsConfig.getProfile();
try {
new BmsConfig().setProfile(tempDir.toString());
IArBillService service = mock(IArBillService.class);
ArBillGenerateQuery query = new ArBillGenerateQuery();
when(service.previewBill(same(query), isNull())).thenReturn(List.of(
bill("A", "0.10"), bill("B", "0.20"), bill("C", "-0.05"), bill("D", null)));
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor() {
@Override
public void execute(Runnable task) {
task.run();
}
};
ArBillGeneratePreviewExportRunner runner = new ArBillGeneratePreviewExportRunner(
service, mock(RedisCache.class), mock(ArBillScopeResolver.class), executor);
for (String dateType : List.of("BIZ_DATE", "PICKUP_DATE")) {
query.setDateType(dateType);
String taskId = runner.submit(query, 1L);
Path file = tempDir.resolve("download/arBillGeneratePreviewExport/" + taskId + ".xlsx");
try (InputStream in = Files.newInputStream(file); Workbook workbook = WorkbookFactory.create(in)) {
Sheet sheet = workbook.getSheetAt(0);
Row header = sheet.getRow(0);
Row lastRow = sheet.getRow(sheet.getLastRowNum());
assertEquals("合计", lastRow.getCell(column(header, "订单号")).getStringCellValue());
assertEquals(0.25, lastRow.getCell(column(header, "应收金额")).getNumericCellValue(), 0.000001);
assertEquals(5, sheet.getLastRowNum());
}
}
} finally {
new BmsConfig().setProfile(previousProfile);
}
}
private static PrcOrderBill bill(String orderNo, String amount) {
PrcOrderBill bill = new PrcOrderBill();
bill.setOrderNo(orderNo);
bill.setIncomeTotal(amount == null ? null : new BigDecimal(amount));
return bill;
}
private static int column(Row header, String name) {
for (int i = 0; i < header.getLastCellNum(); i++) {
if (name.equals(header.getCell(i).getStringCellValue())) return i;
}
throw new AssertionError("缺少导出列: " + name);
}
}
@@ -1,6 +1,7 @@
package com.bms.receivable.task;
import java.io.OutputStream;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
@@ -75,10 +76,18 @@ public class ArBillGeneratePreviewExportRunner {
put(taskId, userId, "RUNNING", 5, "正在查询订单", null, 0, fileName, null);
List<PrcOrderBill> bills = billService.previewBill(query, scope);
put(taskId, userId, "RUNNING", 40, "正在生成 Excel", null, bills.size(), fileName, null);
List<ArBillGeneratePreviewExportRow> rows = new ArrayList<>(bills.size());
List<ArBillGeneratePreviewExportRow> rows = new ArrayList<>(bills.size() + 1);
BigDecimal totalAmount = BigDecimal.ZERO;
for (PrcOrderBill bill : bills) {
rows.add(ArBillGeneratePreviewExportRow.from(bill));
if (bill.getIncomeTotal() != null) {
totalAmount = totalAmount.add(bill.getIncomeTotal());
}
}
ArBillGeneratePreviewExportRow totalRow = new ArBillGeneratePreviewExportRow();
totalRow.setOrderNo("合计");
totalRow.setIncomeTotal(totalAmount);
rows.add(totalRow);
ExcelUtil<ArBillGeneratePreviewExportRow> util = new ExcelUtil<>(ArBillGeneratePreviewExportRow.class);
if (ArBillDateType.PICKUP_DATE == ArBillDateType.of(query.getDateType())) {
util.hideColumn("bizDate");
+9
View File
@@ -58,6 +58,15 @@ export function previewArBill(query) {
})
}
export function getArBillPreviewAmount(query) {
return request({
url: '/ar/bill/generate/preview/amount',
method: 'post',
data: query,
timeout: 60000
})
}
export function previewExcludedArBill(query) {
return request({
url: '/ar/bill/generate/exclusion-preview',
@@ -41,7 +41,11 @@
<span v-else class="payee-empty">当前币种尚未配置公司默认收款账户</span>
</div>
<div class="preview-toolbar">
<span v-if="previewSearched" class="preview-summary">可选 {{ previewTotal }} 条订单,已选 <b>{{ displaySelectedCount }}</b> 条,应收合计 <b>{{ money(displaySelectedAmount) }}</b></span>
<span v-if="previewSearched" class="preview-summary">
可选 {{ previewTotal }} 条订单,已选 <b>{{ displaySelectedCount }}</b> 条
<template v-if="previewAmountVisible">,应收合计 <b>{{ money(displaySelectedAmount) }}</b></template>
<el-button link type="primary" :loading="previewAmountLoading" :disabled="previewLoading || !previewTotal" @click="queryPreviewAmount">查询应收合计</el-button>
</span>
<span v-else class="preview-summary">请选择客户、币种和账单截止日后查询可生成的计费订单</span>
<el-button v-if="previewSearched && previewTotal > 0" type="warning" plain :icon="Download" :loading="previewExportLoading" :disabled="previewLoading || previewExportLoading" @click="exportPreview">导出查询结果</el-button>
<el-button type="primary" plain :loading="previewLoading" :disabled="!bankAccount" @click="queryGeneratePreview">查询</el-button>
@@ -104,7 +108,7 @@
@current-change="handlePreviewPageChange"
/>
<template #footer>
<span class="generate-footer-summary">已选 {{ displaySelectedCount }} 条订单 · {{ money(displaySelectedAmount) }}</span>
<span class="generate-footer-summary">已选 {{ displaySelectedCount }} 条订单<template v-if="previewAmountVisible"> · {{ money(displaySelectedAmount) }}</template></span>
<el-button type="primary" :loading="generateLoading" :disabled="!bankAccount || !previewSearched || !displaySelectedCount" @click="submitGenerate">生成账单</el-button>
<el-button @click="dialogOpen = false">取消</el-button>
</template>
@@ -163,7 +167,7 @@
<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">已选择 {{ displaySelectedCount }} 条订单,应收合计 {{ money(displaySelectedAmount) }}</div>
<div class="bill-name-summary">已选择 {{ displaySelectedCount }} 条订单<template v-if="previewAmountVisible">,应收合计 {{ money(displaySelectedAmount) }}</template></div>
<template #footer>
<el-button @click="billNameOpen = false">取消</el-button>
<el-button type="primary" :loading="generateLoading" @click="confirmGenerate">确认生成</el-button>
@@ -173,7 +177,7 @@
</template>
<script setup name="GenerateBillDialog">
import { listArBillGenerateCustomerOptions, getDefaultArBankAccount, previewArBill, previewExcludedArBill, generateArBill } from '@/api/ar/bill'
import { listArBillGenerateCustomerOptions, getDefaultArBankAccount, previewArBill, getArBillPreviewAmount, previewExcludedArBill, generateArBill } from '@/api/ar/bill'
import { Download, Refresh, Search, Select } from '@element-plus/icons-vue'
import { AR_BILL_SELECTION_MODE } from '@/constants/ar/enums'
import { exportAsync } from '@/utils/excelExport'
@@ -222,6 +226,9 @@ const previewSearched = ref(false)
const previewRows = ref([])
const previewTotal = ref(0)
const previewTotalAmount = ref(0)
const previewAmountVisible = ref(false)
const previewAmountLoading = ref(false)
let previewAmountRequestSeq = 0
const previewPageNum = ref(1)
// 主预览列表固定每页 5 条;Map 负责保存跨页勾选结果。
const previewPageSize = 5
@@ -267,7 +274,7 @@ const allSelected = computed(() => selectionMode.value === AR_BILL_SELECTION_MOD
const displaySelectedCount = computed(() => selectionMode.value === AR_BILL_SELECTION_MODE.ALL_MATCHED
? 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) - confirmedExcludeAmount.value, 0) : selectedAmount.value)
? previewTotalAmount.value - confirmedExcludeAmount.value : selectedAmount.value)
watch(() => props.modelValue, visible => {
if (visible) {
@@ -323,6 +330,9 @@ function resetPreview() {
previewRows.value = []
previewTotal.value = 0
previewTotalAmount.value = 0
previewAmountRequestSeq += 1
previewAmountVisible.value = false
previewAmountLoading.value = false
previewPageNum.value = 1
selectedRows.value = []
selectedRowMap.clear()
@@ -366,7 +376,7 @@ function buildPreviewQuery(pageNum = previewPageNum.value, pageSize = previewPag
return { ...(previewQuery.value || buildGenerateQuery()), pageNum, pageSize }
}
// 查询按钮是预览入口,后端返回当前页、总数和全量金额合计。
// 查询按钮是预览入口,后端返回当前页和总数。
function queryGeneratePreview() {
generateFormRef.value.validate(valid => {
if (!valid) return
@@ -383,7 +393,6 @@ function fetchPreview(initializeSelectionMode = false) {
syncingPageSelection = true
previewRows.value = res.rows || []
previewTotal.value = Number(res.total) || 0
previewTotalAmount.value = Number(res.totalAmount) || 0
// 仅首次查询默认全选;翻页请求保留用户当前的选择模式。
if (initializeSelectionMode) {
selectionMode.value = previewTotal.value > 0
@@ -397,6 +406,25 @@ function fetchPreview(initializeSelectionMode = false) {
})
}
async function queryPreviewAmount() {
if (!previewQuery.value || !previewTotal.value || previewAmountLoading.value) return
const requestSeq = ++previewAmountRequestSeq
previewAmountVisible.value = false
previewAmountLoading.value = true
try {
const res = await getArBillPreviewAmount({ ...previewQuery.value })
if (requestSeq !== previewAmountRequestSeq) return
if (res.data == null || !Number.isFinite(Number(res.data))) {
proxy.$modal.msgError('应收合计查询失败,请重试')
return
}
previewTotalAmount.value = Number(res.data)
previewAmountVisible.value = true
} finally {
if (requestSeq === previewAmountRequestSeq) previewAmountLoading.value = false
}
}
function handlePreviewPageChange(page) {
previewPageNum.value = page
fetchPreview()