负责账单列表、账单生成、确认/作废、异步导出、日志和详情分页接口; + * 具体业务规则统一下沉到 {@code IArBillService}。
+ */ @RestController @RequestMapping("/ar/bill") @Validated -public class ArBillController extends BaseController -{ +public class ArBillController extends BaseController { @Autowired private IArBillService billService; + /** + * 查询账单列表;分页参数由 BaseController.startPage 交给 MyBatis PageHelper 处理。 + */ @PreAuthorize("@ss.hasPermi('ar:bill:list')") @GetMapping("/list") - public TableDataInfo list(ArBillQuery query) - { + public TableDataInfo list(ArBillQuery query) { startPage(); List由事务提交后监听器处理,确保异步线程读取到已落库的导出任务。
+ */ +public class ArBillExportTaskEvent +{ + private final Long exportId; + + public ArBillExportTaskEvent(Long exportId) + { + this.exportId = exportId; + } + + public Long getExportId() + { + return exportId; + } +} diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/event/ArBillGenerateTaskEvent.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/event/ArBillGenerateTaskEvent.java new file mode 100644 index 00000000..0ca6f70c --- /dev/null +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/event/ArBillGenerateTaskEvent.java @@ -0,0 +1,21 @@ +package com.ruoyi.ar.event; + +/** + * 应收账单异步生成任务已提交事件。 + * 监听器在创建账单事务提交后消费,避免后台线程读取到未提交的任务或账单头。 + */ +public class ArBillGenerateTaskEvent +{ + /** 待投递的生成任务主键。 */ + private final Long taskId; + + public ArBillGenerateTaskEvent(Long taskId) + { + this.taskId = taskId; + } + + public Long getTaskId() + { + return taskId; + } +} diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/mapper/ArBankAccountMapper.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/mapper/ArBankAccountMapper.java index 1174f854..8a221f13 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/mapper/ArBankAccountMapper.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/mapper/ArBankAccountMapper.java @@ -3,7 +3,6 @@ package com.ruoyi.ar.mapper; import org.apache.ibatis.annotations.Param; import com.ruoyi.ar.domain.ArBankAccount; -public interface ArBankAccountMapper -{ +public interface ArBankAccountMapper { ArBankAccount selectDefaultByCurrency(@Param("currency") String currency); } diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/mapper/ArBillDetailFeeMapper.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/mapper/ArBillDetailFeeMapper.java index dc5c2431..f9c36cb5 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/mapper/ArBillDetailFeeMapper.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/mapper/ArBillDetailFeeMapper.java @@ -1,15 +1,23 @@ package com.ruoyi.ar.mapper; import java.util.List; + +import org.apache.ibatis.annotations.Param; import com.ruoyi.ar.domain.ArBillDetailFee; -public interface ArBillDetailFeeMapper -{ +public interface ArBillDetailFeeMapper { List这里同时承载账单生命周期、余额核销前置校验、异步生成任务和异步导出任务。 + * 大数据量场景使用数据库游标式分页和 OOXML 流式拼接,避免一次性加载全部订单。
+ */ @Service -public class ArBillServiceImpl implements IArBillService -{ +public class ArBillServiceImpl implements IArBillService { + private static final Logger log = LoggerFactory.getLogger(ArBillServiceImpl.class); + private static final String EXPORT_TEMPLATE_BUSINESS_TYPE = "AR_BILL"; private static final String EXPORT_TEMPLATE_RESOURCE = "templates/ar/ar_bill_template.xlsx"; - private static final int TEMPLATE_DATA_START_ROW = 4; + /** + * Excel 行号从 1 开始;模板第 4 行是表头,明细从第 5 行开始写入。 + */ + private static final int TEMPLATE_DATA_START_ROW = 5; private static final int TEMPLATE_FOOTER_START_ROW = 5; private static final int TEMPLATE_COLUMN_COUNT = 20; + /** + * 单次查询订单数;费用仅查询当前订单批次,避免大账单占满堆内存。 + */ + private static final int EXPORT_PAGE_SIZE = 2000; + /** + * 异步生成账单单批订单数,2核4G环境下控制内存和事务时长。 + */ + private static final int GENERATE_PAGE_SIZE = 1000; + /** + * 手动模式避免将超大订单 ID 集合放入请求和 SQL IN,超大范围应使用全选模式。 + */ + private static final int EXPLICIT_SOURCE_ID_LIMIT = 10000; + private static final int EXCLUDED_SOURCE_ID_LIMIT = 10000; + /** + * 模板明细行使用的边框、字体和换行样式;样式定义来自 ar_bill_template.xlsx。 + */ + private static final int TEMPLATE_DETAIL_STYLE_INDEX = 31; + private static final String TEMPLATE_SHEET_XML = "xl/worksheets/sheet1.xml"; + private static final Pattern TEMPLATE_ROW_PATTERN = Pattern.compile( + "事务提交后立即尝试投递任务;定时补偿负责恢复节点异常、清理超时取消任务, + * 并重新投递仍处于 WAITING 状态的任务。
+ */ +@Component +public class ArBillGenerateTaskRunner { + private static final Logger log = LoggerFactory.getLogger(ArBillGenerateTaskRunner.class); + /** + * 生成任务超过 15 分钟没有心跳,认为执行节点异常并恢复为待处理。 + */ + private static final long PROCESSING_TIMEOUT_MILLIS = 15L * 60L * 1000L; + /** + * 取消请求超过 5 分钟没有完成,交由补偿逻辑清理账单和明细。 + */ + private static final long CANCELLATION_TIMEOUT_MILLIS = 5L * 60L * 1000L; + + @Autowired + private IArBillService billService; + + @Autowired + @Qualifier("arBillGenerateTaskExecutor") + private ThreadPoolTaskExecutor generateTaskExecutor; + + /** + * 账单生成事务提交后触发,避免事务回滚时提前执行异步任务。 + */ + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void run(ArBillGenerateTaskEvent event) { + submit(event.getTaskId()); + } + + /** + * 每 30 秒执行一次任务恢复、取消清理和待处理任务投递。 + */ + @Scheduled(cron = "0/30 * * * * ?") + public void recoverAndSubmitTasks() { + try { + Date expiredBefore = new Date(System.currentTimeMillis() - PROCESSING_TIMEOUT_MILLIS); + int recovered = billService.recoverExpiredGenerateTasks(expiredBefore); + if (recovered > 0) { + log.warn("已恢复 {} 个超时的应收账单生成任务", recovered); + } + + Date cancellationExpiredBefore = new Date(System.currentTimeMillis() - CANCELLATION_TIMEOUT_MILLIS); + int canceled = billService.recoverCancelRequestedGenerateTasks(cancellationExpiredBefore); + if (canceled > 0) { + log.warn("已自动清理 {} 个超时的应收账单取消任务", canceled); + } + + for (ArBillGenerateTask task : billService.selectWaitingGenerateTasks(5)) { + submit(task.getTaskId()); + } + } catch (Exception e) { + log.error("应收账单生成任务调度失败", e); + } + } + + /** + * 先用数据库条件更新抢占任务,再提交到有界线程池,避免重复执行。 + */ + private void submit(Long taskId) { + try { + if (!billService.claimGenerateTask(taskId)) { + return; + } + generateTaskExecutor.execute(() -> billService.processGenerateTask(taskId)); + } catch (TaskRejectedException e) { + log.error("应收账单生成任务提交线程池失败,taskId={}", taskId, e); + try { + billService.failGenerateTask(taskId, "生成任务较多,请稍后重新发起"); + } catch (Exception cleanupException) { + log.error("线程池拒绝后,清理应收账单生成任务失败,taskId={}", taskId, cleanupException); + } + } catch (Exception e) { + log.error("应收账单生成任务提交失败,taskId={}", taskId, e); + try { + billService.failGenerateTask(taskId, "生成任务提交失败,请稍后重新发起"); + } catch (Exception cleanupException) { + log.error("任务提交异常后,清理应收账单生成任务失败,taskId={}", taskId, cleanupException); + } + } + } +} diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/mapper/PrcOrderBillMapper.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/mapper/PrcOrderBillMapper.java index 2dbf8e13..c37804b5 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/mapper/PrcOrderBillMapper.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/price/mapper/PrcOrderBillMapper.java @@ -20,12 +20,36 @@ public interface PrcOrderBillMapper /** 应收账单生成读取指定客户截止日期前的未出账订单计费快照。 */ public List