diff --git a/bms-back/bms-admin/src/main/java/com/bms/web/controller/receivable/ArBillController.java b/bms-back/bms-admin/src/main/java/com/bms/web/controller/receivable/ArBillController.java index fbb853d2..c66b2a04 100644 --- a/bms-back/bms-admin/src/main/java/com/bms/web/controller/receivable/ArBillController.java +++ b/bms-back/bms-admin/src/main/java/com/bms/web/controller/receivable/ArBillController.java @@ -3,6 +3,7 @@ package com.bms.web.controller.receivable; import java.util.ArrayList; import java.util.List; import java.util.Date; +import java.util.Map; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -30,13 +31,12 @@ import com.bms.receivable.domain.ArBillGenerateTask; import com.bms.receivable.domain.ArBillLog; import com.bms.receivable.domain.vo.ArBillActionRequest; import com.bms.receivable.domain.vo.ArBillGenerateQuery; -import com.bms.receivable.domain.vo.ArBillGeneratePreviewExportRow; import com.bms.receivable.domain.vo.ArBillGenerateRequest; import com.bms.receivable.domain.vo.ArBillListExportRow; import com.bms.receivable.domain.vo.ArBillQuery; -import com.bms.receivable.enums.ArBillDateType; import com.bms.receivable.service.IArBillService; import com.bms.receivable.security.ArBillScopeResolver; +import com.bms.receivable.task.ArBillGeneratePreviewExportRunner; import com.bms.common.annotation.Log; import com.bms.common.core.controller.BaseController; import com.bms.common.core.domain.AjaxResult; @@ -64,6 +64,9 @@ public class ArBillController extends BaseController { @Autowired private ArBillScopeResolver billScope; + @Autowired + private ArBillGeneratePreviewExportRunner previewExportRunner; + /** 应收筛选专用客户选项,与账单使用同一角色规则。 */ @PreAuthorize("@ss.hasPermi('ar:bill:list')") @GetMapping("/customer-options") @@ -174,26 +177,25 @@ public class ArBillController extends BaseController { return result; } - /** 按生成预览使用的相同筛选条件导出全部匹配订单,不受分页参数影响。 */ + /** 按生成预览使用的相同筛选条件提交异步导出任务。 */ @Log(title = "应收账单生成订单", businessType = BusinessType.EXPORT) @PreAuthorize("@ss.hasPermi('ar:bill:generate')") @PostMapping("/generate/preview/export") - public void exportGeneratePreview(HttpServletResponse response, ArBillGenerateQuery query, - HttpServletRequest request) { - rejectMultiValueParameter(request, "customerId"); - ArBillGenerateQuery exportQuery = query == null ? new ArBillGenerateQuery() : query; - List bills = billService.previewBill(exportQuery); - List rows = new ArrayList<>(bills.size()); - for (PrcOrderBill bill : bills) { - rows.add(ArBillGeneratePreviewExportRow.from(bill)); - } - ExcelUtil util = new ExcelUtil<>(ArBillGeneratePreviewExportRow.class); - if (ArBillDateType.PICKUP_DATE == ArBillDateType.of(exportQuery.getDateType())) { - util.hideColumn("bizDate"); - } else { - util.hideColumn("pickupDate"); - } - util.exportExcel(response, rows, "应收账单待生成订单"); + public AjaxResult exportGeneratePreview(@RequestBody ArBillGenerateQuery query) { + return success(Map.of("taskId", previewExportRunner.submit(query, getUserId()))); + } + + @PreAuthorize("@ss.hasPermi('ar:bill:generate')") + @GetMapping("/generate/preview/export/task/{taskId}") + public AjaxResult generatePreviewExportTask(@PathVariable String taskId) { + return success(previewExportRunner.getTask(taskId, getUserId())); + } + + @PreAuthorize("@ss.hasPermi('ar:bill:generate')") + @GetMapping("/generate/preview/export/download/{taskId}") + public void downloadGeneratePreviewExport(@PathVariable String taskId, HttpServletResponse response) + throws Exception { + previewExportRunner.download(taskId, getUserId(), response); } /** 查询需要从全选结果中排除的订单;空条件不执行查询,避免返回全部订单。 */ diff --git a/bms-back/bms-system/src/main/java/com/bms/receivable/domain/ArBillGeneratePreviewExportTask.java b/bms-back/bms-system/src/main/java/com/bms/receivable/domain/ArBillGeneratePreviewExportTask.java new file mode 100644 index 00000000..a1044ef7 --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/receivable/domain/ArBillGeneratePreviewExportTask.java @@ -0,0 +1,20 @@ +package com.bms.receivable.domain; + +import java.io.Serializable; +import lombok.Data; + +/** 应收账单生成预览导出任务的临时状态。 */ +@Data +public class ArBillGeneratePreviewExportTask implements Serializable { + private static final long serialVersionUID = 1L; + + private String taskId; + private Long userId; + private String status; + private int percent; + private String message; + private String error; + private int rowCount; + private String fileName; + private String storageKey; +} diff --git a/bms-back/bms-system/src/main/java/com/bms/receivable/security/ArBillScopeResolver.java b/bms-back/bms-system/src/main/java/com/bms/receivable/security/ArBillScopeResolver.java index 1e00f09d..7f127021 100644 --- a/bms-back/bms-system/src/main/java/com/bms/receivable/security/ArBillScopeResolver.java +++ b/bms-back/bms-system/src/main/java/com/bms/receivable/security/ArBillScopeResolver.java @@ -69,8 +69,12 @@ public class ArBillScopeResolver { } public BmsCustomerOption requireCustomerAccessible(Long customerId) { - ArBillRowScope scope = resolve(); - BmsCustomerOption customer = customerId == null ? null + return requireCustomerAccessible(customerId, resolve()); + } + + /** 异步任务使用提交时由服务端解析的权限范围。 */ + public BmsCustomerOption requireCustomerAccessible(Long customerId, ArBillRowScope scope) { + BmsCustomerOption customer = customerId == null || scope == null ? null : withoutPagination(() -> mapper.selectAccessibleCustomer(customerId, scope)); if (customer == null) throw new ServiceException("客户不存在或无权访问"); return customer; diff --git a/bms-back/bms-system/src/main/java/com/bms/receivable/service/IArBillService.java b/bms-back/bms-system/src/main/java/com/bms/receivable/service/IArBillService.java index c59eefed..acb25574 100644 --- a/bms-back/bms-system/src/main/java/com/bms/receivable/service/IArBillService.java +++ b/bms-back/bms-system/src/main/java/com/bms/receivable/service/IArBillService.java @@ -15,6 +15,7 @@ import com.bms.receivable.domain.vo.ArBillGenerateQuery; import com.bms.receivable.domain.vo.ArBillActionRequest; import com.bms.receivable.domain.vo.ArBillQuery; import com.bms.receivable.domain.vo.ArBillSummary; +import com.bms.receivable.security.ArBillRowScope; import com.bms.price.domain.PrcOrderBill; /** @@ -40,6 +41,8 @@ public interface IArBillService List previewBill(ArBillGenerateQuery query); + List previewBill(ArBillGenerateQuery query, ArBillRowScope scope); + List previewExcludedBill(ArBillGenerateQuery query); BigDecimal sumPreviewBill(ArBillGenerateQuery query); diff --git a/bms-back/bms-system/src/main/java/com/bms/receivable/service/impl/ArBillServiceImpl.java b/bms-back/bms-system/src/main/java/com/bms/receivable/service/impl/ArBillServiceImpl.java index 78cf656d..7795d202 100644 --- a/bms-back/bms-system/src/main/java/com/bms/receivable/service/impl/ArBillServiceImpl.java +++ b/bms-back/bms-system/src/main/java/com/bms/receivable/service/impl/ArBillServiceImpl.java @@ -73,6 +73,7 @@ import com.bms.receivable.mapper.ArBillExportMapper; import com.bms.receivable.mapper.ArBillLogMapper; import com.bms.receivable.mapper.ArBillMapper; import com.bms.receivable.security.ArBillScopeResolver; +import com.bms.receivable.security.ArBillRowScope; import com.bms.customer.customerinfo.domain.BmsCustomerOption; import com.bms.customer.customerinfo.domain.BmsCustomer; import com.bms.customer.customerinfo.mapper.BmsCustomerMapper; @@ -333,9 +334,14 @@ public class ArBillServiceImpl implements IArBillService { */ @Override public List previewBill(ArBillGenerateQuery query) { + return previewBill(query, billScope.resolve()); + } + + @Override + public List previewBill(ArBillGenerateQuery query, ArBillRowScope scope) { long startedAt = System.nanoTime(); validatePreviewQuery(query); - BmsCustomerOption customer = billScope.requireCustomerAccessible(query.getCustomerId()); + BmsCustomerOption customer = billScope.requireCustomerAccessible(query.getCustomerId(), scope); query.setCustomerCode(customer.getCustomerCode()); List orderIdentifiers = normalizeOrderIdentifiers(query.getOrderOrTrackingNo()); List excludeOrderIdentifiers = normalizeOrderIdentifiers(query.getExcludeOrderOrTrackingNo()); diff --git a/bms-back/bms-system/src/main/java/com/bms/receivable/task/ArBillGeneratePreviewExportRunner.java b/bms-back/bms-system/src/main/java/com/bms/receivable/task/ArBillGeneratePreviewExportRunner.java new file mode 100644 index 00000000..39c8a12c --- /dev/null +++ b/bms-back/bms-system/src/main/java/com/bms/receivable/task/ArBillGeneratePreviewExportRunner.java @@ -0,0 +1,174 @@ +package com.bms.receivable.task; + +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.core.task.TaskRejectedException; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import org.springframework.stereotype.Component; +import com.bms.common.config.BmsConfig; +import com.bms.common.core.redis.RedisCache; +import com.bms.common.exception.ServiceException; +import com.bms.common.utils.DateUtils; +import com.bms.common.utils.file.FileUtils; +import com.bms.common.utils.poi.ExcelUtil; +import com.bms.common.utils.uuid.IdUtils; +import com.bms.price.domain.PrcOrderBill; +import com.bms.receivable.domain.ArBillGeneratePreviewExportTask; +import com.bms.receivable.domain.vo.ArBillGeneratePreviewExportRow; +import com.bms.receivable.domain.vo.ArBillGenerateQuery; +import com.bms.receivable.enums.ArBillDateType; +import com.bms.receivable.security.ArBillRowScope; +import com.bms.receivable.security.ArBillScopeResolver; +import com.bms.receivable.service.IArBillService; + +/** 生成账单预览的异步导出;使用发起人的权限查询订单。 */ +@Component +public class ArBillGeneratePreviewExportRunner { + private static final Logger log = LoggerFactory.getLogger(ArBillGeneratePreviewExportRunner.class); + private static final String CACHE_PREFIX = "ar:bill:generate:preview:export:"; + private static final String REL_DIR = "arBillGeneratePreviewExport"; + private static final int TTL_MINUTES = 60; + private static final long FILE_RETENTION_MILLIS = TimeUnit.HOURS.toMillis(24); + + private final IArBillService billService; + private final RedisCache redisCache; + private final ThreadPoolTaskExecutor executor; + private final ArBillScopeResolver billScope; + + public ArBillGeneratePreviewExportRunner(IArBillService billService, RedisCache redisCache, ArBillScopeResolver billScope, + @Qualifier("arBillExportTaskExecutor") ThreadPoolTaskExecutor executor) { + this.billService = billService; + this.redisCache = redisCache; + this.billScope = billScope; + this.executor = executor; + } + + public String submit(ArBillGenerateQuery query, Long userId) { + String taskId = IdUtils.fastSimpleUUID(); + String fileName = "应收账单待生成订单_" + DateUtils.dateTimeNow() + ".xlsx"; + ArBillRowScope scope = billScope.resolve(); + put(taskId, userId, "PENDING", 0, "排队中", null, 0, fileName, null); + try { + executor.execute(() -> run(taskId, userId, query, fileName, scope)); + } catch (TaskRejectedException e) { + put(taskId, userId, "FAIL", 100, "导出失败", "导出任务较多,请稍后重试", 0, fileName, null); + } + return taskId; + } + + private void run(String taskId, Long userId, ArBillGenerateQuery query, String fileName, + ArBillRowScope scope) { + Path file = null; + try { + put(taskId, userId, "RUNNING", 5, "正在查询订单", null, 0, fileName, null); + List bills = billService.previewBill(query, scope); + put(taskId, userId, "RUNNING", 40, "正在生成 Excel", null, bills.size(), fileName, null); + List rows = new ArrayList<>(bills.size()); + for (PrcOrderBill bill : bills) { + rows.add(ArBillGeneratePreviewExportRow.from(bill)); + } + ExcelUtil util = new ExcelUtil<>(ArBillGeneratePreviewExportRow.class); + if (ArBillDateType.PICKUP_DATE == ArBillDateType.of(query.getDateType())) { + util.hideColumn("bizDate"); + } else { + util.hideColumn("pickupDate"); + } + Path dir = Paths.get(BmsConfig.getDownloadPath(), REL_DIR).toAbsolutePath().normalize(); + Files.createDirectories(dir); + file = dir.resolve(taskId + ".xlsx").normalize(); + if (!file.startsWith(dir)) { + throw new IllegalStateException("invalid export path"); + } + try (OutputStream out = Files.newOutputStream(file)) { + util.exportExcelToStream(rows, "应收账单待生成订单", out); + } + put(taskId, userId, "SUCCESS", 100, "导出完成,正在下载", null, bills.size(), fileName, + REL_DIR + "/" + taskId + ".xlsx"); + } catch (Exception e) { + log.error("应收账单生成预览导出失败 taskId={}", taskId, e); + if (file != null) { + try { Files.deleteIfExists(file); } catch (Exception cleanupError) { + log.warn("清理导出文件失败 taskId={}", taskId, cleanupError); + } + } + String error = e instanceof ServiceException ? e.getMessage() : "导出失败,请稍后重试"; + put(taskId, userId, "FAIL", 100, "导出失败", error, 0, fileName, null); + } + } + + public ArBillGeneratePreviewExportTask getTask(String taskId, Long userId) { + ArBillGeneratePreviewExportTask task = redisCache.getCacheObject(CACHE_PREFIX + taskId); + if (task == null || !userId.equals(task.getUserId())) { + throw new ServiceException("导出任务不存在或已过期"); + } + return task; + } + + public void download(String taskId, Long userId, HttpServletResponse response) throws Exception { + ArBillGeneratePreviewExportTask task = getTask(taskId, userId); + String storageKey = task.getStorageKey(); + if (!"SUCCESS".equals(task.getStatus()) || storageKey == null || !FileUtils.checkAllowDownload(storageKey)) { + throw new ServiceException("导出文件尚未生成"); + } + Path root = Paths.get(BmsConfig.getDownloadPath()).toAbsolutePath().normalize(); + Path file = root.resolve(storageKey).normalize(); + if (!file.startsWith(root) || !Files.isRegularFile(file)) { + throw new ServiceException("导出文件不存在"); + } + response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + FileUtils.setAttachmentResponseHeader(response, task.getFileName()); + FileUtils.writeBytes(file.toString(), response.getOutputStream()); + } + + /** 清理未下载或因客户端断开而遗留的过期文件。 */ + @Scheduled(cron = "0 15 * * * ?") + public void cleanupExpiredFiles() { + Path dir = Paths.get(BmsConfig.getDownloadPath(), REL_DIR).toAbsolutePath().normalize(); + if (!Files.isDirectory(dir)) { + return; + } + long cutoff = System.currentTimeMillis() - FILE_RETENTION_MILLIS; + try (Stream files = Files.list(dir)) { + files.filter(file -> file.getFileName().toString().matches("[a-f0-9]{32}\\.xlsx")) + .filter(file -> Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) + .forEach(file -> { + try { + if (Files.getLastModifiedTime(file, LinkOption.NOFOLLOW_LINKS).toMillis() < cutoff) { + Files.deleteIfExists(file); + } + } catch (Exception e) { + log.warn("清理过期导出文件失败: {}", file, e); + } + }); + } catch (Exception e) { + log.warn("扫描过期导出文件失败: {}", dir, e); + } + } + + private void put(String taskId, Long userId, String status, int percent, String message, String error, + int rowCount, String fileName, String storageKey) { + ArBillGeneratePreviewExportTask task = new ArBillGeneratePreviewExportTask(); + task.setTaskId(taskId); + task.setUserId(userId); + task.setStatus(status); + task.setPercent(percent); + task.setMessage(message); + task.setError(error); + task.setRowCount(rowCount); + task.setFileName(fileName); + task.setStorageKey(storageKey); + redisCache.setCacheObject(CACHE_PREFIX + taskId, task, TTL_MINUTES, TimeUnit.MINUTES); + } +} diff --git a/bms-front/src/views/ar/bill/components/GenerateBillDialog.vue b/bms-front/src/views/ar/bill/components/GenerateBillDialog.vue index 90e65078..a0009cbd 100644 --- a/bms-front/src/views/ar/bill/components/GenerateBillDialog.vue +++ b/bms-front/src/views/ar/bill/components/GenerateBillDialog.vue @@ -1,5 +1,20 @@ + + +
{{ previewExportMessage }}
+
导出行数 {{ previewExportRowCount }}
+

导出在后台进行,可收起弹窗继续操作;请勿关闭或刷新本页

+ +
+
@@ -150,6 +176,7 @@ import { listArBillGenerateCustomerOptions, getDefaultArBankAccount, previewArBill, 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' const props = defineProps({ modelValue: { @@ -168,6 +195,29 @@ const billNameOpen = ref(false) const generateLoading = ref(false) const previewLoading = ref(false) const previewExportLoading = ref(false) +const previewExportFinished = ref(false) +const previewExportProgressVisible = ref(false) +const previewExportPercent = ref(0) +const previewExportStatus = ref(undefined) +const previewExportMessage = ref('') +const previewExportRowCount = ref(0) +let previewExportHideTimer = null +const previewExportFloatVisible = computed(() => previewExportLoading.value || previewExportFinished.value) +const previewExportDegree = computed(() => Math.round(previewExportPercent.value / 100 * 360)) +const previewExportFloatTitle = computed(() => { + if (previewExportLoading.value) return '导出中' + return previewExportStatus.value === 'exception' ? '导出失败' : '导出完成' +}) +const previewExportFloatSummary = computed(() => previewExportRowCount.value > 0 + ? `${previewExportPercent.value}% / ${previewExportRowCount.value} 行` + : `${previewExportPercent.value}%`) +const previewExportDialogTitle = computed(() => { + if (previewExportLoading.value) return '异步导出进行中' + return previewExportStatus.value === 'exception' ? '异步导出失败' : '异步导出完成' +}) +onBeforeUnmount(() => { + clearTimeout(previewExportHideTimer) +}) const previewSearched = ref(false) const previewRows = ref([]) const previewTotal = ref(0) @@ -352,13 +402,50 @@ function handlePreviewPageChange(page) { fetchPreview() } -function exportPreview() { +async function exportPreview() { if (!previewSearched.value || !previewTotal.value || !previewQuery.value || previewExportLoading.value) return + clearTimeout(previewExportHideTimer) previewExportLoading.value = true - proxy.download('/ar/bill/generate/preview/export', { ...previewQuery.value }, `ar_bill_generate_preview_${Date.now()}.xlsx`) - .finally(() => { - previewExportLoading.value = false + previewExportFinished.value = false + previewExportProgressVisible.value = false + previewExportPercent.value = 0 + previewExportStatus.value = undefined + previewExportMessage.value = '正在提交导出任务' + previewExportRowCount.value = 0 + try { + const result = await exportAsync({ + baseUrl: '/ar/bill/generate/preview/export', + query: { ...previewQuery.value }, + filePrefix: 'ar_bill_generate_preview', + showLoading: false, + onProgress: task => { + previewExportPercent.value = Math.max(0, Math.min(100, Number(task.percent) || 0)) + previewExportRowCount.value = Number(task.rowCount) || 0 + previewExportMessage.value = task.error || task.message || '导出中' + if (task.status === 'FAIL' || task.status === 'FAILED') previewExportStatus.value = 'exception' + } }) + if (result) { + previewExportPercent.value = 100 + previewExportStatus.value = 'success' + previewExportMessage.value = '导出完成' + } else { + if (previewExportStatus.value !== 'exception') { + previewExportMessage.value = '导出失败,请查看提示后重试' + } + previewExportStatus.value = 'exception' + } + } catch (error) { + previewExportStatus.value = 'exception' + previewExportMessage.value = error?.message || '导出失败,请稍后重试' + } finally { + previewExportLoading.value = false + previewExportFinished.value = true + previewExportHideTimer = window.setTimeout(() => { + previewExportFinished.value = false + previewExportHideTimer = null + }, 1000) + } } // 当前页变化只更新当前页 ID,selectedRowMap 因此可以跨页保留手动选择。 @@ -562,6 +649,17 @@ 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-export-status-float { position: fixed; top: 92px; right: 24px; z-index: 3001; } +.preview-export-status-button { display: inline-flex; align-items: center; gap: 10px; min-width: 200px; min-height: 68px; padding: 6px 16px 6px 7px; border-radius: 36px; } +.preview-export-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-warning) 0deg var(--export-progress, 0deg), var(--el-color-warning-light-8) var(--export-progress, 0deg) 360deg); } +.preview-export-progress-ring::after { position: absolute; inset: 5px; content: ''; border-radius: 50%; background: var(--el-bg-color); } +.preview-export-progress-value { position: relative; z-index: 1; color: var(--el-color-warning); font-size: 12px; font-weight: 600; line-height: 1; } +.preview-export-status-text { display: flex; flex-direction: column; align-items: flex-start; gap: 3px; line-height: 1.2; white-space: nowrap; } +.preview-export-status-text strong { font-size: 14px; font-weight: 600; } +.preview-export-status-text small { color: var(--el-text-color-secondary); font-size: 11px; } +.preview-export-progress__msg { margin-top: 12px; color: var(--el-text-color-regular); } +.preview-export-progress__text { margin-top: 8px; color: var(--el-text-color-secondary); font-size: 13px; } +.preview-export-progress__hint { margin-top: 12px; color: var(--el-color-warning); font-size: 12px; } .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; }