From 85b37bc07e2060fc2ca6de313f70e63b2d90733c Mon Sep 17 00:00:00 2001 From: caojia <1301869380@qq.com> Date: Sun, 20 Sep 2026 16:42:11 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B4=A6=E5=8D=95=E5=BC=82=E6=AD=A5=E7=94=9F?= =?UTF-8?q?=E6=88=90=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bms-back/pom.xml | 11 +- .../web/controller/ar/ArBillController.java | 171 +- .../src/main/resources/application-druid.yml | 9 +- bms-back/ruoyi-system/pom.xml | 4 +- .../ar/config/ArBillExportTaskConfig.java | 44 + .../com/ruoyi/ar/domain/ArBankAccount.java | 8 +- .../main/java/com/ruoyi/ar/domain/ArBill.java | 48 +- .../com/ruoyi/ar/domain/ArBillDetail.java | 8 +- .../com/ruoyi/ar/domain/ArBillDetailFee.java | 8 +- .../com/ruoyi/ar/domain/ArBillExport.java | 31 +- .../ruoyi/ar/domain/ArBillGenerateTask.java | 89 + .../java/com/ruoyi/ar/domain/ArBillLog.java | 28 +- .../com/ruoyi/ar/domain/ArExportTemplate.java | 28 + .../ar/domain/vo/ArBillGenerateQuery.java | 6 + .../ar/domain/vo/ArBillGenerateRequest.java | 11 +- .../ar/enums/ArBillGenerateTaskStatus.java | 22 + .../ar/enums/ArBillGenerationStatus.java | 16 + .../ruoyi/ar/enums/ArBillSelectionMode.java | 49 + .../java/com/ruoyi/ar/enums/ArBillStatus.java | 3 + .../ruoyi/ar/event/ArBillExportTaskEvent.java | 21 + .../ar/event/ArBillGenerateTaskEvent.java | 21 + .../ruoyi/ar/mapper/ArBankAccountMapper.java | 3 +- .../ar/mapper/ArBillDetailFeeMapper.java | 12 +- .../ruoyi/ar/mapper/ArBillDetailMapper.java | 17 +- .../ruoyi/ar/mapper/ArBillExportMapper.java | 14 +- .../ar/mapper/ArBillGenerateTaskMapper.java | 44 + .../com/ruoyi/ar/mapper/ArBillMapper.java | 34 +- .../ar/mapper/ArExportTemplateMapper.java | 14 + .../ruoyi/ar/service/IArBillLogService.java | 6 +- .../com/ruoyi/ar/service/IArBillService.java | 46 +- .../impl/ArBalanceWriteoffServiceImpl.java | 110 +- .../ar/service/impl/ArBillLogServiceImpl.java | 17 +- .../ar/service/impl/ArBillServiceImpl.java | 1727 ++++++++++++----- .../ruoyi/ar/task/ArBillExportTaskRunner.java | 62 + .../ar/task/ArBillGenerateTaskRunner.java | 103 + .../price/mapper/PrcOrderBillMapper.java | 33 +- .../mapper/ar/ArBankAccountMapper.xml | 21 +- .../mapper/ar/ArBillDetailFeeMapper.xml | 118 +- .../mapper/ar/ArBillDetailMapper.xml | 52 +- .../mapper/ar/ArBillExportMapper.xml | 59 +- .../mapper/ar/ArBillGenerateTaskMapper.xml | 175 ++ .../resources/mapper/ar/ArBillLogMapper.xml | 71 +- .../main/resources/mapper/ar/ArBillMapper.xml | 136 +- .../mapper/ar/ArExportTemplateMapper.xml | 45 + .../mapper/price/PrcOrderBillMapper.xml | 103 +- bms-back/sql/ar/ar_bill.sql | 211 ++ bms-front/src/api/ar/bill.js | 31 +- bms-front/src/constants/ar/enums.js | 6 + .../views/ar/bill/components/BillDetail.vue | 94 +- .../ar/bill/components/GenerateBillDialog.vue | 357 +++- bms-front/src/views/ar/bill/index.vue | 379 +++- bms-front/src/views/ar/bill/log.vue | 2 + 52 files changed, 3948 insertions(+), 790 deletions(-) create mode 100644 bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/config/ArBillExportTaskConfig.java create mode 100644 bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/ArBillGenerateTask.java create mode 100644 bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/ArExportTemplate.java create mode 100644 bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/enums/ArBillGenerateTaskStatus.java create mode 100644 bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/enums/ArBillGenerationStatus.java create mode 100644 bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/enums/ArBillSelectionMode.java create mode 100644 bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/event/ArBillExportTaskEvent.java create mode 100644 bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/event/ArBillGenerateTaskEvent.java create mode 100644 bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/mapper/ArBillGenerateTaskMapper.java create mode 100644 bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/mapper/ArExportTemplateMapper.java create mode 100644 bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/task/ArBillExportTaskRunner.java create mode 100644 bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/task/ArBillGenerateTaskRunner.java create mode 100644 bms-back/ruoyi-system/src/main/resources/mapper/ar/ArBillGenerateTaskMapper.xml create mode 100644 bms-back/ruoyi-system/src/main/resources/mapper/ar/ArExportTemplateMapper.xml diff --git a/bms-back/pom.xml b/bms-back/pom.xml index 1a1c13a8..c45f2c45 100644 --- a/bms-back/pom.xml +++ b/bms-back/pom.xml @@ -194,6 +194,15 @@ ${java.version} ${java.version} ${project.build.sourceEncoding} + + full + + + org.projectlombok + lombok + 1.18.46 + + @@ -229,4 +238,4 @@ - \ No newline at end of file + diff --git a/bms-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/ar/ArBillController.java b/bms-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/ar/ArBillController.java index 77ef332a..4d2a753c 100644 --- a/bms-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/ar/ArBillController.java +++ b/bms-back/ruoyi-admin/src/main/java/com/ruoyi/web/controller/ar/ArBillController.java @@ -1,6 +1,7 @@ package com.ruoyi.web.controller.ar; import java.util.List; + import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import org.springframework.beans.factory.annotation.Autowired; @@ -17,7 +18,9 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import com.ruoyi.ar.domain.ArBill; import com.ruoyi.ar.domain.ArBankAccount; +import com.ruoyi.ar.domain.ArBillDetailFee; import com.ruoyi.ar.domain.ArBillExport; +import com.ruoyi.ar.domain.ArBillGenerateTask; import com.ruoyi.ar.domain.ArBillLog; import com.ruoyi.ar.domain.vo.ArBillActionRequest; import com.ruoyi.ar.domain.vo.ArBillGenerateQuery; @@ -33,114 +36,218 @@ import com.ruoyi.common.utils.StringUtils; import com.ruoyi.common.utils.uuid.IdUtils; import com.ruoyi.price.domain.PrcOrderBill; -/** 应收账单管理。 */ +/** + * 应收账单 HTTP 接口层。 + * + *

负责账单列表、账单生成、确认/作废、异步导出、日志和详情分页接口; + * 具体业务规则统一下沉到 {@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 list = billService.selectArBillList(query); return getDataTable(list); } + /** + * 查询账单头信息;账单明细通过独立分页接口按需加载。 + */ @PreAuthorize("@ss.hasPermi('ar:bill:query')") @GetMapping("/{arBillId}") - public AjaxResult getInfo(@PathVariable Long arBillId) - { + public AjaxResult getInfo(@PathVariable Long arBillId) { return success(billService.selectArBillById(arBillId)); } - /** 查询指定币种的公司默认收款账户,账号返回脱敏值。 */ + /** + * 分页查询账单明细,避免详情页一次性加载全部订单。 + */ + @PreAuthorize("@ss.hasPermi('ar:bill:query')") + @GetMapping("/{arBillId}/details") + public TableDataInfo details(@PathVariable Long arBillId) { + startPage(); + return getDataTable(billService.selectArBillDetails(arBillId)); + } + + /** + * 查询单条账单明细的费用明细,按展开行懒加载。 + */ + @PreAuthorize("@ss.hasPermi('ar:bill:query')") + @GetMapping("/{arBillId}/details/{detailId}/fees") + public AjaxResult detailFees(@PathVariable Long arBillId, @PathVariable Long detailId) { + List fees = billService.selectArBillDetailFees(arBillId, detailId); + return success(fees); + } + + /** + * 查询指定币种的公司默认收款账户,账号返回脱敏值。 + */ @PreAuthorize("@ss.hasPermi('ar:bill:generate')") @GetMapping("/default-bank-account") - public AjaxResult defaultBankAccount(@RequestParam(required = false, defaultValue = "CNY") String currency) - { + public AjaxResult defaultBankAccount(@RequestParam(required = false, defaultValue = "CNY") String currency) { ArBankAccount account = billService.getDefaultBankAccount(currency); return success(account); } - /** 查询生成账单前的订单计费结果。 */ + /** + * 查询生成账单前的订单计费结果。 + */ @PreAuthorize("@ss.hasPermi('ar:bill:generate')") @GetMapping("/generate/preview") - public TableDataInfo preview(ArBillGenerateQuery query) - { + public AjaxResult preview(ArBillGenerateQuery query) { + startPage(); List list = billService.previewBill(query); - return getDataTable(list); + TableDataInfo pageData = getDataTable(list); + AjaxResult result = success(); + result.put("rows", pageData.getRows()); + result.put("total", pageData.getTotal()); + result.put("totalAmount", billService.sumPreviewBill(query)); + return result; } + /** + * 创建账单头和异步生成任务,接口不等待全部订单明细处理完成。 + */ @Log(title = "应收账单", businessType = BusinessType.INSERT) @PreAuthorize("@ss.hasPermi('ar:bill:generate')") @PostMapping("/generate") public AjaxResult generate(@Valid @RequestBody ArBillGenerateRequest request, - @RequestHeader(value = "X-Request-Id", required = false) String headerRequestId) - { + @RequestHeader(value = "X-Request-Id", required = false) String headerRequestId) { String requestId = requestId(request.getRequestId(), headerRequestId); - ArBill bill = billService.generateBill(request, getUsername(), requestId); - return success(bill).put("request_id", requestId); + ArBillGenerateTask task = billService.generateBill(request, getUsername(), requestId); + return success(task).put("request_id", requestId); } + /** + * 查询单个应收账单异步生成任务。 + */ + @PreAuthorize("@ss.hasPermi('ar:bill:query')") + @GetMapping("/generate/task/{taskId}") + public AjaxResult generateTaskInfo(@PathVariable Long taskId) { + return success(billService.selectGenerateTask(taskId)); + } + + /** + * 查询当前用户仍在处理中的应收账单生成任务。 + */ + @PreAuthorize("@ss.hasPermi('ar:bill:query')") + @GetMapping("/generate/tasks/active") + public AjaxResult activeGenerateTasks() { + return success(billService.selectActiveGenerateTasks(getUsername())); + } + + /** + * 请求取消应收账单异步生成任务。 + */ + @Log(title = "应收账单", businessType = BusinessType.UPDATE) + @PreAuthorize("@ss.hasPermi('ar:bill:generate')") + @PostMapping("/generate/task/{taskId}/cancel") + public AjaxResult cancelGenerateTask(@PathVariable Long taskId, + @RequestHeader(value = "X-Request-Id", required = false) String headerRequestId) { + String requestId = requestId(null, headerRequestId); + return success(billService.cancelGenerateTask(taskId, getUsername(), requestId)).put("request_id", requestId); + } + + /** + * 整单重新发起应收账单异步生成任务。 + */ + @Log(title = "应收账单", businessType = BusinessType.INSERT) + @PreAuthorize("@ss.hasPermi('ar:bill:generate')") + @PostMapping("/generate/task/{taskId}/retry") + public AjaxResult retryGenerateTask(@PathVariable Long taskId, + @RequestHeader(value = "X-Request-Id", required = false) String headerRequestId) { + String requestId = requestId(null, headerRequestId); + return success(billService.retryGenerateTask(taskId, getUsername(), requestId)).put("request_id", requestId); + } + + /** + * 确认已生成完成的账单,使其进入可核销状态。 + */ @Log(title = "应收账单", businessType = BusinessType.UPDATE) @PreAuthorize("@ss.hasPermi('ar:bill:confirm')") @PutMapping("/{arBillId}/confirm") public AjaxResult confirm(@PathVariable Long arBillId, @RequestBody(required = false) ArBillActionRequest request, - @RequestHeader(value = "X-Request-Id", required = false) String headerRequestId) - { + @RequestHeader(value = "X-Request-Id", required = false) String headerRequestId) { String requestId = requestId(request == null ? null : request.getRequestId(), headerRequestId); return success(billService.confirmBill(arBillId, request, getUsername(), requestId)).put("request_id", requestId); } + /** + * 作废尚未发生核销的账单;账单明细保留作为历史快照。 + */ @Log(title = "应收账单", businessType = BusinessType.UPDATE) @PreAuthorize("@ss.hasPermi('ar:bill:void')") @PutMapping("/{arBillId}/void") public AjaxResult voidBill(@PathVariable Long arBillId, @RequestBody ArBillActionRequest request, - @RequestHeader(value = "X-Request-Id", required = false) String headerRequestId) - { + @RequestHeader(value = "X-Request-Id", required = false) String headerRequestId) { String requestId = requestId(request == null ? null : request.getRequestId(), headerRequestId); return success(billService.voidBill(arBillId, request, getUsername(), requestId)).put("request_id", requestId); } + /** + * 仅提交导出任务;前端通过导出任务状态接口轮询并在完成后下载。 + */ @Log(title = "应收账单导出", businessType = BusinessType.EXPORT) @PreAuthorize("@ss.hasPermi('ar:bill:export')") @PostMapping("/{arBillId}/export") public AjaxResult export(@PathVariable Long arBillId, - @RequestHeader(value = "X-Request-Id", required = false) String headerRequestId) - { + @RequestHeader(value = "X-Request-Id", required = false) String headerRequestId) { String requestId = requestId(null, headerRequestId); return success(billService.exportBill(arBillId, getUsername(), requestId)).put("request_id", requestId); } + /** + * 查询当前账单的导出历史和任务状态。 + */ @PreAuthorize("@ss.hasPermi('ar:bill:query')") @GetMapping("/{arBillId}/exports") - public AjaxResult exports(@PathVariable Long arBillId) - { + public AjaxResult exports(@PathVariable Long arBillId) { return success(billService.selectExports(arBillId)); } + /** + * 查询单个账单导出任务状态,供前端轮询。 + */ + @PreAuthorize("@ss.hasPermi('ar:bill:query')") + @GetMapping("/export/{exportId}") + public AjaxResult exportInfo(@PathVariable Long exportId) { + return success(billService.selectExport(exportId)); + } + + /** + * 下载已成功生成的导出文件;文件内容由服务层从临时文件或配置存储读取。 + */ @PreAuthorize("@ss.hasPermi('ar:bill:export')") @GetMapping("/export/{exportId}/download") - public void download(@PathVariable Long exportId, HttpServletResponse response) throws Exception - { + public void download(@PathVariable Long exportId, HttpServletResponse response) throws Exception { billService.downloadExport(exportId, response); } + /** + * 分页查询账单操作日志。 + */ @PreAuthorize("@ss.hasPermi('ar:log:list')") @GetMapping("/log/list") - public TableDataInfo logs(ArBillLog query) - { + public TableDataInfo logs(ArBillLog query) { startPage(); return getDataTable(billService.selectLogList(query)); } - private String requestId(String bodyRequestId, String headerRequestId) - { + /** + * 解析接口幂等号:优先使用请求体,再使用请求头,最后生成新的 UUID。 + * 生成账单、确认、作废和导出都依赖该值避免重复提交。 + */ + private String requestId(String bodyRequestId, String headerRequestId) { if (StringUtils.isNotEmpty(bodyRequestId)) return bodyRequestId; if (StringUtils.isNotEmpty(headerRequestId)) return headerRequestId; return IdUtils.fastSimpleUUID(); diff --git a/bms-back/ruoyi-admin/src/main/resources/application-druid.yml b/bms-back/ruoyi-admin/src/main/resources/application-druid.yml index 601f33f5..b48acc38 100644 --- a/bms-back/ruoyi-admin/src/main/resources/application-druid.yml +++ b/bms-back/ruoyi-admin/src/main/resources/application-druid.yml @@ -29,8 +29,8 @@ spring: connectTimeout: 30000 # 配置网络超时时间 socketTimeout: 60000 - # 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒 - timeBetweenEvictionRunsMillis: 60000 + # 配置间隔多久检测一次空闲连接,避免远程数据库断开后连接池继续复用失效连接,单位是毫秒 + timeBetweenEvictionRunsMillis: 30000 # 配置一个连接在池中最小生存的时间,单位是毫秒 minEvictableIdleTimeMillis: 300000 # 配置一个连接在池中最大生存的时间,单位是毫秒 @@ -38,7 +38,8 @@ spring: # 配置检测连接是否有效 validationQuery: SELECT 1 FROM DUAL testWhileIdle: true - testOnBorrow: false + # 异步账单任务借用连接时校验连接有效性,避免拿到已被数据库断开的连接 + testOnBorrow: true testOnReturn: false webStatFilter: enabled: true @@ -59,4 +60,4 @@ spring: merge-sql: true wall: config: - multi-statement-allow: true \ No newline at end of file + multi-statement-allow: true diff --git a/bms-back/ruoyi-system/pom.xml b/bms-back/ruoyi-system/pom.xml index ed74f25d..485bc844 100644 --- a/bms-back/ruoyi-system/pom.xml +++ b/bms-back/ruoyi-system/pom.xml @@ -26,9 +26,11 @@ org.projectlombok lombok + + 1.18.46 true - \ No newline at end of file + diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/config/ArBillExportTaskConfig.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/config/ArBillExportTaskConfig.java new file mode 100644 index 00000000..a330b51e --- /dev/null +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/config/ArBillExportTaskConfig.java @@ -0,0 +1,44 @@ +package com.ruoyi.ar.config; + +import java.util.concurrent.ThreadPoolExecutor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +/** 应收账单大文件导出专用线程池。 */ +@Configuration +public class ArBillExportTaskConfig +{ + /** + * 账单导出会同时占用数据库连接、CPU 和磁盘,最多允许两个任务并发生成。 + * 队列满时由提交器将任务标记为失败,避免回退到 Web 请求线程执行。 + */ + @Bean(name = "arBillExportTaskExecutor") + public ThreadPoolTaskExecutor arBillExportTaskExecutor() + { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(2); + executor.setMaxPoolSize(2); + executor.setQueueCapacity(2); + executor.setKeepAliveSeconds(60); + executor.setThreadNamePrefix("ar-bill-export-"); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy()); + return executor; + } + + /** + * 账单生成会同时占用数据库连接、CPU 和磁盘,2核4G环境下先限制为单任务串行处理。 + */ + @Bean(name = "arBillGenerateTaskExecutor") + public ThreadPoolTaskExecutor arBillGenerateTaskExecutor() + { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(1); + executor.setMaxPoolSize(1); + executor.setQueueCapacity(5); + executor.setKeepAliveSeconds(60); + executor.setThreadNamePrefix("ar-bill-generate-"); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy()); + return executor; + } +} diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/ArBankAccount.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/ArBankAccount.java index de92e875..b369ae3a 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/ArBankAccount.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/ArBankAccount.java @@ -2,13 +2,15 @@ package com.ruoyi.ar.domain; import java.io.Serializable; import java.util.Date; + import com.fasterxml.jackson.annotation.JsonFormat; import lombok.Data; -/** 公司收款账户配置。 */ +/** + * 公司收款账户配置。 + */ @Data -public class ArBankAccount implements Serializable -{ +public class ArBankAccount implements Serializable { private static final long serialVersionUID = 1L; private Long bankAccountId; diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/ArBill.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/ArBill.java index c13dd750..0edda125 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/ArBill.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/ArBill.java @@ -3,41 +3,72 @@ package com.ruoyi.ar.domain; import java.math.BigDecimal; import java.util.Date; import java.util.List; + import com.fasterxml.jackson.annotation.JsonFormat; import org.springframework.format.annotation.DateTimeFormat; import com.ruoyi.common.core.domain.BaseEntity; import lombok.Data; import lombok.EqualsAndHashCode; -/** 正式应收账单。 */ +/** + * 正式应收账单。 + */ @Data @EqualsAndHashCode(callSuper = true) -public class ArBill extends BaseEntity -{ +public class ArBill extends BaseEntity { private static final long serialVersionUID = 1L; + /** + * 账单头主键。 + */ private Long arBillId; + /** + * 对外展示的账单编号。 + */ private String billNo; + /** + * 财务填写的账单名称。 + */ private String billName; + /** + * 账单所属客户主键。 + */ private Long customerId; private String customerCode; private String customerName; @DateTimeFormat(pattern = "yyyy-MM-dd") @JsonFormat(pattern = "yyyy-MM-dd") + /** 账单所属期开始日期,导出模板展示用。 */ private Date billPeriodStart; @DateTimeFormat(pattern = "yyyy-MM-dd") @JsonFormat(pattern = "yyyy-MM-dd") + /** 账单所属期结束日期,同时作为生成订单的截止日期。 */ private Date billPeriodEnd; @DateTimeFormat(pattern = "yyyy-MM-dd") @JsonFormat(pattern = "yyyy-MM-dd") private Date billDate; private String currency; private Integer orderCount; + /** + * 账单明细应收金额合计。 + */ private BigDecimal totalAmount; + /** + * 已通过余额核销的金额。 + */ private BigDecimal writtenOffAmount; + /** + * 尚未核销的金额,核销入口按此字段判断是否可操作。 + */ private BigDecimal unwrittenAmount; - /** 状态:DRAFT草稿、CONFIRMED已确认、PARTIAL_WRITTEN_OFF部分核销、WRITTEN_OFF已核销、VOID已作废。 */ + /** + * 状态:DRAFT草稿、CONFIRMED已确认、PARTIAL_WRITTEN_OFF部分核销、WRITTEN_OFF已核销、VOID已作废。 + */ private String status; + /** + * 生成状态:GENERATING生成中、READY可用、FAILED生成失败。 + */ + private String generationStatus; private Integer billVersion; private String bankAccountName; private String bankAccountNo; @@ -47,7 +78,16 @@ public class ArBill extends BaseEntity @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private Date confirmedTime; + /** + * 账单明细;详情接口分页查询时通常不直接填充该集合。 + */ private List details; + /** + * 当前账单的导出历史。 + */ private List exports; + /** + * 当前账单的操作日志。 + */ private List logs; } diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/ArBillDetail.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/ArBillDetail.java index 0d8b6742..94cb66d9 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/ArBillDetail.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/ArBillDetail.java @@ -4,13 +4,15 @@ import java.io.Serializable; import java.math.BigDecimal; import java.util.Date; import java.util.List; + import com.fasterxml.jackson.annotation.JsonFormat; import lombok.Data; -/** 账单订单快照。 */ +/** + * 账单订单快照。 + */ @Data -public class ArBillDetail implements Serializable -{ +public class ArBillDetail implements Serializable { private static final long serialVersionUID = 1L; private Long detailId; diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/ArBillDetailFee.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/ArBillDetailFee.java index fb656d88..d8521da0 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/ArBillDetailFee.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/ArBillDetailFee.java @@ -3,13 +3,15 @@ package com.ruoyi.ar.domain; import java.io.Serializable; import java.math.BigDecimal; import java.util.Date; + import com.fasterxml.jackson.annotation.JsonFormat; import lombok.Data; -/** 账单费用快照。 */ +/** + * 账单费用快照。 + */ @Data -public class ArBillDetailFee implements Serializable -{ +public class ArBillDetailFee implements Serializable { private static final long serialVersionUID = 1L; private Long detailFeeId; diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/ArBillExport.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/ArBillExport.java index 964d44b5..0908ec36 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/ArBillExport.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/ArBillExport.java @@ -2,26 +2,49 @@ package com.ruoyi.ar.domain; import java.io.Serializable; import java.util.Date; + import com.fasterxml.jackson.annotation.JsonFormat; import lombok.Data; -/** 账单导出文件元数据。 */ +/** + * 账单导出文件元数据。 + */ @Data -public class ArBillExport implements Serializable -{ +public class ArBillExport implements Serializable { private static final long serialVersionUID = 1L; private Long exportId; private Long arBillId; private String fileName; private String fileType; + /** + * 提交导出任务时绑定的模板版本,避免后续模板变更影响历史任务。 + */ + private Long templateId; private String templateCode; private String templateVersion; + private String templateStorageType; + private String templateStorageKey; + private String templateHash; + /** + * 生成文件的实际存储键,下载接口据此读取文件。 + */ private String storageKey; + /** + * 文件字节数,便于前端展示和运维排查。 + */ private Long fileSize; + /** + * 文件摘要,用于校验文件完整性。 + */ private String fileHash; + /** + * 导出的明细行数。 + */ private Integer rowCount; - /** 导出状态:PROCESSING生成中、SUCCESS成功可下载、FAILED失败。 */ + /** + * 导出状态:PROCESSING生成中、SUCCESS成功可下载、FAILED失败。 + */ private String exportStatus; private String exportBy; @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/ArBillGenerateTask.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/ArBillGenerateTask.java new file mode 100644 index 00000000..2187cfc0 --- /dev/null +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/ArBillGenerateTask.java @@ -0,0 +1,89 @@ +package com.ruoyi.ar.domain; + +import java.io.Serializable; +import java.math.BigDecimal; +import java.util.Date; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +/** + * 应收账单异步生成任务。 + */ +@Data +public class ArBillGenerateTask implements Serializable { + private static final long serialVersionUID = 1L; + + /** + * 异步任务主键。 + */ + private Long taskId; + /** + * 面向日志和前端展示的任务编号。 + */ + private String taskNo; + /** + * 任务对应的账单头主键。 + */ + private Long arBillId; + /** + * 订单选择模式 code:EXPLICIT=手动选中,ALL_MATCHED=全选查询结果。 + */ + private String selectionMode; + /** + * 生成请求快照,避免任务执行过程中依赖前端当前条件。 + */ + private String requestPayload; + /** + * 任务创建时的来源订单创建时间快照。 + */ + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date snapshotCreateTime; + /** + * 按 bill_id 字典序处理的游标。 + */ + private String lastSourceBillId; + /** + * 待处理总数;全选模式统计完成前使用 -1。 + */ + private Integer totalCount; + /** + * 已扫描的来源订单数,用于进度展示。 + */ + private Integer processedCount; + /** + * 成功写入账单明细的订单数。 + */ + private Integer successCount; + /** + * 已写入账单明细的累计应收金额。 + */ + private BigDecimal totalAmount; + /** + * 任务状态,取 ArBillGenerateTaskStatus 中定义的值。 + */ + private String status; + /** + * 失败、取消或补偿清理时返回给用户的说明。 + */ + private String errorMessage; + /** + * 请求幂等号,防止重复创建账单生成任务。 + */ + private String requestToken; + /** + * 发起任务的用户。 + */ + private String createBy; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date createTime; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + /** 首次被执行器领取的时间。 */ + private Date startedAt; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + /** 最近一次进度更新时间,用于识别执行节点异常。 */ + private Date heartbeatTime; + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + /** 成功、失败或取消完成的时间。 */ + private Date finishedAt; +} diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/ArBillLog.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/ArBillLog.java index a22c351c..ba217d9b 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/ArBillLog.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/ArBillLog.java @@ -2,23 +2,31 @@ package com.ruoyi.ar.domain; import java.io.Serializable; import java.util.Date; + import com.fasterxml.jackson.annotation.JsonFormat; import lombok.Data; -/** 应收模块追加式审计日志。 */ +/** + * 应收模块追加式审计日志。 + */ @Data -public class ArBillLog implements Serializable -{ +public class ArBillLog implements Serializable { private static final long serialVersionUID = 1L; private Long logId; private Long arBillId; - /** 对象类型:BILL应收账单、EXPORT账单导出、BALANCE_WRITEOFF余额核销。 */ + /** + * 对象类型:BILL应收账单、EXPORT账单导出、BALANCE_WRITEOFF余额核销。 + */ private String objectType; private String objectId; - /** 对象业务标识,例如账单号、收款单号或核销单号。 */ + /** + * 对象业务标识,例如账单号、收款单号或核销单号。 + */ private String objectDisplay; - /** 操作类型,取值由 ArLogOperationType 维护。 */ + /** + * 操作类型,取值由 ArLogOperationType 维护。 + */ private String operationType; private String operationDesc; private String fromStatus; @@ -27,11 +35,15 @@ public class ArBillLog implements Serializable private String afterData; private String operatorId; private String operatorName; - /** 操作来源:WEB网页端、API接口、JOB定时任务、IMPORT数据导入。 */ + /** + * 操作来源:WEB网页端、API接口、JOB定时任务、IMPORT数据导入。 + */ private String operationSource; private String requestId; private String traceId; - /** 操作结果:SUCCESS成功、FAILED失败。 */ + /** + * 操作结果:SUCCESS成功、FAILED失败。 + */ private String result; private String errorMessage; private String clientIp; diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/ArExportTemplate.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/ArExportTemplate.java new file mode 100644 index 00000000..35c89842 --- /dev/null +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/ArExportTemplate.java @@ -0,0 +1,28 @@ +package com.ruoyi.ar.domain; + +import java.io.Serializable; +import java.util.Date; + +import lombok.Data; + +/** + * 应收账单导出模板元数据。 + */ +@Data +public class ArExportTemplate implements Serializable { + private static final long serialVersionUID = 1L; + + private Long templateId; + private String templateCode; + private String templateName; + private String businessType; + private String templateVersion; + private String storageType; + private String storageKey; + private String fileHash; + private String status; + private String isDefault; + private Date effectiveStart; + private Date effectiveEnd; + private String remark; +} diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/vo/ArBillGenerateQuery.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/vo/ArBillGenerateQuery.java index e3a11149..911b768b 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/vo/ArBillGenerateQuery.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/vo/ArBillGenerateQuery.java @@ -16,4 +16,10 @@ public class ArBillGenerateQuery private Date billPeriodEnd; private String currency; + + /** 订单号或运单号关键词,按 OR 条件模糊查询。 */ + private String orderOrTrackingNo; + + /** 全选模式下,排除弹窗使用的附加订单号或运单号关键词。 */ + private String excludeOrderOrTrackingNo; } diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/vo/ArBillGenerateRequest.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/vo/ArBillGenerateRequest.java index a85dd987..26939407 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/vo/ArBillGenerateRequest.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/domain/vo/ArBillGenerateRequest.java @@ -4,8 +4,8 @@ import java.util.Date; import java.util.List; import com.fasterxml.jackson.annotation.JsonFormat; import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; +import com.ruoyi.ar.enums.ArBillSelectionMode; import lombok.Data; /** 从订单计费结果生成正式账单的参数。 */ @@ -21,14 +21,21 @@ public class ArBillGenerateRequest @NotNull(message = "账单截止日不能为空") @JsonFormat(pattern = "yyyy-MM-dd") private Date billPeriodEnd; - @NotEmpty(message = "请至少选择一条订单") + /** 手动模式选中的来源订单 ID;全选模式为空。 */ private List sourceBillIds; + /** 选择模式:EXPLICIT 手动选择,ALL_MATCHED 全选查询结果。 */ + private String selectionMode = ArBillSelectionMode.EXPLICIT.getCode(); + /** 全选模式中用户明确排除的来源订单 ID。 */ + private List excludedSourceBillIds; @JsonFormat(pattern = "yyyy-MM-dd") private Date billDate; private String currency = "CNY"; + /** 订单号或运单号关键词,生成时用于再次校验筛选条件。 */ + private String orderOrTrackingNo; private String bankAccountName; private String bankAccountNo; private String bankName; private String remark; + /** 前端幂等号;重复提交同一请求时复用原任务。 */ private String requestId; } diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/enums/ArBillGenerateTaskStatus.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/enums/ArBillGenerateTaskStatus.java new file mode 100644 index 00000000..bc216ef2 --- /dev/null +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/enums/ArBillGenerateTaskStatus.java @@ -0,0 +1,22 @@ +package com.ruoyi.ar.enums; + +/** 应收账单异步生成任务状态。 */ +public final class ArBillGenerateTaskStatus +{ + /** 待线程池领取的任务。 */ + public static final String WAITING = "WAITING"; + /** 已被某个执行节点领取,正在生成明细。 */ + public static final String RUNNING = "RUNNING"; + /** 明细全部生成成功。 */ + public static final String SUCCESS = "SUCCESS"; + /** 生成失败,账单已进入失败状态。 */ + public static final String FAILED = "FAILED"; + /** 用户已请求取消,执行器会在安全边界清理已生成数据。 */ + public static final String CANCEL_REQUESTED = "CANCEL_REQUESTED"; + /** 取消清理完成,账单及其生成明细已回收。 */ + public static final String CANCELED = "CANCELED"; + + private ArBillGenerateTaskStatus() + { + } +} diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/enums/ArBillGenerationStatus.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/enums/ArBillGenerationStatus.java new file mode 100644 index 00000000..a32c7506 --- /dev/null +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/enums/ArBillGenerationStatus.java @@ -0,0 +1,16 @@ +package com.ruoyi.ar.enums; + +/** 应收账单生成状态,与账单业务状态分离。 */ +public final class ArBillGenerationStatus +{ + /** 账单头已创建,明细和费用正在异步生成。 */ + public static final String GENERATING = "GENERATING"; + /** 明细生成完成,允许确认、核销和导出。 */ + public static final String READY = "READY"; + /** 明细生成失败或取消后已完成清理。 */ + public static final String FAILED = "FAILED"; + + private ArBillGenerationStatus() + { + } +} diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/enums/ArBillSelectionMode.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/enums/ArBillSelectionMode.java new file mode 100644 index 00000000..427ad3e5 --- /dev/null +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/enums/ArBillSelectionMode.java @@ -0,0 +1,49 @@ +package com.ruoyi.ar.enums; + +/** + * 应收账单生成时的订单选择模式。 + * 数据库和接口仍保存英文 code,业务代码通过枚举统一校验和判断。 + */ +public enum ArBillSelectionMode +{ + /** 手动选择当前预览结果中的订单。 */ + EXPLICIT("EXPLICIT", "手动选择"), + /** 选择全部查询结果,仅排除用户明确勾选的订单。 */ + ALL_MATCHED("ALL_MATCHED", "全选查询结果"); + + private final String code; + private final String label; + + ArBillSelectionMode(String code, String label) + { + this.code = code; + this.label = label; + } + + public String getCode() + { + return code; + } + + public String getLabel() + { + return label; + } + + /** 空值按历史默认行为视为手动选择,非法非空值返回 null 交由业务层记录失败日志。 */ + public static ArBillSelectionMode of(String code) + { + if (code == null || code.trim().isEmpty()) + { + return EXPLICIT; + } + for (ArBillSelectionMode mode : values()) + { + if (mode.code.equalsIgnoreCase(code.trim())) + { + return mode; + } + } + return null; + } +} diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/enums/ArBillStatus.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/enums/ArBillStatus.java index a4d83c8b..430222b3 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/enums/ArBillStatus.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/enums/ArBillStatus.java @@ -28,16 +28,19 @@ public enum ArBillStatus return label; } + /** 只有草稿账单允许确认。 */ public boolean canConfirm() { return this == DRAFT; } + /** 草稿或已确认账单允许作废;已核销账单不可直接作废。 */ public boolean canVoid() { return this == DRAFT || this == CONFIRMED; } + /** 已确认或部分核销账单允许继续核销。 */ public boolean canWriteOff() { return this == CONFIRMED || this == PARTIAL_WRITTEN_OFF; diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/event/ArBillExportTaskEvent.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/event/ArBillExportTaskEvent.java new file mode 100644 index 00000000..b623203d --- /dev/null +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/event/ArBillExportTaskEvent.java @@ -0,0 +1,21 @@ +package com.ruoyi.ar.event; + +/** + * 应收账单导出任务已提交事件。 + * + *

由事务提交后监听器处理,确保异步线程读取到已落库的导出任务。

+ */ +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 selectByDetailId(Long detailId); + List selectByBillIdAndDetailId(@Param("arBillId") Long arBillId, + @Param("detailId") Long detailId); + List selectByBillId(Long arBillId); + List selectByDetailIds(@Param("detailIds") List detailIds); + int insert(ArBillDetailFee fee); int insertBatch(List fees); + + int deleteByBillId(Long arBillId); } diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/mapper/ArBillDetailMapper.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/mapper/ArBillDetailMapper.java index 7e532815..1c5874a3 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/mapper/ArBillDetailMapper.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/mapper/ArBillDetailMapper.java @@ -1,16 +1,27 @@ package com.ruoyi.ar.mapper; import java.util.List; + +import org.apache.ibatis.annotations.Param; import com.ruoyi.ar.domain.ArBillDetail; -public interface ArBillDetailMapper -{ +public interface ArBillDetailMapper { List selectByBillId(Long arBillId); - /** 查询账单明细及费用明细,避免详情页逐层查询。 */ + List selectByBillIdAndSourceIds(@Param("arBillId") Long arBillId, + @Param("sourceBillIds") List sourceBillIds); + + /** + * 查询账单明细及费用明细,避免详情页逐层查询。 + */ List selectByBillIdWithFees(Long arBillId); + List selectExportPage(@Param("arBillId") Long arBillId, + @Param("lastDetailNo") Integer lastDetailNo, @Param("pageSize") Integer pageSize); + int insert(ArBillDetail detail); int insertBatch(List details); + + int deleteByBillId(Long arBillId); } diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/mapper/ArBillExportMapper.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/mapper/ArBillExportMapper.java index 07539830..38703ee5 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/mapper/ArBillExportMapper.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/mapper/ArBillExportMapper.java @@ -1,15 +1,25 @@ package com.ruoyi.ar.mapper; +import java.util.Date; import java.util.List; + +import org.apache.ibatis.annotations.Param; import com.ruoyi.ar.domain.ArBillExport; -public interface ArBillExportMapper -{ +public interface ArBillExportMapper { List selectByBillId(Long arBillId); ArBillExport selectById(Long exportId); ArBillExport selectLatestSuccessByBillId(Long arBillId); + ArBillExport selectProcessingByBillId(Long arBillId); + int insert(ArBillExport export); + + int markSuccess(ArBillExport export); + + int markFailed(@Param("exportId") Long exportId, @Param("remark") String remark); + + int markExpiredProcessingAsFailed(@Param("expiredBefore") Date expiredBefore, @Param("remark") String remark); } diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/mapper/ArBillGenerateTaskMapper.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/mapper/ArBillGenerateTaskMapper.java new file mode 100644 index 00000000..15b2192b --- /dev/null +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/mapper/ArBillGenerateTaskMapper.java @@ -0,0 +1,44 @@ +package com.ruoyi.ar.mapper; + +import java.util.Date; +import java.util.List; + +import org.apache.ibatis.annotations.Param; +import com.ruoyi.ar.domain.ArBillGenerateTask; + +/** + * 应收账单异步生成任务 Mapper。 + */ +public interface ArBillGenerateTaskMapper { + ArBillGenerateTask selectById(Long taskId); + + ArBillGenerateTask selectByRequestToken(String requestToken); + + List selectActiveByCreateBy(@Param("createBy") String createBy); + + List selectWaiting(@Param("limit") int limit); + + int insert(ArBillGenerateTask task); + + int markRunning(@Param("taskId") Long taskId, @Param("startedAt") Date startedAt); + + int updateTotalCount(@Param("taskId") Long taskId, @Param("totalCount") Integer totalCount); + + int updateProgress(@Param("taskId") Long taskId, @Param("lastSourceBillId") String lastSourceBillId, + @Param("processedCount") Integer processedCount, @Param("successCount") Integer successCount, + @Param("totalAmount") java.math.BigDecimal totalAmount, @Param("heartbeatTime") Date heartbeatTime); + + int markSuccess(@Param("taskId") Long taskId, @Param("finishedAt") Date finishedAt); + + int markFailed(@Param("taskId") Long taskId, @Param("errorMessage") String errorMessage, + @Param("finishedAt") Date finishedAt); + + int markCancelRequested(@Param("taskId") Long taskId); + + int markCanceled(@Param("taskId") Long taskId, @Param("finishedAt") Date finishedAt); + + int resetExpiredRunning(@Param("expiredBefore") Date expiredBefore); + + List selectCancelRequestedBefore(@Param("expiredBefore") Date expiredBefore, + @Param("limit") int limit); +} diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/mapper/ArBillMapper.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/mapper/ArBillMapper.java index 1602382c..85816da3 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/mapper/ArBillMapper.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/mapper/ArBillMapper.java @@ -3,35 +3,49 @@ package com.ruoyi.ar.mapper; import java.math.BigDecimal; import java.util.Date; import java.util.List; + import org.apache.ibatis.annotations.Param; import com.ruoyi.ar.domain.ArBill; import com.ruoyi.ar.domain.vo.ArBillQuery; -public interface ArBillMapper -{ +public interface ArBillMapper { List selectArBillList(ArBillQuery query); ArBill selectArBillById(Long arBillId); ArBill selectArBillByBillNo(String billNo); + ArBill selectGeneratingByCustomerPeriod(@Param("customerId") Long customerId, + @Param("currency") String currency, @Param("cutoffDate") Date cutoffDate); + Integer selectMaxBillVersion(@Param("customerId") Long customerId, - @Param("cutoffDate") Date cutoffDate, @Param("currency") String currency); + @Param("cutoffDate") Date cutoffDate, @Param("currency") String currency); ArBill selectArBillByIdForUpdate(Long arBillId); - /** 查询客户可核销账单(已确认/部分核销),按账单日期升序,供余额核销选择 */ + /** + * 查询客户可核销账单(已确认/部分核销),按账单日期升序,供余额核销选择 + */ List selectUnsettledByCustomer(@Param("customerId") Long customerId); int insertArBill(ArBill bill); int updateArBillStatus(@Param("arBillId") Long arBillId, @Param("status") String status, - @Param("remark") String remark, @Param("operator") String operator, - @Param("fromStatus") String fromStatus); + @Param("remark") String remark, @Param("operator") String operator, + @Param("fromStatus") String fromStatus); int updateArBillAmounts(@Param("arBillId") Long arBillId, - @Param("writtenOffAmount") BigDecimal writtenOffAmount, - @Param("unwrittenAmount") BigDecimal unwrittenAmount, - @Param("status") String status, @Param("operator") String operator, - @Param("fromStatus") String fromStatus); + @Param("writtenOffAmount") BigDecimal writtenOffAmount, + @Param("unwrittenAmount") BigDecimal unwrittenAmount, + @Param("status") String status, @Param("operator") String operator, + @Param("fromStatus") String fromStatus); + + int updateArBillProgress(@Param("arBillId") Long arBillId, + @Param("orderCount") Integer orderCount, @Param("totalAmount") BigDecimal totalAmount, + @Param("unwrittenAmount") BigDecimal unwrittenAmount, + @Param("billPeriodStart") Date billPeriodStart, @Param("operator") String operator); + + int updateArBillGenerationStatus(@Param("arBillId") Long arBillId, + @Param("generationStatus") String generationStatus, @Param("operator") String operator, + @Param("remark") String remark); } diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/mapper/ArExportTemplateMapper.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/mapper/ArExportTemplateMapper.java new file mode 100644 index 00000000..3321ba5c --- /dev/null +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/mapper/ArExportTemplateMapper.java @@ -0,0 +1,14 @@ +package com.ruoyi.ar.mapper; + +import java.util.Date; + +import org.apache.ibatis.annotations.Param; +import com.ruoyi.ar.domain.ArExportTemplate; + +public interface ArExportTemplateMapper { + /** + * 查询指定业务类型当前生效的默认模板。 + */ + ArExportTemplate selectDefaultEnabled(@Param("businessType") String businessType, + @Param("effectiveDate") Date effectiveDate); +} diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/service/IArBillLogService.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/service/IArBillLogService.java index f3b8f453..d1ed4ba8 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/service/IArBillLogService.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/service/IArBillLogService.java @@ -1,16 +1,16 @@ package com.ruoyi.ar.service; import java.util.List; + import com.ruoyi.ar.domain.ArBillLog; import com.ruoyi.ar.enums.ArLogObjectType; import com.ruoyi.ar.enums.ArLogOperationType; -public interface IArBillLogService -{ +public interface IArBillLogService { List selectLogList(ArBillLog query); void record(ArBillLog log); void recordFailure(Long arBillId, ArLogObjectType objectType, String objectId, ArLogOperationType operationType, - String fromStatus, String message, String operator, String requestId); + String fromStatus, String message, String operator, String requestId); } diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/service/IArBillService.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/service/IArBillService.java index 89a7409a..9edd9d64 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/service/IArBillService.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/service/IArBillService.java @@ -1,9 +1,14 @@ package com.ruoyi.ar.service; +import java.math.BigDecimal; +import java.util.Date; import java.util.List; import com.ruoyi.ar.domain.ArBill; import com.ruoyi.ar.domain.ArBankAccount; +import com.ruoyi.ar.domain.ArBillDetail; +import com.ruoyi.ar.domain.ArBillDetailFee; import com.ruoyi.ar.domain.ArBillExport; +import com.ruoyi.ar.domain.ArBillGenerateTask; import com.ruoyi.ar.domain.ArBillLog; import com.ruoyi.ar.domain.vo.ArBillGenerateRequest; import com.ruoyi.ar.domain.vo.ArBillGenerateQuery; @@ -11,17 +16,48 @@ import com.ruoyi.ar.domain.vo.ArBillActionRequest; import com.ruoyi.ar.domain.vo.ArBillQuery; import com.ruoyi.price.domain.PrcOrderBill; +/** + * 应收账单业务接口。 + * 查询接口面向列表/详情页面,生成和导出接口面向异步任务执行器; + * Controller 不直接操作 Mapper,统一由这里维护业务状态和事务边界。 + */ public interface IArBillService { List selectArBillList(ArBillQuery query); ArBill selectArBillById(Long arBillId); + List selectArBillDetails(Long arBillId); + + List selectArBillDetailFees(Long arBillId, Long detailId); + ArBankAccount getDefaultBankAccount(String currency); List previewBill(ArBillGenerateQuery query); - ArBill generateBill(ArBillGenerateRequest request, String operator, String requestId); + BigDecimal sumPreviewBill(ArBillGenerateQuery query); + + ArBillGenerateTask generateBill(ArBillGenerateRequest request, String operator, String requestId); + + ArBillGenerateTask selectGenerateTask(Long taskId); + + List selectActiveGenerateTasks(String operator); + + ArBillGenerateTask cancelGenerateTask(Long taskId, String operator, String requestId); + + ArBillGenerateTask retryGenerateTask(Long taskId, String operator, String requestId); + + void processGenerateTask(Long taskId); + + boolean claimGenerateTask(Long taskId); + + void failGenerateTask(Long taskId, String remark); + + int recoverExpiredGenerateTasks(Date expiredBefore); + + int recoverCancelRequestedGenerateTasks(Date expiredBefore); + + List selectWaitingGenerateTasks(int limit); ArBill confirmBill(Long arBillId, ArBillActionRequest request, String operator, String requestId); @@ -29,6 +65,14 @@ public interface IArBillService ArBillExport exportBill(Long arBillId, String operator, String requestId); + ArBillExport selectExport(Long exportId); + + void processExportTask(Long exportId); + + void failExportTask(Long exportId, String remark); + + int failExpiredExportTasks(Date expiredBefore); + List selectExports(Long arBillId); void downloadExport(Long exportId, jakarta.servlet.http.HttpServletResponse response) throws Exception; diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/service/impl/ArBalanceWriteoffServiceImpl.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/service/impl/ArBalanceWriteoffServiceImpl.java index 582a4cda..d408bcde 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/service/impl/ArBalanceWriteoffServiceImpl.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/service/impl/ArBalanceWriteoffServiceImpl.java @@ -7,6 +7,7 @@ import java.time.format.DateTimeFormatter; import java.util.Date; import java.util.List; import java.util.concurrent.ThreadLocalRandom; + import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -39,8 +40,7 @@ import com.ruoyi.customer.customermoney.domain.CustomerBalanceFlow; * @date 2026-09-16 */ @Service -public class ArBalanceWriteoffServiceImpl implements IArBalanceWriteoffService -{ +public class ArBalanceWriteoffServiceImpl implements IArBalanceWriteoffService { private static final DateTimeFormatter NO_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS"); private static final int MONEY_SCALE = 2; @@ -56,55 +56,55 @@ public class ArBalanceWriteoffServiceImpl implements IArBalanceWriteoffService @Autowired private ICustomerAccountApi customerAccountApi; + /** + * 按客户查询允许余额核销的未结清账单。 + */ @Override - public List selectUnsettledBills(Long customerId) - { - if (customerId == null) - { + public List selectUnsettledBills(Long customerId) { + if (customerId == null) { return List.of(); } return billMapper.selectUnsettledByCustomer(customerId); } + /** + * 查询账单余额核销记录列表。 + */ @Override - public List selectWriteoffList(ArBalanceWriteoff query) - { + public List selectWriteoffList(ArBalanceWriteoff query) { return writeoffMapper.selectWriteoffList(query); } + /** + * 执行余额核销主流程:锁账单、登记核销单、扣减客户余额、更新账单金额和状态。 + * 任一步失败都会回滚当前事务,避免核销单、资金流水和账单状态不一致。 + */ @Override @Transactional(rollbackFor = Exception.class) - public ArBalanceWriteoff writeoff(ArBalanceWriteoff cmd) - { - if (cmd == null || cmd.getArBillId() == null) - { + public ArBalanceWriteoff writeoff(ArBalanceWriteoff cmd) { + if (cmd == null || cmd.getArBillId() == null) { throw new ServiceException("请选择要核销的账单"); } BigDecimal amount = cmd.getWriteoffAmount(); - if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) - { + if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { throw new ServiceException("核销金额必须大于0"); } amount = amount.setScale(MONEY_SCALE, RoundingMode.HALF_UP); // 1. 行锁锁定账单(加锁顺序:账单 -> 账户,与反核销保持一致) ArBill bill = billMapper.selectArBillByIdForUpdate(cmd.getArBillId()); - if (bill == null) - { + if (bill == null) { throw new ServiceException("应收账单不存在"); } ArBillStatus status = ArBillStatus.of(bill.getStatus()); - if (status == null || !status.canWriteOff()) - { + if (status == null || !status.canWriteOff()) { throw new ServiceException("当前账单状态不允许核销:" + (status == null ? bill.getStatus() : status.getLabel())); } - if (cmd.getCustomerId() != null && !cmd.getCustomerId().equals(bill.getCustomerId())) - { + if (cmd.getCustomerId() != null && !cmd.getCustomerId().equals(bill.getCustomerId())) { throw new ServiceException("账单与所选客户不匹配"); } BigDecimal unwritten = nvl(bill.getUnwrittenAmount()); - if (amount.compareTo(unwritten) > 0) - { + if (amount.compareTo(unwritten) > 0) { throw new ServiceException("核销金额超过账单未核销金额:" + unwritten.toPlainString()); } @@ -130,7 +130,7 @@ public class ArBalanceWriteoffServiceImpl implements IArBalanceWriteoffService // 3. 扣减账户余额并释放等额已用额度,写 BILL_PAY 扣款流水(余额不足在此处拦截) String flowRemark = "余额核销账单:" + bill.getBillNo() - + (StringUtils.hasText(writeoff.getRemark()) ? "(" + writeoff.getRemark() + ")" : ""); + + (StringUtils.hasText(writeoff.getRemark()) ? "(" + writeoff.getRemark() + ")" : ""); BalanceChangeCommand payCmd = new BalanceChangeCommand(); payCmd.setCustomerId(bill.getCustomerId()); payCmd.setCurrency(currency); @@ -147,40 +147,38 @@ public class ArBalanceWriteoffServiceImpl implements IArBalanceWriteoffService BigDecimal newUnwritten = unwritten.subtract(amount); String nextStatus = billStatus(written, nvl(bill.getTotalAmount()), fromStatus); if (billMapper.updateArBillAmounts(bill.getArBillId(), written, newUnwritten, - nextStatus, operator, fromStatus) != 1) - { + nextStatus, operator, fromStatus) != 1) { throw new ServiceException("账单状态已发生变化,请刷新后重试"); } recordLog(bill.getArBillId(), writeoff.getWriteoffId(), ArLogOperationType.BALANCE_WRITEOFF, - "账户余额核销账单 " + amount.toPlainString() + " " + currency, - fromStatus, nextStatus, operator, requestId); + "账户余额核销账单 " + amount.toPlainString() + " " + currency, + fromStatus, nextStatus, operator, requestId); writeoff.setAccountId(flow.getAccountId()); writeoff.setBalanceFlowId(flow.getId()); return writeoff; } + /** + * 执行反核销主流程:锁定核销单和账单、回补客户余额、恢复账单未核销金额, + * 最后将原核销记录标记为已取消。 + */ @Override @Transactional(rollbackFor = Exception.class) - public void cancelWriteoff(Long writeoffId, String remark) - { - if (writeoffId == null) - { + public void cancelWriteoff(Long writeoffId, String remark) { + if (writeoffId == null) { throw new ServiceException("核销记录不存在"); } // 1. 锁核销单 -> 锁账单(与核销相同的加锁顺序) ArBalanceWriteoff writeoff = writeoffMapper.selectByIdForUpdate(writeoffId); - if (writeoff == null) - { + if (writeoff == null) { throw new ServiceException("核销记录不存在"); } - if (!ArBalanceWriteoffStatus.EFFECTIVE.getCode().equals(writeoff.getStatus())) - { + if (!ArBalanceWriteoffStatus.EFFECTIVE.getCode().equals(writeoff.getStatus())) { throw new ServiceException("只有有效状态的核销记录可以反核销"); } ArBill bill = billMapper.selectArBillByIdForUpdate(writeoff.getArBillId()); - if (bill == null) - { + if (bill == null) { throw new ServiceException("核销关联的账单不存在"); } @@ -204,44 +202,40 @@ public class ArBalanceWriteoffServiceImpl implements IArBalanceWriteoffService // 3. 账单核销金额回退,状态按剩余已核销额重算(归零则回到已确认) BigDecimal written = nvl(bill.getWrittenOffAmount()).subtract(nvl(writeoff.getWriteoffAmount())); - if (written.compareTo(BigDecimal.ZERO) < 0) - { + if (written.compareTo(BigDecimal.ZERO) < 0) { written = BigDecimal.ZERO; } BigDecimal newUnwritten = nvl(bill.getTotalAmount()).subtract(written); String nextStatus = billStatus(written, nvl(bill.getTotalAmount()), fromStatus); if (billMapper.updateArBillAmounts(bill.getArBillId(), written, newUnwritten, - nextStatus, operator, fromStatus) != 1) - { + nextStatus, operator, fromStatus) != 1) { throw new ServiceException("账单状态已发生变化,请刷新后重试"); } // 4. 核销单置为CANCELLED并登记回补流水 if (writeoffMapper.updateCancelled(writeoffId, reverseFlow.getId(), operator, cancelRemark, - ArBalanceWriteoffStatus.CANCELLED.getCode(), ArBalanceWriteoffStatus.EFFECTIVE.getCode()) != 1) - { + ArBalanceWriteoffStatus.CANCELLED.getCode(), ArBalanceWriteoffStatus.EFFECTIVE.getCode()) != 1) { throw new ServiceException("核销记录状态已变化,请刷新后重试"); } recordLog(bill.getArBillId(), writeoffId, ArLogOperationType.CANCEL_BALANCE_WRITEOFF, - "反核销回补账单:" + cancelRemark, - fromStatus, nextStatus, operator, requestId); + "反核销回补账单:" + cancelRemark, + fromStatus, nextStatus, operator, requestId); } - /** 账单状态重算:已核销归零(非草稿)-> 已确认;足额 -> 已核销;否则部分核销 */ - private String billStatus(BigDecimal written, BigDecimal total, String current) - { - if (written.compareTo(BigDecimal.ZERO) == 0) - { + /** + * 账单状态重算:归零(非草稿)为已确认,足额为已核销,否则为部分核销。 + */ + private String billStatus(BigDecimal written, BigDecimal total, String current) { + if (written.compareTo(BigDecimal.ZERO) == 0) { return ArBillStatus.DRAFT.getCode().equals(current) - ? ArBillStatus.DRAFT.getCode() : ArBillStatus.CONFIRMED.getCode(); + ? ArBillStatus.DRAFT.getCode() : ArBillStatus.CONFIRMED.getCode(); } return written.compareTo(total) >= 0 - ? ArBillStatus.WRITTEN_OFF.getCode() : ArBillStatus.PARTIAL_WRITTEN_OFF.getCode(); + ? ArBillStatus.WRITTEN_OFF.getCode() : ArBillStatus.PARTIAL_WRITTEN_OFF.getCode(); } private void recordLog(Long billId, Long writeoffId, ArLogOperationType operationType, String desc, - String fromStatus, String toStatus, String operator, String requestId) - { + String fromStatus, String toStatus, String operator, String requestId) { ArBillLog log = new ArBillLog(); log.setArBillId(billId); log.setObjectType(ArLogObjectType.BALANCE_WRITEOFF.getCode()); @@ -258,14 +252,12 @@ public class ArBalanceWriteoffServiceImpl implements IArBalanceWriteoffService logService.record(log); } - private String generateNo() - { + private String generateNo() { return "BWO" + LocalDateTime.now().format(NO_FORMATTER) - + ThreadLocalRandom.current().nextInt(100, 1000); + + ThreadLocalRandom.current().nextInt(100, 1000); } - private BigDecimal nvl(BigDecimal value) - { + private BigDecimal nvl(BigDecimal value) { return value == null ? BigDecimal.ZERO : value; } } diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/service/impl/ArBillLogServiceImpl.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/service/impl/ArBillLogServiceImpl.java index 55740d69..b368cec7 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/service/impl/ArBillLogServiceImpl.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/service/impl/ArBillLogServiceImpl.java @@ -1,6 +1,7 @@ package com.ruoyi.ar.service.impl; import java.util.List; + import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; @@ -15,29 +16,27 @@ import com.ruoyi.ar.service.IArBillLogService; import com.ruoyi.common.utils.uuid.IdUtils; @Service -public class ArBillLogServiceImpl implements IArBillLogService -{ +public class ArBillLogServiceImpl implements IArBillLogService { @Autowired private ArBillLogMapper logMapper; @Override - public List selectLogList(ArBillLog query) - { + public List selectLogList(ArBillLog query) { return logMapper.selectList(query); } @Override - public void record(ArBillLog log) - { + public void record(ArBillLog log) { logMapper.insert(log); } - /** 失败日志必须独立提交,不能随业务异常一起回滚。 */ + /** + * 失败日志必须独立提交,不能随业务异常一起回滚。 + */ @Override @Transactional(propagation = Propagation.REQUIRES_NEW) public void recordFailure(Long arBillId, ArLogObjectType objectType, String objectId, ArLogOperationType operationType, - String fromStatus, String message, String operator, String requestId) - { + String fromStatus, String message, String operator, String requestId) { ArBillLog log = new ArBillLog(); log.setArBillId(arBillId); log.setObjectType(objectType.getCode()); diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/service/impl/ArBillServiceImpl.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/service/impl/ArBillServiceImpl.java index c3919b57..a13e2bbd 100644 --- a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/service/impl/ArBillServiceImpl.java +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/service/impl/ArBillServiceImpl.java @@ -3,8 +3,12 @@ package com.ruoyi.ar.service.impl; import java.io.File; import java.io.IOException; import java.io.InputStream; +import java.io.OutputStream; import java.math.BigDecimal; +import java.nio.file.AtomicMoveNotSupportedException; import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.text.SimpleDateFormat; @@ -14,27 +18,37 @@ import java.util.HashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; +import java.util.zip.ZipOutputStream; + import jakarta.servlet.http.HttpServletResponse; -import org.apache.poi.ss.usermodel.BorderStyle; -import org.apache.poi.ss.usermodel.Cell; -import org.apache.poi.ss.usermodel.CellStyle; -import org.apache.poi.ss.usermodel.Font; -import org.apache.poi.ss.usermodel.HorizontalAlignment; -import org.apache.poi.ss.usermodel.Row; -import org.apache.poi.ss.usermodel.Sheet; -import org.apache.poi.ss.usermodel.VerticalAlignment; -import org.apache.poi.xssf.usermodel.XSSFWorkbook; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.core.io.ClassPathResource; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import com.alibaba.fastjson2.JSON; import com.ruoyi.ar.domain.ArBill; import com.ruoyi.ar.domain.ArBankAccount; import com.ruoyi.ar.domain.ArBillDetail; import com.ruoyi.ar.domain.ArBillDetailFee; import com.ruoyi.ar.domain.ArBillExport; +import com.ruoyi.ar.domain.ArBillGenerateTask; import com.ruoyi.ar.domain.ArBillLog; +import com.ruoyi.ar.domain.ArExportTemplate; +import com.ruoyi.ar.event.ArBillExportTaskEvent; +import com.ruoyi.ar.event.ArBillGenerateTaskEvent; +import com.ruoyi.ar.enums.ArBillGenerateTaskStatus; +import com.ruoyi.ar.enums.ArBillGenerationStatus; +import com.ruoyi.ar.enums.ArBillSelectionMode; import com.ruoyi.ar.enums.ArBillStatus; import com.ruoyi.ar.enums.ArExportStatus; import com.ruoyi.ar.enums.ArLogObjectType; @@ -47,7 +61,9 @@ import com.ruoyi.ar.mapper.ArBillDetailMapper; import com.ruoyi.ar.mapper.ArBillExportMapper; import com.ruoyi.ar.mapper.ArBillLogMapper; import com.ruoyi.ar.mapper.ArBillMapper; +import com.ruoyi.ar.mapper.ArBillGenerateTaskMapper; import com.ruoyi.ar.mapper.ArBankAccountMapper; +import com.ruoyi.ar.mapper.ArExportTemplateMapper; import com.ruoyi.ar.service.IArBillLogService; import com.ruoyi.ar.service.IArBillService; import com.ruoyi.ar.domain.vo.ArBillActionRequest; @@ -66,369 +82,864 @@ import com.ruoyi.price.domain.PrcOrderBill; import com.ruoyi.price.mapper.PrcIncomeBillFeeMapper; import com.ruoyi.price.mapper.PrcOrderBillMapper; +/** + * 应收账单核心业务实现。 + * + *

这里同时承载账单生命周期、余额核销前置校验、异步生成任务和异步导出任务。 + * 大数据量场景使用数据库游标式分页和 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( + "]*\\br=\"(\\d+)\"[^>]*>.*?", Pattern.DOTALL); + private static final Pattern CELL_PATTERN = Pattern.compile( + "]*?)(?:/\\s*>|>(.*?))", Pattern.DOTALL); + private static final Pattern CELL_REF_PATTERN = Pattern.compile( + "\\br\\s*=\\s*[\\\"']([A-Z]+\\d+)[\\\"']", Pattern.CASE_INSENSITIVE); + private static final Pattern CELL_ROW_REF_PATTERN = Pattern.compile( + "(]*\\br=\")([A-Z]+)\\d+(\")"); + private static final Pattern ROW_REF_PATTERN = Pattern.compile( + "(]*\\br=\")\\d+(\")"); + private static final Pattern DIMENSION_PATTERN = Pattern.compile("]*/>"); + private static final Pattern MERGE_REF_PATTERN = Pattern.compile( + "(]*\\bref=\")([A-Z]+)(\\d+)(?::([A-Z]+)(\\d+))?(\")"); - @Autowired private ArBillMapper billMapper; - @Autowired private ArBankAccountMapper bankAccountMapper; - @Autowired private ArBillDetailMapper detailMapper; - @Autowired private ArBillDetailFeeMapper detailFeeMapper; - @Autowired private ArBillExportMapper exportMapper; - @Autowired private ArBillLogMapper logMapper; - @Autowired private IArBillLogService logService; - @Autowired private PrcOrderBillMapper sourceBillMapper; - @Autowired private PrcIncomeBillFeeMapper sourceFeeMapper; + @Autowired + private ArBillMapper billMapper; + @Autowired + private ArBillGenerateTaskMapper generateTaskMapper; + @Autowired + private ArBankAccountMapper bankAccountMapper; + @Autowired + private ArExportTemplateMapper exportTemplateMapper; + @Autowired + private ArBillDetailMapper detailMapper; + @Autowired + private ArBillDetailFeeMapper detailFeeMapper; + @Autowired + private ArBillExportMapper exportMapper; + @Autowired + private ArBillLogMapper logMapper; + @Autowired + private IArBillLogService logService; + @Autowired + private ApplicationEventPublisher eventPublisher; + @Autowired + private PrcOrderBillMapper sourceBillMapper; + @Autowired + private PrcIncomeBillFeeMapper sourceFeeMapper; + @Autowired + private PlatformTransactionManager transactionManager; + /** + * 查询账单列表,并对返回给前端的收款账号做脱敏。 + */ @Override - public List selectArBillList(ArBillQuery query) - { + public List selectArBillList(ArBillQuery query) { List bills = billMapper.selectArBillList(query); - for (ArBill bill : bills) - { + for (ArBill bill : bills) { bill.setBankAccountNo(maskAccount(bill.getBankAccountNo())); } return bills; } + /** + * 查询账单头信息;导出记录随账单头返回,明细由独立分页接口查询。 + */ @Override - public ArBill selectArBillById(Long arBillId) - { + public ArBill selectArBillById(Long arBillId) { ArBill bill = billMapper.selectArBillById(arBillId); - if (bill == null) - { + if (bill == null) { throw new ServiceException("应收账单不存在"); } - fillDetails(bill); bill.setBankAccountNo(maskAccount(bill.getBankAccountNo())); bill.setExports(exportMapper.selectByBillId(arBillId)); return bill; } + /** + * 查询账单明细,分页由 Controller 层的 startPage 统一接管。 + */ @Override - public ArBankAccount getDefaultBankAccount(String currency) - { + public List selectArBillDetails(Long arBillId) { + return detailMapper.selectByBillId(arBillId); + } + + /** + * 查询单条账单明细的费用项,详情页展开行时懒加载。 + */ + @Override + public List selectArBillDetailFees(Long arBillId, Long detailId) { + return detailFeeMapper.selectByBillIdAndDetailId(arBillId, detailId); + } + + /** + * 查询并脱敏指定币种的默认收款账户。 + */ + @Override + public ArBankAccount getDefaultBankAccount(String currency) { ArBankAccount account = bankAccountMapper.selectDefaultByCurrency( - StringUtils.isEmpty(currency) ? "CNY" : currency); - if (account != null) - { + StringUtils.isEmpty(currency) ? "CNY" : currency); + if (account != null) { account.setAccountNo(maskAccount(account.getAccountNo())); } return account; } + /** + * 分页预览可生成订单;订单号和运单号关键字在 SQL 中使用 OR 条件。 + */ @Override - public List previewBill(ArBillGenerateQuery query) - { + public List previewBill(ArBillGenerateQuery query) { validatePreviewQuery(query); return sourceBillMapper.selectPrcOrderBillByCustomerPeriod(query.getCustomerId(), - query.getCustomerCode(), query.getBillPeriodEnd(), query.getCurrency(), ArOrderBillStatus.UNBILLED.getCode()); + query.getCustomerCode(), StringUtils.trim(query.getOrderOrTrackingNo()), + StringUtils.trim(query.getExcludeOrderOrTrackingNo()), query.getBillPeriodEnd(), query.getCurrency(), + ArOrderBillStatus.UNBILLED.getCode(), null); } + /** + * 汇总当前生成条件下的订单应收金额,用于弹窗顶部合计展示。 + */ + @Override + public BigDecimal sumPreviewBill(ArBillGenerateQuery query) { + validatePreviewQuery(query); + return nvl(sourceBillMapper.sumPrcOrderBillIncomeByCustomerPeriod(query.getCustomerId(), + query.getCustomerCode(), StringUtils.trim(query.getOrderOrTrackingNo()), + StringUtils.trim(query.getExcludeOrderOrTrackingNo()), query.getBillPeriodEnd(), query.getCurrency(), + ArOrderBillStatus.UNBILLED.getCode())); + } + + /** + * 创建账单头和异步生成任务。 + * 请求参数会序列化保存为快照,后续任务只依赖快照执行;事务提交后再发布事件, + * 由任务执行器按批次读取来源订单、写入明细并更新游标。 + */ @Override @Transactional(rollbackFor = Exception.class) - public ArBill generateBill(ArBillGenerateRequest request, String operator, String requestId) - { + public ArBillGenerateTask generateBill(ArBillGenerateRequest request, String operator, String requestId) { validatePeriod(request); - ArBillLog previous = logMapper.selectSuccessByRequestId(requestId, ArLogObjectType.BILL.getCode(), - ArLogResult.SUCCESS.getCode()); - if (previous != null && isNumber(previous.getObjectId())) - { - return selectArBillById(Long.valueOf(previous.getObjectId())); + ArBillGenerateTask existed = generateTaskMapper.selectByRequestToken(requestId); + if (existed != null) { + return existed; } - List requestedSourceBillIds = normalizeSourceBillIds(request.getSourceBillIds()); - if (requestedSourceBillIds.isEmpty()) - { + ArBillSelectionMode selectionModeEnum = ArBillSelectionMode.of(request.getSelectionMode()); + if (selectionModeEnum == null) { throw failure(null, ArLogObjectType.BILL, "", ArLogOperationType.GENERATE, - null, "请至少选择一条有效订单", operator, requestId); + null, "无效的订单选择模式", operator, requestId); } - String requestedCurrency = StringUtils.isEmpty(request.getCurrency()) ? "CNY" : request.getCurrency(); - List sources = sourceBillMapper.selectPrcOrderBillByCustomerCutoffAndIds( - request.getCustomerId(), request.getCustomerCode(), request.getBillPeriodEnd(), requestedCurrency, - requestedSourceBillIds, ArOrderBillStatus.UNBILLED.getCode()); - if (sources == null || sources.isEmpty() || sources.size() != requestedSourceBillIds.size()) - { - throw failure(null, ArLogObjectType.BILL, "", ArLogOperationType.GENERATE, null, - "部分所选订单已出账、已变更或不符合当前筛选条件,请重新查询后生成", operator, requestId); + String selectionMode = selectionModeEnum.getCode(); + boolean allMatched = selectionModeEnum == ArBillSelectionMode.ALL_MATCHED; + List sourceBillIds = normalizeSourceBillIds(request.getSourceBillIds()); + List excludedSourceBillIds = normalizeSourceBillIds(request.getExcludedSourceBillIds()); + if (!allMatched && sourceBillIds.isEmpty()) { + throw failure(null, ArLogObjectType.BILL, "", ArLogOperationType.GENERATE, + null, "请至少选择一条有效订单", operator, requestId); } + if (!allMatched && sourceBillIds.size() > EXPLICIT_SOURCE_ID_LIMIT) { + throw new ServiceException("手动选择订单不能超过 " + EXPLICIT_SOURCE_ID_LIMIT + + " 条,订单量较大时请使用全选查询结果模式"); + } + if (allMatched && excludedSourceBillIds.size() > EXCLUDED_SOURCE_ID_LIMIT) { + throw new ServiceException("全选模式最多排除 " + EXCLUDED_SOURCE_ID_LIMIT + + " 条订单,请缩小查询条件后再生成"); + } + request.setSelectionMode(selectionMode); - String currency = requestedCurrency; - BigDecimal total = BigDecimal.ZERO; - for (PrcOrderBill source : sources) - { - if (source.getCurrency() != null && !currency.equalsIgnoreCase(source.getCurrency())) - { - throw failure(null, ArLogObjectType.BILL, "", ArLogOperationType.GENERATE, - null, "截止日期内存在多个币种,不能合并为一张账单", operator, requestId); - } - total = total.add(nvl(source.getIncomeTotal())); + String currency = StringUtils.isEmpty(request.getCurrency()) ? "CNY" : request.getCurrency().trim(); + ArBill generatingBill = billMapper.selectGeneratingByCustomerPeriod( + request.getCustomerId(), currency, request.getBillPeriodEnd()); + if (generatingBill != null) { + throw new ServiceException("该客户当前账期已有账单正在生成,请稍后再试"); } - ArBankAccount bankAccount = bankAccountMapper.selectDefaultByCurrency(currency); - if (bankAccount == null) - { + if (bankAccount == null) { throw failure(null, ArLogObjectType.BILL, "", ArLogOperationType.GENERATE, null, - "未配置 " + currency + " 币种的公司默认收款账户,请先完成收款账户配置", operator, requestId); + "未配置 " + currency + " 币种的公司默认收款账户,请先完成收款账户配置", operator, requestId); } + Date now = new Date(); + Integer maxVersion = billMapper.selectMaxBillVersion(request.getCustomerId(), request.getBillPeriodEnd(), currency); ArBill bill = new ArBill(); - bill.setBillNo("AR" + new SimpleDateFormat("yyyyMM").format(new Date()) - + IdUtils.fastSimpleUUID().substring(0, 8).toUpperCase()); + bill.setBillNo("AR" + new SimpleDateFormat("yyyyMM").format(now) + + IdUtils.fastSimpleUUID().substring(0, 8).toUpperCase()); bill.setBillName(request.getBillName().trim()); bill.setCustomerId(request.getCustomerId()); - bill.setCustomerCode(StringUtils.isEmpty(request.getCustomerCode()) ? sources.get(0).getCustomerCode() : request.getCustomerCode()); - bill.setCustomerName(StringUtils.isEmpty(request.getCustomerName()) ? sources.get(0).getCustomerName() : request.getCustomerName()); - bill.setBillPeriodStart(earliestBizDate(sources)); + String customerCode = StringUtils.trim(request.getCustomerCode()); + String customerName = StringUtils.trim(request.getCustomerName()); + bill.setCustomerCode(customerCode == null ? "" : customerCode); + bill.setCustomerName(customerName == null ? "" : customerName); + bill.setBillPeriodStart(request.getBillPeriodEnd()); bill.setBillPeriodEnd(request.getBillPeriodEnd()); - bill.setBillDate(request.getBillDate() == null ? new Date() : request.getBillDate()); + bill.setBillDate(request.getBillDate() == null ? now : request.getBillDate()); bill.setCurrency(currency); - bill.setOrderCount(sources.size()); - bill.setTotalAmount(total); + bill.setOrderCount(0); + bill.setTotalAmount(BigDecimal.ZERO); bill.setWrittenOffAmount(BigDecimal.ZERO); - bill.setUnwrittenAmount(total); + bill.setUnwrittenAmount(BigDecimal.ZERO); bill.setStatus(ArBillStatus.DRAFT.getCode()); + bill.setGenerationStatus(ArBillGenerationStatus.GENERATING); + bill.setBillVersion((maxVersion == null ? 0 : maxVersion) + 1); bill.setBankAccountName(bankAccount.getAccountName()); bill.setBankAccountNo(bankAccount.getAccountNo()); bill.setBankName(bankAccount.getBankName()); bill.setRemark(request.getRemark()); bill.setCreateBy(operator); bill.setUpdateBy(operator); - - List sourceBillIds = new ArrayList<>(); - for (PrcOrderBill source : sources) - { - sourceBillIds.add(source.getBillId()); - } - int claimedCount = sourceBillMapper.claimArBillOrders(sourceBillIds, operator, - ArOrderBillStatus.UNBILLED.getCode(), ArOrderBillStatus.BILLED.getCode()); - if (claimedCount != sourceBillIds.size()) - { - throw failure(null, ArLogObjectType.BILL, "", ArLogOperationType.GENERATE, null, - "部分计费订单已被其他账单占用,请重新查询后生成", operator, requestId); - } - - // 订单占用成功后再锁定版本号,避免并发生成时先查到同一个版本。 - Integer maxVersion = billMapper.selectMaxBillVersion(request.getCustomerId(), request.getBillPeriodEnd(), currency); - bill.setBillVersion((maxVersion == null ? 0 : maxVersion) + 1); - billMapper.insertArBill(bill); - List details = new ArrayList<>(); - int detailNo = 1; - for (PrcOrderBill source : sources) - { - details.add(toDetail(bill, source, detailNo++, operator)); + ArBillGenerateTask task = new ArBillGenerateTask(); + task.setTaskNo("AG" + new SimpleDateFormat("yyyyMMddHHmmss").format(now) + + IdUtils.fastSimpleUUID().substring(0, 8).toUpperCase()); + task.setArBillId(bill.getArBillId()); + task.setSelectionMode(selectionMode); + task.setRequestPayload(JSON.toJSONString(request)); + task.setSnapshotCreateTime(now); + task.setTotalCount(allMatched ? -1 : sourceBillIds.size()); + task.setProcessedCount(0); + task.setSuccessCount(0); + task.setTotalAmount(BigDecimal.ZERO); + task.setStatus(ArBillGenerateTaskStatus.WAITING); + task.setRequestToken(requestId); + task.setCreateBy(operator); + generateTaskMapper.insert(task); + eventPublisher.publishEvent(new ArBillGenerateTaskEvent(task.getTaskId())); + return task; + } + + /** + * 查询单个异步生成任务,供前端轮询进度和结果。 + */ + @Override + public ArBillGenerateTask selectGenerateTask(Long taskId) { + ArBillGenerateTask task = generateTaskMapper.selectById(taskId); + if (task == null) { + throw new ServiceException("账单生成任务不存在"); + } + return task; + } + + /** + * 查询当前用户仍处于等待、执行或取消中的生成任务。 + */ + @Override + public List selectActiveGenerateTasks(String operator) { + return generateTaskMapper.selectActiveByCreateBy(operator); + } + + /** + * 请求取消生成任务;执行中的任务先标记 CANCEL_REQUESTED,再由安全边界完成清理。 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public ArBillGenerateTask cancelGenerateTask(Long taskId, String operator, String requestId) { + ArBillGenerateTask task = selectGenerateTask(taskId); + if (!operator.equals(task.getCreateBy())) { + throw new ServiceException("只能取消自己创建的账单生成任务"); + } + if (ArBillGenerateTaskStatus.SUCCESS.equals(task.getStatus()) + || ArBillGenerateTaskStatus.FAILED.equals(task.getStatus()) + || ArBillGenerateTaskStatus.CANCELED.equals(task.getStatus())) { + return task; + } + if (ArBillGenerateTaskStatus.WAITING.equals(task.getStatus())) { + cleanupGenerateTask(task, "用户取消账单生成", true, operator, requestId); + } else { + generateTaskMapper.markCancelRequested(taskId); + } + return selectGenerateTask(taskId); + } + + /** + * 仅允许失败任务按原请求快照重新发起生成。 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public ArBillGenerateTask retryGenerateTask(Long taskId, String operator, String requestId) { + ArBillGenerateTask task = selectGenerateTask(taskId); + if (!operator.equals(task.getCreateBy())) { + throw new ServiceException("只能重新生成自己创建的账单任务"); + } + if (!ArBillGenerateTaskStatus.FAILED.equals(task.getStatus())) { + throw new ServiceException("只有失败的账单生成任务可以重试"); + } + ArBillGenerateRequest request = JSON.parseObject(task.getRequestPayload(), ArBillGenerateRequest.class); + request.setRequestId(requestId); + return generateBill(request, operator, requestId); + } + + /** + * 执行异步生成任务。每个批次使用短事务,事务内完成来源订单占用、明细落库、 + * 费用落库和任务游标更新;异常统一进入独立事务清理,避免任务永久卡在运行中。 + */ + @Override + public void processGenerateTask(Long taskId) { + try { + ArBillGenerateTask task = generateTaskMapper.selectById(taskId); + if (task == null || !ArBillGenerateTaskStatus.RUNNING.equals(task.getStatus())) { + log.warn("应收账单生成任务未执行,任务不存在或状态不是 RUNNING,taskId={}, status={}", + taskId, task == null ? null : task.getStatus()); + return; + } + log.info("开始执行应收账单生成任务,taskId={}, arBillId={}, selectionMode={}", + taskId, task.getArBillId(), task.getSelectionMode()); + ArBillGenerateRequest request = JSON.parseObject(task.getRequestPayload(), ArBillGenerateRequest.class); + List sourceBillIds = normalizeSourceBillIds(request.getSourceBillIds()); + List excludedSourceBillIds = normalizeSourceBillIds(request.getExcludedSourceBillIds()); + TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager); + transactionTemplate.setTimeout(300); + + if (task.getTotalCount() == null || task.getTotalCount() < 0) { + log.info("开始统计应收账单生成范围,taskId={}", taskId); + int totalCount = sourceBillMapper.countPrcOrderBillByCustomerPeriod( + request.getCustomerId(), request.getCustomerCode(), StringUtils.trim(request.getOrderOrTrackingNo()), + request.getBillPeriodEnd(), request.getCurrency(), ArOrderBillStatus.UNBILLED.getCode(), + task.getSnapshotCreateTime(), null, excludedSourceBillIds); + if (totalCount <= 0) { + throw new ServiceException("当前全选范围内没有可生成的订单"); + } + if (generateTaskMapper.updateTotalCount(taskId, totalCount) != 1) { + throw new ServiceException("账单生成任务状态已发生变化,请稍后重试"); + } + task.setTotalCount(totalCount); + log.info("应收账单生成范围统计完成,taskId={}, totalCount={}", taskId, totalCount); + } + + while (true) { + task = generateTaskMapper.selectById(taskId); + if (task == null) { + return; + } + if (ArBillGenerateTaskStatus.CANCEL_REQUESTED.equals(task.getStatus())) { + ArBillGenerateTask cancelTask = task; + transactionTemplate.execute(status -> { + cleanupGenerateTask(cancelTask, "用户取消账单生成", true, + cancelTask.getCreateBy(), cancelTask.getRequestToken()); + return null; + }); + return; + } + ArBillGenerateTask currentTask = task; + GenerateBatchResult result = transactionTemplate.execute(status -> processGenerateBatch( + currentTask, request, sourceBillIds, excludedSourceBillIds)); + if (result == null || result.finished) { + transactionTemplate.execute(status -> { + completeGenerateTask(taskId); + return null; + }); + log.info("应收账单生成任务执行完成,taskId={}", taskId); + return; + } + } + } catch (Exception e) { + String message = generateFailureMessage(e); + log.error("应收账单生成任务执行失败,taskId={}, message={}", taskId, message, e); + finishGenerateTaskSafely(taskId, message); + } + } + + /** + * 用数据库条件更新抢占 WAITING 任务,返回 true 才表示当前节点获得执行权。 + */ + @Override + public boolean claimGenerateTask(Long taskId) { + return generateTaskMapper.markRunning(taskId, new Date()) == 1; + } + + /** + * 在线程池拒绝或任务提交异常时,安全地结束并清理生成任务。 + */ + @Override + public void failGenerateTask(Long taskId, String remark) { + finishGenerateTaskSafely(taskId, remark); + } + + /** + * 将长时间无心跳的 RUNNING 任务恢复为 WAITING,供调度器重新投递。 + */ + @Override + public int recoverExpiredGenerateTasks(Date expiredBefore) { + return generateTaskMapper.resetExpiredRunning(expiredBefore); + } + + /** + * 清理长时间停留在 CANCEL_REQUESTED 的任务,避免取消请求永久悬挂。 + */ + @Override + public int recoverCancelRequestedGenerateTasks(Date expiredBefore) { + List tasks = generateTaskMapper.selectCancelRequestedBefore(expiredBefore, 10); + int recovered = 0; + for (ArBillGenerateTask task : tasks) { + try { + TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager); + transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + Boolean cleaned = transactionTemplate.execute(status -> { + ArBillGenerateTask current = generateTaskMapper.selectById(task.getTaskId()); + if (current == null || !ArBillGenerateTaskStatus.CANCEL_REQUESTED.equals(current.getStatus())) { + return Boolean.FALSE; + } + cleanupGenerateTask(current, "用户取消账单生成(超时自动清理)", true, + current.getCreateBy(), current.getRequestToken()); + return Boolean.TRUE; + }); + if (Boolean.TRUE.equals(cleaned)) { + recovered++; + log.warn("已自动清理超时的应收账单取消任务,taskId={}, arBillId={}", + task.getTaskId(), task.getArBillId()); + } + } catch (Exception e) { + log.error("自动清理应收账单取消任务失败,taskId={}", task.getTaskId(), e); + } + } + return recovered; + } + + /** + * 按创建顺序查询少量 WAITING 任务,供定时调度器投递。 + */ + @Override + public List selectWaitingGenerateTasks(int limit) { + return generateTaskMapper.selectWaiting(limit); + } + + /** + * 处理一批来源订单。ALL_MATCHED 使用 bill_id 游标推进,EXPLICIT 使用请求中的订单 ID 集合; + * 每批最多 GENERATE_PAGE_SIZE 条,成功后原子更新任务进度。 + */ + private GenerateBatchResult processGenerateBatch(ArBillGenerateTask task, ArBillGenerateRequest request, + List sourceBillIds, List excludedSourceBillIds) { + ArBillSelectionMode selectionMode = ArBillSelectionMode.of(task.getSelectionMode()); + if (selectionMode == null) { + throw new ServiceException("账单生成任务的订单选择模式无效"); + } + List explicitIds = selectionMode == ArBillSelectionMode.EXPLICIT + ? sourceBillIds : null; + List sources = sourceBillMapper.selectPrcOrderBillByCustomerPeriodCursor( + request.getCustomerId(), request.getCustomerCode(), StringUtils.trim(request.getOrderOrTrackingNo()), + request.getBillPeriodEnd(), StringUtils.isEmpty(request.getCurrency()) ? "CNY" : request.getCurrency(), + ArOrderBillStatus.UNBILLED.getCode(), task.getSnapshotCreateTime(), task.getLastSourceBillId(), + explicitIds, selectionMode == ArBillSelectionMode.ALL_MATCHED ? excludedSourceBillIds : null, + GENERATE_PAGE_SIZE); + if (sources == null || sources.isEmpty()) { + return new GenerateBatchResult(true); + } + + ArBill bill = billMapper.selectArBillById(task.getArBillId()); + if (bill == null) { + throw new ServiceException("应收账单不存在"); + } + List currentSourceBillIds = new ArrayList<>(); + for (PrcOrderBill source : sources) { + currentSourceBillIds.add(source.getBillId()); + if (source.getCurrency() != null && !bill.getCurrency().equalsIgnoreCase(source.getCurrency())) { + throw new ServiceException("截止日期内存在多个币种,不能合并为一张账单"); + } + } + int claimedCount = sourceBillMapper.claimArBillOrders(currentSourceBillIds, task.getCreateBy(), + ArOrderBillStatus.UNBILLED.getCode(), ArOrderBillStatus.BILLED.getCode()); + if (claimedCount != currentSourceBillIds.size()) { + throw new ServiceException("部分计费订单已被其他账单占用,请重新查询后生成"); + } + + int detailNo = (task.getProcessedCount() == null ? 0 : task.getProcessedCount()) + 1; + List details = new ArrayList<>(sources.size()); + for (PrcOrderBill source : sources) { + details.add(toDetail(bill, source, detailNo++, task.getCreateBy())); } detailMapper.insertBatch(details); - // Batch insert does not need to expose generated IDs to the caller. Re-read the - // persisted details once, then attach source fees in one batch query. Map detailsBySourceBill = new HashMap<>(); - for (ArBillDetail detail : detailMapper.selectByBillId(bill.getArBillId())) - { + for (ArBillDetail detail : detailMapper.selectByBillIdAndSourceIds(bill.getArBillId(), currentSourceBillIds)) { detailsBySourceBill.put(detail.getPrcOrderBillId(), detail); } - List detailFees = new ArrayList<>(); - List sourceFees = sourceBillIds.isEmpty() - ? new ArrayList() : sourceFeeMapper.selectByBillIds(sourceBillIds); - Map feeNumbers = new HashMap<>(); - for (PrcIncomeBillFee sourceFee : sourceFees) - { - ArBillDetail detail = detailsBySourceBill.get(sourceFee.getBillId()); - if (detail == null) - { - throw new ServiceException("来源费用找不到对应的账单明细:" + sourceFee.getBillId()); - } - int feeNo = feeNumbers.merge(sourceFee.getBillId(), 1, Integer::sum); - detailFees.add(toDetailFee(detail, sourceFee, feeNo, operator)); + if (detailsBySourceBill.size() != currentSourceBillIds.size()) { + throw new ServiceException("账单明细写入数量不一致"); } - if (!detailFees.isEmpty()) - { + + List sourceFees = sourceFeeMapper.selectByBillIds(currentSourceBillIds); + List detailFees = new ArrayList<>(sourceFees == null ? 0 : sourceFees.size()); + Map feeNumbers = new HashMap<>(); + if (sourceFees != null) { + for (PrcIncomeBillFee sourceFee : sourceFees) { + ArBillDetail detail = detailsBySourceBill.get(sourceFee.getBillId()); + if (detail == null) { + throw new ServiceException("来源费用找不到对应的账单明细:" + sourceFee.getBillId()); + } + int feeNo = feeNumbers.merge(sourceFee.getBillId(), 1, Integer::sum); + detailFees.add(toDetailFee(detail, sourceFee, feeNo, task.getCreateBy())); + } + } + if (!detailFees.isEmpty()) { detailFeeMapper.insertBatch(detailFees); } - recordSuccess(bill, ArLogObjectType.BILL, ArLogOperationType.CREATE, - "生成正式账单", null, ArBillStatus.DRAFT.getCode(), requestId, operator); - bill.setBankAccountNo(maskAccount(bill.getBankAccountNo())); - return bill; + + BigDecimal batchAmount = BigDecimal.ZERO; + for (PrcOrderBill source : sources) { + batchAmount = batchAmount.add(nvl(source.getIncomeTotal())); + } + int processedCount = (task.getProcessedCount() == null ? 0 : task.getProcessedCount()) + sources.size(); + BigDecimal totalAmount = nvl(task.getTotalAmount()).add(batchAmount); + Date periodStart = earliestBizDate(sources); + billMapper.updateArBillProgress(bill.getArBillId(), processedCount, totalAmount, totalAmount, + periodStart, task.getCreateBy()); + String lastSourceBillId = sources.get(sources.size() - 1).getBillId(); + if (generateTaskMapper.updateProgress(task.getTaskId(), lastSourceBillId, processedCount, + processedCount, totalAmount, new Date()) != 1) { + throw new ServiceException("账单生成任务状态已发生变化,请稍后重试"); + } + return new GenerateBatchResult(false); } + /** + * 释放来源订单占用、删除半成品明细,并将账单和任务置为失败或已取消。 + */ + private void cleanupGenerateTask(ArBillGenerateTask task, String message, boolean canceled, + String operator, String requestId) { + sourceBillMapper.releaseArBillOrdersByArBillId(task.getArBillId(), operator, + ArOrderBillStatus.BILLED.getCode(), ArOrderBillStatus.UNBILLED.getCode()); + detailFeeMapper.deleteByBillId(task.getArBillId()); + detailMapper.deleteByBillId(task.getArBillId()); + billMapper.updateArBillGenerationStatus(task.getArBillId(), ArBillGenerationStatus.FAILED, operator, message); + if (canceled) { + generateTaskMapper.markCanceled(task.getTaskId(), new Date()); + } else { + generateTaskMapper.markFailed(task.getTaskId(), message, new Date()); + try { + logService.recordFailure(task.getArBillId(), ArLogObjectType.BILL, + String.valueOf(task.getTaskId()), ArLogOperationType.CREATE, ArBillStatus.DRAFT.getCode(), + message, operator, requestId); + } catch (Exception logException) { + log.warn("账单生成失败日志写入失败,但任务清理已完成,taskId={}", task.getTaskId(), logException); + } + } + } + + /** + * 任务线程异常时,使用独立事务执行清理,避免异常逃逸后任务永久停留在 RUNNING/CANCEL_REQUESTED。 + * 清理失败时保留当前任务状态,交由定时恢复再次尝试,避免账单明细和来源订单状态不一致。 + */ + private void finishGenerateTaskSafely(Long taskId, String message) { + try { + TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager); + transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + transactionTemplate.execute(status -> { + ArBillGenerateTask current = generateTaskMapper.selectById(taskId); + if (current == null || ArBillGenerateTaskStatus.SUCCESS.equals(current.getStatus()) + || ArBillGenerateTaskStatus.FAILED.equals(current.getStatus()) + || ArBillGenerateTaskStatus.CANCELED.equals(current.getStatus())) { + return null; + } + boolean canceled = ArBillGenerateTaskStatus.CANCEL_REQUESTED.equals(current.getStatus()); + cleanupGenerateTask(current, message, canceled, current.getCreateBy(), current.getRequestToken()); + return null; + }); + } catch (Exception cleanupException) { + log.error("应收账单生成任务异常清理失败,将由定时任务重试,taskId={}, message={}", + taskId, message, cleanupException); + } + } + + /** + * 校验批次结果完整后,将账单生成状态置为 READY、任务置为 SUCCESS。 + */ + private void completeGenerateTask(Long taskId) { + ArBillGenerateTask task = generateTaskMapper.selectById(taskId); + if (task == null) { + throw new ServiceException("账单生成任务不存在"); + } + if (task.getSuccessCount() == null || task.getSuccessCount() == 0 + || (task.getTotalCount() != null && task.getTotalCount() >= 0 + && task.getSuccessCount() < task.getTotalCount())) { + throw new ServiceException("账单生成结果为空或订单数量发生变化,请重新生成"); + } + ArBill finalBill = billMapper.selectArBillById(task.getArBillId()); + billMapper.updateArBillGenerationStatus(task.getArBillId(), + ArBillGenerationStatus.READY, task.getCreateBy(), null); + if (generateTaskMapper.markSuccess(taskId, new Date()) != 1) { + throw new ServiceException("账单生成任务状态已发生变化,请稍后重试"); + } + recordSuccess(finalBill, ArLogObjectType.BILL, ArLogOperationType.CREATE, + "异步生成正式账单", ArBillStatus.DRAFT.getCode(), ArBillStatus.DRAFT.getCode(), + task.getRequestToken(), task.getCreateBy()); + } + + private String generateFailureMessage(Exception e) { + String message = StringUtils.isEmpty(e.getMessage()) ? "账单生成失败" : e.getMessage(); + return message.length() > 1000 ? message.substring(0, 1000) : message; + } + + private static class GenerateBatchResult { + private final boolean finished; + + private GenerateBatchResult(boolean finished) { + this.finished = finished; + } + } + + /** + * 确认已完成生成且明细金额校验通过的草稿账单。 + */ @Override @Transactional(rollbackFor = Exception.class) - public ArBill confirmBill(Long arBillId, ArBillActionRequest request, String operator, String requestId) - { + public ArBill confirmBill(Long arBillId, ArBillActionRequest request, String operator, String requestId) { ArBill bill = billMapper.selectArBillByIdForUpdate(arBillId); - if (bill == null) - { + if (bill == null) { throw failure(arBillId, ArLogObjectType.BILL, String.valueOf(arBillId), ArLogOperationType.CONFIRM, - null, "应收账单不存在", operator, requestId); + null, "应收账单不存在", operator, requestId); + } + if (!ArBillGenerationStatus.READY.equals(bill.getGenerationStatus())) { + throw failure(arBillId, ArLogObjectType.BILL, String.valueOf(arBillId), ArLogOperationType.CONFIRM, + bill.getStatus(), "账单尚未生成完成,暂不能确认", operator, requestId); } ArBillStatus currentStatus = ArBillStatus.of(bill.getStatus()); - if (currentStatus == null || !currentStatus.canConfirm()) - { + if (currentStatus == null || !currentStatus.canConfirm()) { throw failure(arBillId, ArLogObjectType.BILL, String.valueOf(arBillId), ArLogOperationType.CONFIRM, - bill.getStatus(), - "只有草稿账单可以确认,当前状态为 " + bill.getStatus(), operator, requestId); + bill.getStatus(), + "只有草稿账单可以确认,当前状态为 " + bill.getStatus(), operator, requestId); } - if (!validateDetails(arBillId, bill.getTotalAmount())) - { + if (!validateDetails(arBillId, bill.getTotalAmount())) { throw failure(arBillId, ArLogObjectType.BILL, String.valueOf(arBillId), ArLogOperationType.CONFIRM, - bill.getStatus(), - "账单费用明细汇总与订单或账单总额不一致", operator, requestId); + bill.getStatus(), + "账单费用明细汇总与订单或账单总额不一致", operator, requestId); } if (billMapper.updateArBillStatus(arBillId, ArBillStatus.CONFIRMED.getCode(), - StringUtils.isEmpty(request == null ? null : request.getRemark()) ? bill.getRemark() : request.getRemark(), - operator, ArBillStatus.DRAFT.getCode()) != 1) - { + StringUtils.isEmpty(request == null ? null : request.getRemark()) ? bill.getRemark() : request.getRemark(), + operator, ArBillStatus.DRAFT.getCode()) != 1) { throw failure(arBillId, ArLogObjectType.BILL, String.valueOf(arBillId), ArLogOperationType.CONFIRM, - bill.getStatus(), - "账单状态已发生变化,请刷新后重试", operator, requestId); + bill.getStatus(), + "账单状态已发生变化,请刷新后重试", operator, requestId); } // 应收账单只负责形成应收,不参与客户余额或授信额度变更。 recordSuccess(bill, ArLogObjectType.BILL, ArLogOperationType.CONFIRM, - "确认应收账单", ArBillStatus.DRAFT.getCode(), ArBillStatus.CONFIRMED.getCode(), requestId, operator); + "确认应收账单", ArBillStatus.DRAFT.getCode(), ArBillStatus.CONFIRMED.getCode(), requestId, operator); return selectArBillById(arBillId); } + /** + * 作废账单并释放来源订单;明细不删除,用于保留历史快照和审计依据。 + */ @Override @Transactional(rollbackFor = Exception.class) - public ArBill voidBill(Long arBillId, ArBillActionRequest request, String operator, String requestId) - { + public ArBill voidBill(Long arBillId, ArBillActionRequest request, String operator, String requestId) { String remark = request == null ? null : request.getRemark(); - if (StringUtils.isEmpty(remark)) - { + if (StringUtils.isEmpty(remark)) { throw failure(arBillId, ArLogObjectType.BILL, String.valueOf(arBillId), ArLogOperationType.VOID, - null, "作废必须填写原因", operator, requestId); + null, "作废必须填写原因", operator, requestId); } ArBill bill = billMapper.selectArBillByIdForUpdate(arBillId); - if (bill == null) - { + if (bill == null) { throw failure(arBillId, ArLogObjectType.BILL, String.valueOf(arBillId), ArLogOperationType.VOID, - null, "应收账单不存在", operator, requestId); + null, "应收账单不存在", operator, requestId); + } + if (!ArBillGenerationStatus.READY.equals(bill.getGenerationStatus())) { + throw failure(arBillId, ArLogObjectType.BILL, String.valueOf(arBillId), ArLogOperationType.VOID, + bill.getStatus(), "账单尚未生成完成,暂不能作废", operator, requestId); } ArBillStatus currentStatus = ArBillStatus.of(bill.getStatus()); - if (currentStatus == null || !currentStatus.canVoid()) - { + if (currentStatus == null || !currentStatus.canVoid()) { throw failure(arBillId, ArLogObjectType.BILL, String.valueOf(arBillId), ArLogOperationType.VOID, - bill.getStatus(), - "当前状态不允许作废", operator, requestId); + bill.getStatus(), + "当前状态不允许作废", operator, requestId); } List sourceBillIds = new ArrayList<>(); - for (ArBillDetail detail : detailMapper.selectByBillId(arBillId)) - { - if (!StringUtils.isEmpty(detail.getPrcOrderBillId())) - { + for (ArBillDetail detail : detailMapper.selectByBillId(arBillId)) { + if (!StringUtils.isEmpty(detail.getPrcOrderBillId())) { sourceBillIds.add(detail.getPrcOrderBillId()); } } - if (billMapper.updateArBillStatus(arBillId, ArBillStatus.VOID.getCode(), remark, operator, currentStatus.getCode()) != 1) - { + if (billMapper.updateArBillStatus(arBillId, ArBillStatus.VOID.getCode(), remark, operator, currentStatus.getCode()) != 1) { throw failure(arBillId, ArLogObjectType.BILL, String.valueOf(arBillId), ArLogOperationType.VOID, - bill.getStatus(), - "账单状态已发生变化,请刷新后重试", operator, requestId); - } - if (!sourceBillIds.isEmpty()) - { - int releasedCount = sourceBillMapper.releaseArBillOrders(sourceBillIds, operator, - ArOrderBillStatus.BILLED.getCode(), ArOrderBillStatus.UNBILLED.getCode()); - if (releasedCount != sourceBillIds.size()) - { - throw failure(arBillId, ArLogObjectType.BILL, String.valueOf(arBillId), ArLogOperationType.VOID, bill.getStatus(), - "部分来源订单状态异常,账单未作废,请刷新后重试", operator, requestId); + "账单状态已发生变化,请刷新后重试", operator, requestId); + } + if (!sourceBillIds.isEmpty()) { + int releasedCount = sourceBillMapper.releaseArBillOrders(sourceBillIds, operator, + ArOrderBillStatus.BILLED.getCode(), ArOrderBillStatus.UNBILLED.getCode()); + if (releasedCount != sourceBillIds.size()) { + throw failure(arBillId, ArLogObjectType.BILL, String.valueOf(arBillId), ArLogOperationType.VOID, + bill.getStatus(), + "部分来源订单状态异常,账单未作废,请刷新后重试", operator, requestId); } } recordSuccess(bill, ArLogObjectType.BILL, ArLogOperationType.VOID, - "作废应收账单:" + remark, bill.getStatus(), ArBillStatus.VOID.getCode(), requestId, operator); + "作废应收账单:" + remark, bill.getStatus(), ArBillStatus.VOID.getCode(), requestId, operator); return selectArBillById(arBillId); } + /** + * 创建异步导出任务;模板版本在提交时绑定,保证后续模板变更不影响历史导出。 + */ @Override - public ArBillExport exportBill(Long arBillId, String operator, String requestId) - { + @Transactional(rollbackFor = Exception.class) + public ArBillExport exportBill(Long arBillId, String operator, String requestId) { ArBillLog previous = logMapper.selectSuccessByRequestId(requestId, ArLogObjectType.EXPORT.getCode(), - ArLogResult.SUCCESS.getCode()); - if (previous != null && isNumber(previous.getObjectId())) - { + ArLogResult.SUCCESS.getCode()); + if (previous != null && isNumber(previous.getObjectId())) { ArBillExport previousExport = exportMapper.selectById(Long.valueOf(previous.getObjectId())); - if (previousExport != null) - { + if (previousExport != null) { return previousExport; } } ArBill bill = billMapper.selectArBillById(arBillId); - if (bill == null) - { + if (bill == null) { throw failure(arBillId, ArLogObjectType.EXPORT, String.valueOf(arBillId), ArLogOperationType.EXPORT, - null, "应收账单不存在", operator, requestId); + null, "应收账单不存在", operator, requestId); } - if (ArBillStatus.is(bill.getStatus(), ArBillStatus.DRAFT) || ArBillStatus.is(bill.getStatus(), ArBillStatus.VOID)) - { + if (!ArBillGenerationStatus.READY.equals(bill.getGenerationStatus())) { throw failure(arBillId, ArLogObjectType.EXPORT, String.valueOf(arBillId), ArLogOperationType.EXPORT, - bill.getStatus(), - "草稿或作废账单不可导出", operator, requestId); + bill.getStatus(), "账单尚未生成完成,暂不能导出", operator, requestId); + } + if (ArBillStatus.is(bill.getStatus(), ArBillStatus.DRAFT) || ArBillStatus.is(bill.getStatus(), ArBillStatus.VOID)) { + throw failure(arBillId, ArLogObjectType.EXPORT, String.valueOf(arBillId), ArLogOperationType.EXPORT, + bill.getStatus(), + "草稿或作废账单不可导出", operator, requestId); + } + ArBillExport processing = exportMapper.selectProcessingByBillId(arBillId); + if (processing != null) { + return processing; + } + ArExportTemplate template = exportTemplateMapper.selectDefaultEnabled(EXPORT_TEMPLATE_BUSINESS_TYPE, + bill.getBillDate() == null ? bill.getBillPeriodEnd() : bill.getBillDate()); + if (template == null) { + throw failure(arBillId, ArLogObjectType.EXPORT, String.valueOf(arBillId), ArLogOperationType.EXPORT, + bill.getStatus(), "未配置启用中的应收账单默认导出模板", operator, requestId); } - fillDetails(bill); String fileName = bill.getBillNo() + "_" + safeFilePart(bill.getCustomerName()) + "_" - + new SimpleDateFormat("yyyyMM").format(bill.getBillPeriodEnd()) + ".xlsx"; + + new SimpleDateFormat("yyyyMM").format(bill.getBillPeriodEnd()) + ".xlsx"; ArBillExport export = new ArBillExport(); export.setArBillId(arBillId); export.setFileName(fileName); export.setFileType("XLSX"); - export.setTemplateCode("AR_CUSTOMER_BILL"); - export.setTemplateVersion("1.0"); + export.setTemplateId(template.getTemplateId()); + export.setTemplateCode(template.getTemplateCode()); + export.setTemplateVersion(template.getTemplateVersion()); + export.setTemplateStorageType(template.getStorageType()); + export.setTemplateStorageKey(template.getStorageKey()); + export.setTemplateHash(template.getFileHash()); export.setExportBy(operator); export.setRowCount(bill.getOrderCount()); - try - { - byte[] data = buildWorkbook(bill); + export.setStorageKey(""); + export.setExportStatus(ArExportStatus.PROCESSING.getCode()); + exportMapper.insert(export); + recordSuccess(bill, ArLogObjectType.EXPORT, ArLogOperationType.EXPORT, + "已提交账单导出任务", bill.getStatus(), bill.getStatus(), requestId, operator, export.getExportId()); + eventPublisher.publishEvent(new ArBillExportTaskEvent(export.getExportId())); + return export; + } + + /** + * 查询单个异步导出任务,供前端轮询状态。 + */ + @Override + public ArBillExport selectExport(Long exportId) { + ArBillExport export = exportMapper.selectById(exportId); + if (export == null) { + throw new ServiceException("导出任务不存在"); + } + return export; + } + + /** + * 执行异步导出任务:读取模板、流式拼接明细并原子移动临时文件。 + */ + @Override + public void processExportTask(Long exportId) { + ArBillExport export = exportMapper.selectById(exportId); + if (export == null || !ArExportStatus.PROCESSING.getCode().equals(export.getExportStatus())) { + return; + } + + ArBill bill = null; + try { + bill = billMapper.selectArBillById(export.getArBillId()); + if (bill == null) { + throw new ServiceException("应收账单不存在"); + } + if (ArBillStatus.is(bill.getStatus(), ArBillStatus.DRAFT) || ArBillStatus.is(bill.getStatus(), ArBillStatus.VOID)) { + throw new ServiceException("草稿或作废账单不可导出"); + } String relative = "ar/" + DateUtils.datePath() + "/" + IdUtils.fastSimpleUUID() + ".xlsx"; String downloadDir = RuoYiConfig.getProfile() + File.separator + "download"; File file = FileUploadUtils.getAbsoluteFile(downloadDir, relative); - Files.write(file.toPath(), data); + Path target = file.toPath(); + Path temp = Files.createTempFile(target.getParent(), ".ar-bill-export-", ".xlsx.tmp"); + boolean moved = false; + try { + ArExportTemplate template = resolveExportTemplate(export, bill); + writeOoxmlWorkbook(bill, template, temp); + moveToTarget(temp, target); + moved = true; + } finally { + if (!moved) { + Files.deleteIfExists(temp); + } + } export.setStorageKey(FileUploadUtils.getPathFileName(downloadDir, relative)); - export.setFileSize((long) data.length); - export.setFileHash(sha256(data)); - export.setExportStatus(ArExportStatus.SUCCESS.getCode()); - exportMapper.insert(export); - recordSuccess(bill, ArLogObjectType.EXPORT, ArLogOperationType.EXPORT, - "导出账单 XLSX", bill.getStatus(), bill.getStatus(), requestId, operator, export.getExportId()); - return export; - } - catch (Exception e) - { - export.setStorageKey(""); - export.setExportStatus(ArExportStatus.FAILED.getCode()); - export.setRemark(e.getMessage()); - exportMapper.insert(export); - logService.recordFailure(arBillId, ArLogObjectType.EXPORT, String.valueOf(arBillId), ArLogOperationType.EXPORT, - bill.getStatus(), - e.getMessage(), operator, requestId); - throw new ServiceException("账单导出失败:" + e.getMessage()); + export.setFileSize(Files.size(target)); + export.setFileHash(sha256(target)); + if (exportMapper.markSuccess(export) == 0) { + Files.deleteIfExists(target); + } + } catch (Exception e) { + String message = exportFailureMessage(e); + if (exportMapper.markFailed(exportId, message) > 0) { + logService.recordFailure(export.getArBillId(), ArLogObjectType.EXPORT, String.valueOf(exportId), + ArLogOperationType.EXPORT, bill == null ? null : bill.getStatus(), message, export.getExportBy(), null); + } } } @Override - public List selectExports(Long arBillId) - { + public int failExpiredExportTasks(Date expiredBefore) { + return exportMapper.markExpiredProcessingAsFailed(expiredBefore, "导出任务超时,请重新发起导出"); + } + + @Override + public void failExportTask(Long exportId, String remark) { + exportMapper.markFailed(exportId, remark); + } + + @Override + public List selectExports(Long arBillId) { return exportMapper.selectByBillId(arBillId); } @Override - public void downloadExport(Long exportId, HttpServletResponse response) throws Exception - { + public void downloadExport(Long exportId, HttpServletResponse response) throws Exception { ArBillExport export = exportMapper.selectById(exportId); - if (export == null || !ArExportStatus.SUCCESS.getCode().equals(export.getExportStatus()) || StringUtils.isEmpty(export.getStorageKey())) - { + if (export == null || !ArExportStatus.SUCCESS.getCode().equals(export.getExportStatus()) || StringUtils.isEmpty(export.getStorageKey())) { throw new ServiceException("导出文件不存在或尚未生成成功"); } String resource = export.getStorageKey(); - if (!FileUtils.checkAllowDownload(resource)) - { + if (!FileUtils.checkAllowDownload(resource)) { throw new ServiceException("导出文件路径非法"); } response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); @@ -437,42 +948,28 @@ public class ArBillServiceImpl implements IArBillService } @Override - public List selectLogList(ArBillLog query) - { + public List selectLogList(ArBillLog query) { return logService.selectLogList(query); } - private void fillDetails(ArBill bill) - { - List details = detailMapper.selectByBillIdWithFees(bill.getArBillId()); - for (ArBillDetail detail : details) - { - if (detail.getFees() == null) - { - detail.setFees(new ArrayList<>()); - } - } - bill.setDetails(details); + private String exportFailureMessage(Exception e) { + String message = StringUtils.isEmpty(e.getMessage()) ? "账单导出失败" : e.getMessage(); + return message.length() > 500 ? message.substring(0, 500) : message; } - private boolean validateDetails(Long arBillId, BigDecimal billTotal) - { + private boolean validateDetails(Long arBillId, BigDecimal billTotal) { List details = detailMapper.selectByBillIdWithFees(arBillId); BigDecimal detailTotal = BigDecimal.ZERO; - for (ArBillDetail detail : details) - { + for (ArBillDetail detail : details) { BigDecimal feeTotal = BigDecimal.ZERO; List fees = detail.getFees(); - if (fees == null) - { + if (fees == null) { fees = new ArrayList(); } - for (ArBillDetailFee fee : fees) - { + for (ArBillDetailFee fee : fees) { feeTotal = feeTotal.add(nvl(fee.getAmount())); } - if (feeTotal.compareTo(nvl(detail.getTotalAmount())) != 0) - { + if (feeTotal.compareTo(nvl(detail.getTotalAmount())) != 0) { return false; } detailTotal = detailTotal.add(nvl(detail.getTotalAmount())); @@ -480,8 +977,7 @@ public class ArBillServiceImpl implements IArBillService return !details.isEmpty() && detailTotal.compareTo(nvl(billTotal)) == 0; } - private ArBillDetail toDetail(ArBill bill, PrcOrderBill source, int detailNo, String operator) - { + private ArBillDetail toDetail(ArBill bill, PrcOrderBill source, int detailNo, String operator) { ArBillDetail detail = new ArBillDetail(); detail.setArBillId(bill.getArBillId()); detail.setPrcOrderBillId(source.getBillId()); @@ -508,8 +1004,7 @@ public class ArBillServiceImpl implements IArBillService return detail; } - private ArBillDetailFee toDetailFee(ArBillDetail detail, PrcIncomeBillFee source, int feeNo, String operator) - { + private ArBillDetailFee toDetailFee(ArBillDetail detail, PrcIncomeBillFee source, int feeNo, String operator) { ArBillDetailFee fee = new ArBillDetailFee(); fee.setDetailId(detail.getDetailId()); fee.setPrcIncomeBillFeeId(source.getFeeId()); @@ -526,14 +1021,12 @@ public class ArBillServiceImpl implements IArBillService } private void recordSuccess(ArBill bill, ArLogObjectType objectType, ArLogOperationType operationType, String desc, String fromStatus, - String toStatus, String requestId, String operator) - { + String toStatus, String requestId, String operator) { recordSuccess(bill, objectType, operationType, desc, fromStatus, toStatus, requestId, operator, null); } private void recordSuccess(ArBill bill, ArLogObjectType objectType, ArLogOperationType operationType, String desc, String fromStatus, - String toStatus, String requestId, String operator, Long objectId) - { + String toStatus, String requestId, String operator, Long objectId) { ArBillLog log = new ArBillLog(); log.setArBillId(bill.getArBillId()); log.setObjectType(objectType.getCode()); @@ -553,217 +1046,456 @@ public class ArBillServiceImpl implements IArBillService } private ServiceException failure(Long arBillId, ArLogObjectType objectType, String objectId, ArLogOperationType operationType, - String fromStatus, String message, String operator, String requestId) - { + String fromStatus, String message, String operator, String requestId) { logService.recordFailure(arBillId, objectType, objectId, operationType, fromStatus, message, operator, requestId); return new ServiceException(message); } - private byte[] buildWorkbook(ArBill bill) throws IOException - { - ClassPathResource resource = new ClassPathResource(EXPORT_TEMPLATE_RESOURCE); - if (!resource.exists()) - { - throw new IOException("应收账单导出模板不存在: " + EXPORT_TEMPLATE_RESOURCE); + /** + * 优先使用任务快照中的模板,兼容旧任务时再回退到当前启用的默认模板。 + */ + private ArExportTemplate resolveExportTemplate(ArBillExport export, ArBill bill) { + if (!StringUtils.isEmpty(export.getTemplateStorageKey())) { + ArExportTemplate template = new ArExportTemplate(); + template.setTemplateId(export.getTemplateId()); + template.setTemplateCode(export.getTemplateCode()); + template.setTemplateVersion(export.getTemplateVersion()); + template.setStorageType(export.getTemplateStorageType()); + template.setStorageKey(export.getTemplateStorageKey()); + template.setFileHash(export.getTemplateHash()); + return template; } - try (InputStream input = resource.getInputStream(); XSSFWorkbook workbook = new XSSFWorkbook(input)) - { - Sheet sheet = workbook.getSheet("8.31"); - if (sheet == null) - { - sheet = workbook.getSheetAt(0); - } - Row templateDataRow = sheet.getRow(TEMPLATE_DATA_START_ROW); - List details = bill.getDetails() == null ? new ArrayList() : bill.getDetails(); - CellStyle bodyStyle = createTemplateBodyStyle(workbook); - int footerStartRow = TEMPLATE_FOOTER_START_ROW + Math.max(details.size() - 1, 0); - int footerShift = footerStartRow - TEMPLATE_FOOTER_START_ROW; - if (footerShift != 0) - { - sheet.shiftRows(TEMPLATE_FOOTER_START_ROW, sheet.getLastRowNum(), footerShift); - } + // 兼容模板管理表上线前遗留的处理中任务,使用当前默认模板完成任务。 + ArExportTemplate template = exportTemplateMapper.selectDefaultEnabled(EXPORT_TEMPLATE_BUSINESS_TYPE, + bill.getBillDate() == null ? bill.getBillPeriodEnd() : bill.getBillDate()); + if (template == null) { + throw new ServiceException("导出任务未绑定模板,且当前没有可用的应收账单默认模板"); + } + return template; + } - set(sheet, 0, 0, StringUtils.isEmpty(bill.getBankAccountName()) ? "威狮国际供应链(深圳)有限公司" : bill.getBankAccountName()); - set(sheet, 2, 2, bill.getCustomerName()); - set(sheet, 2, 15, exportPeriod(bill)); - - for (int i = 0; i < details.size(); i++) - { - Row row = sheet.getRow(TEMPLATE_DATA_START_ROW + i); - if (row == null) - { - row = sheet.createRow(TEMPLATE_DATA_START_ROW + i); - copyRowStyle(templateDataRow, row); + /** + * 复制模板 ZIP 包,仅替换 sheet1.xml,保持模板样式、合并单元格和其他资源不变。 + */ + private void writeOoxmlWorkbook(ArBill bill, ArExportTemplate template, Path target) throws IOException { + try (InputStream input = openTemplateStream(template); ZipInputStream templateZip = new ZipInputStream(input); + OutputStream fileOutput = Files.newOutputStream(target); ZipOutputStream output = new ZipOutputStream(fileOutput)) { + boolean replaced = false; + for (ZipEntry entry; (entry = templateZip.getNextEntry()) != null; ) { + ZipEntry outputEntry = new ZipEntry(entry.getName()); + output.putNextEntry(outputEntry); + if (TEMPLATE_SHEET_XML.equals(entry.getName())) { + writeOoxmlSheet(readUtf8(templateZip), bill, output); + replaced = true; + } else if (!entry.isDirectory()) { + templateZip.transferTo(output); } - writeTemplateDetail(row, details.get(i), bodyStyle); + output.closeEntry(); + templateZip.closeEntry(); } - - int bankRow = footerStartRow; - set(sheet, bankRow, 1, "银行信息"); - set(sheet, bankRow + 1, 1, "开户名"); - set(sheet, bankRow + 1, 2, bill.getBankAccountName()); - set(sheet, bankRow + 2, 1, "账号"); - set(sheet, bankRow + 2, 2, exportBankAccountNo(bill.getBankAccountNo())); - set(sheet, bankRow + 3, 1, "开户行"); - set(sheet, bankRow + 3, 2, bill.getBankName()); - - set(sheet, bankRow, 15, "本期应收金额"); - set(sheet, bankRow, 18, bill.getTotalAmount()); - set(sheet, bankRow + 1, 15, "本期已核销金额"); - set(sheet, bankRow + 1, 18, bill.getWrittenOffAmount()); - set(sheet, bankRow + 2, 15, "本期未核销金额"); - set(sheet, bankRow + 2, 18, bill.getUnwrittenAmount()); - return toBytes(workbook); - } - } - - private byte[] toBytes(XSSFWorkbook workbook) throws IOException - { - java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); - workbook.write(out); - return out.toByteArray(); - } - - private void set(Row row, int index, Object value) - { - Cell cell = row.getCell(index, Row.MissingCellPolicy.CREATE_NULL_AS_BLANK); - cell.setCellValue(""); - if (value == null) - { - return; - } - if (value instanceof Number) - { - cell.setCellValue(((Number) value).doubleValue()); - } - else - { - cell.setCellValue(String.valueOf(value)); - } - } - - private void set(Sheet sheet, int rowIndex, int columnIndex, Object value) - { - Row row = sheet.getRow(rowIndex); - if (row == null) - { - row = sheet.createRow(rowIndex); - } - set(row, columnIndex, value); - } - - private void copyRowStyle(Row source, Row target) - { - if (source == null) - { - return; - } - target.setHeight(source.getHeight()); - for (int index = 0; index < TEMPLATE_COLUMN_COUNT; index++) - { - Cell sourceCell = source.getCell(index); - if (sourceCell != null) - { - target.getCell(index, Row.MissingCellPolicy.CREATE_NULL_AS_BLANK).setCellStyle(sourceCell.getCellStyle()); + if (!replaced) { + throw new IOException("应收账单导出模板工作表不存在: " + TEMPLATE_SHEET_XML); } } } - private CellStyle createTemplateBodyStyle(XSSFWorkbook workbook) - { - CellStyle style = workbook.createCellStyle(); - Font font = workbook.createFont(); - font.setFontName("微软雅黑"); - font.setFontHeightInPoints((short) 10); - style.setFont(font); - style.setAlignment(HorizontalAlignment.CENTER); - style.setVerticalAlignment(VerticalAlignment.CENTER); - style.setWrapText(true); - style.setBorderBottom(BorderStyle.THIN); - style.setBorderTop(BorderStyle.THIN); - style.setBorderLeft(BorderStyle.THIN); - style.setBorderRight(BorderStyle.THIN); - return style; + private InputStream openTemplateStream(ArExportTemplate template) throws IOException { + String storageType = StringUtils.isEmpty(template.getStorageType()) ? "CLASSPATH" : template.getStorageType(); + String storageKey = StringUtils.isEmpty(template.getStorageKey()) ? EXPORT_TEMPLATE_RESOURCE : template.getStorageKey(); + if ("CLASSPATH".equalsIgnoreCase(storageType)) { + ClassPathResource resource = new ClassPathResource(storageKey); + if (!resource.exists()) { + throw new IOException("应收账单导出模板不存在: " + storageKey); + } + return resource.getInputStream(); + } + if ("FILE".equalsIgnoreCase(storageType)) { + if (!FileUtils.checkAllowDownload(storageKey)) { + throw new IOException("应收账单模板路径非法"); + } + Path profileRoot = new File(RuoYiConfig.getProfile()).toPath().toAbsolutePath().normalize(); + Path templatePath = profileRoot.resolve(storageKey).normalize(); + if (!templatePath.startsWith(profileRoot) || !Files.exists(templatePath)) { + throw new IOException("应收账单导出模板不存在: " + storageKey); + } + return Files.newInputStream(templatePath); + } + throw new IOException("不支持的应收账单模板存储类型: " + storageType); } - private void writeTemplateDetail(Row row, ArBillDetail detail, CellStyle bodyStyle) - { - row.setHeightInPoints(24); + /** + * 以 XML 流方式写入表头、分页明细和银行信息,避免 POI 在大数据量下移动行。 + */ + private void writeOoxmlSheet(String templateXml, ArBill bill, OutputStream output) throws IOException { + int sheetDataStart = templateXml.indexOf("', sheetDataStart); + int sheetDataEnd = templateXml.indexOf("", sheetDataOpenEnd); + if (sheetDataStart < 0 || sheetDataOpenEnd < 0 || sheetDataEnd < 0) { + throw new IOException("应收账单导出模板缺少 sheetData 节点"); + } + + Map templateRows = extractTemplateRows(templateXml.substring(sheetDataOpenEnd + 1, sheetDataEnd)); + String prefix = removeSheetDimension(templateXml.substring(0, sheetDataOpenEnd + 1)); + writeUtf8(output, prefix); + + String headerRow = replaceCellXml(templateRows.get(1), "A1", + StringUtils.isEmpty(bill.getBankAccountName()) ? "威狮国际供应链(深圳)有限公司" : bill.getBankAccountName(), false); + writeUtf8(output, headerRow); + writeUtf8(output, templateRows.get(2)); + String customerRow = replaceCellXml(templateRows.get(3), "C3", bill.getCustomerName(), false); + // 账单所属期区域为 O3:T3 合并单元格,必须写入左上角 O3 才能显示。 + customerRow = replaceCellXml(customerRow, "O3", exportPeriod(bill), false); + writeUtf8(output, customerRow); + writeUtf8(output, templateRows.get(4)); + + int rowIndex = TEMPLATE_DATA_START_ROW; + int lastDetailNo = 0; + while (true) { + List details = detailMapper.selectExportPage(bill.getArBillId(), lastDetailNo, EXPORT_PAGE_SIZE); + if (details.isEmpty()) { + break; + } + attachFees(details); + for (ArBillDetail detail : details) { + writeOoxmlDetailRow(output, rowIndex++, detail); + } + lastDetailNo = details.get(details.size() - 1).getDetailNo(); + } + + int footerRow = Math.max(rowIndex, TEMPLATE_FOOTER_START_ROW); + writeOoxmlFooter(output, templateRows, footerRow, bill); + String suffix = shiftFooterMerges(templateXml.substring(sheetDataEnd), footerRow - TEMPLATE_FOOTER_START_ROW); + writeUtf8(output, suffix); + } + + private Map extractTemplateRows(String sheetData) { + Map rows = new HashMap<>(); + Matcher matcher = TEMPLATE_ROW_PATTERN.matcher(sheetData); + while (matcher.find()) { + rows.put(Integer.valueOf(matcher.group(1)), matcher.group()); + } + return rows; + } + + private void writeOoxmlFooter(OutputStream output, Map templateRows, int footerRow, ArBill bill) + throws IOException { + int rowOffset = footerRow - TEMPLATE_FOOTER_START_ROW; + for (int offset = 0; offset < 4; offset++) { + int sourceRowNumber = TEMPLATE_FOOTER_START_ROW + offset + 1; + int targetRowNumber = sourceRowNumber + rowOffset; + String row = shiftRowXml(templateRows.get(sourceRowNumber), targetRowNumber); + if (offset == 0) { + row = replaceCellXml(row, "B" + targetRowNumber, "银行信息", false); + row = replaceCellXml(row, "P" + targetRowNumber, "本期应收金额", false); + row = replaceCellXml(row, "S" + targetRowNumber, bill.getTotalAmount(), true); + } else if (offset == 1) { + row = replaceCellXml(row, "B" + targetRowNumber, "开户名", false); + row = replaceCellXml(row, "C" + targetRowNumber, bill.getBankAccountName(), false); + row = replaceCellXml(row, "P" + targetRowNumber, "本期已核销金额", false); + row = replaceCellXml(row, "S" + targetRowNumber, bill.getWrittenOffAmount(), true); + } else if (offset == 2) { + row = replaceCellXml(row, "B" + targetRowNumber, "账号", false); + row = replaceCellXml(row, "C" + targetRowNumber, exportBankAccountNo(bill.getBankAccountNo()), false); + row = replaceCellXml(row, "P" + targetRowNumber, "本期未核销金额", false); + row = replaceCellXml(row, "S" + targetRowNumber, bill.getUnwrittenAmount(), true); + } else { + row = replaceCellXml(row, "B" + targetRowNumber, "开户行", false); + row = replaceCellXml(row, "C" + targetRowNumber, bill.getBankName(), false); + } + writeUtf8(output, row); + } + } + + private void writeOoxmlDetailRow(OutputStream output, int rowNumber, ArBillDetail detail) throws IOException { Object[] values = { - detail.getDetailNo(), - detail.getReceiveTime() == null ? "" : new SimpleDateFormat("yyyy-MM-dd").format(detail.getReceiveTime()), - detail.getOrderNo(), detail.getTrackingNo(), - StringUtils.isEmpty(detail.getProductName()) ? detail.getProductCode() : detail.getProductName(), - StringUtils.isEmpty(detail.getCountryName()) ? detail.getCountryCode() : detail.getCountryName(), - detail.getDestPostcode(), decimal(detail.getActualWeight(), 3), decimal(detail.getPkgLength(), 2), - decimal(detail.getPkgHeight(), 2), decimal(detail.getPkgWidth(), 2), decimal(detail.getVolumeWeight(), 3), - decimal(detail.getChargeWeight(), 3), exportUnitPrice(detail), exportProcessingFee(detail), - exportFormula(detail), exportFeeTypes(detail), detail.getCurrency(), detail.getTotalAmount(), exportRemark(detail) + detail.getDetailNo(), + detail.getReceiveTime() == null ? "" : new SimpleDateFormat("yyyy-MM-dd").format(detail.getReceiveTime()), + detail.getOrderNo(), detail.getTrackingNo(), + StringUtils.isEmpty(detail.getProductName()) ? detail.getProductCode() : detail.getProductName(), + StringUtils.isEmpty(detail.getCountryName()) ? detail.getCountryCode() : detail.getCountryName(), + detail.getDestPostcode(), decimal(detail.getActualWeight(), 3), decimal(detail.getPkgLength(), 2), + decimal(detail.getPkgHeight(), 2), decimal(detail.getPkgWidth(), 2), decimal(detail.getVolumeWeight(), 3), + decimal(detail.getChargeWeight(), 3), exportUnitPrice(detail), exportProcessingFee(detail), + exportFormula(detail), exportFeeTypes(detail), detail.getCurrency(), detail.getTotalAmount(), exportRemark(detail) }; - for (int index = 0; index < values.length; index++) - { - set(row, index, values[index]); - row.getCell(index, Row.MissingCellPolicy.CREATE_NULL_AS_BLANK).setCellStyle(bodyStyle); + StringBuilder row = new StringBuilder(2048); + row.append(""); + for (int index = 0; index < TEMPLATE_COLUMN_COUNT; index++) { + appendOoxmlCell(row, (char) ('A' + index) + String.valueOf(rowNumber), values[index]); + } + row.append(""); + writeUtf8(output, row.toString()); + } + + private void appendOoxmlCell(StringBuilder row, String reference, Object value) { + row.append("").append(numberText((Number) value)).append(""); + } else { + String text = value == null ? "" : String.valueOf(value); + row.append(" t=\"inlineStr\">").append(xmlEscape(text)).append(""); } } - private String exportRemark(ArBillDetail detail) - { + private String replaceCellXml(String rowXml, String reference, Object value, boolean numeric) throws IOException { + if (rowXml == null) { + throw new IOException("应收账单模板缺少行: " + reference); + } + Matcher matcher = CELL_PATTERN.matcher(rowXml); + StringBuffer result = new StringBuffer(); + boolean found = false; + while (matcher.find()) { + String attributes = matcher.group(1); + Matcher referenceMatcher = CELL_REF_PATTERN.matcher(attributes); + if (!referenceMatcher.find() || !reference.equalsIgnoreCase(referenceMatcher.group(1))) { + matcher.appendReplacement(result, Matcher.quoteReplacement(matcher.group())); + continue; + } + found = true; + String normalizedAttributes = attributes.replaceAll("(?i)\\s+t\\s*=\\s*[\\\"'][^\\\"']*[\\\"']", "") + .replaceAll("\\s*/\\s*$", ""); + StringBuilder cell = new StringBuilder(""); + if (numeric) { + cell.append("").append(value == null ? "" : numberText((Number) value)).append(""); + } else { + String text = value == null ? "" : String.valueOf(value); + cell.append("").append(xmlEscape(text)).append(""); + } + cell.append(""); + matcher.appendReplacement(result, Matcher.quoteReplacement(cell.toString())); + } + matcher.appendTail(result); + if (!found) { + return insertMissingCellXml(rowXml, reference, value, numeric); + } + return result.toString(); + } + + /** + * 兼容不同 Excel/WPS 版本保存后被省略的空单元格,按列顺序补回动态单元格。 + * C3/P3 使用模板中对应的样式,避免客户名称或账单周期单元格变成无边框样式。 + */ + private String insertMissingCellXml(String rowXml, String reference, Object value, boolean numeric) throws IOException { + int rowEnd = rowXml.lastIndexOf(""); + if (rowEnd < 0) { + throw new IOException("应收账单模板缺少行结束节点: " + reference); + } + int targetColumn = columnNumber(reference); + int insertAt = rowEnd; + Matcher matcher = CELL_PATTERN.matcher(rowXml); + while (matcher.find()) { + Matcher referenceMatcher = CELL_REF_PATTERN.matcher(matcher.group(1)); + if (referenceMatcher.find() && columnNumber(referenceMatcher.group(1)) > targetColumn) { + insertAt = matcher.start(); + break; + } + } + + String style = fallbackCellStyle(reference); + StringBuilder cell = new StringBuilder("") + .append(value == null ? "" : numberText((Number) value)) + .append(""); + } else { + String text = value == null ? "" : String.valueOf(value); + cell.append(" t=\"inlineStr\">").append(xmlEscape(text)).append(""); + } + return rowXml.substring(0, insertAt) + cell + rowXml.substring(insertAt); + } + + private int columnNumber(String reference) { + int result = 0; + for (int index = 0; index < reference.length() && Character.isLetter(reference.charAt(index)); index++) { + result = result * 26 + Character.toUpperCase(reference.charAt(index)) - 'A' + 1; + } + return result; + } + + private String fallbackCellStyle(String reference) { + if ("A1".equalsIgnoreCase(reference)) { + return "5"; + } + if ("C3".equalsIgnoreCase(reference)) { + return "11"; + } + if ("O3".equalsIgnoreCase(reference) || "P3".equalsIgnoreCase(reference)) { + return "15"; + } + return null; + } + + private String shiftRowXml(String rowXml, int targetRowNumber) throws IOException { + if (rowXml == null) { + throw new IOException("应收账单模板缺少合计区域行"); + } + Matcher rowMatcher = ROW_REF_PATTERN.matcher(rowXml); + if (!rowMatcher.find()) { + throw new IOException("应收账单模板行缺少行号"); + } + StringBuffer shifted = new StringBuffer(); + rowMatcher.appendReplacement(shifted, Matcher.quoteReplacement(rowMatcher.group(1) + targetRowNumber + rowMatcher.group(2))); + rowMatcher.appendTail(shifted); + + Matcher cellMatcher = CELL_ROW_REF_PATTERN.matcher(shifted.toString()); + StringBuffer result = new StringBuffer(); + while (cellMatcher.find()) { + cellMatcher.appendReplacement(result, Matcher.quoteReplacement( + cellMatcher.group(1) + cellMatcher.group(2) + targetRowNumber + cellMatcher.group(3))); + } + cellMatcher.appendTail(result); + return result.toString(); + } + + private String shiftFooterMerges(String xml, int rowOffset) { + Matcher matcher = MERGE_REF_PATTERN.matcher(xml); + StringBuffer result = new StringBuffer(); + int sourceFooterStart = TEMPLATE_FOOTER_START_ROW + 1; + int sourceFooterEnd = sourceFooterStart + 3; + while (matcher.find()) { + int firstRow = Integer.parseInt(matcher.group(3)); + String lastColumn = matcher.group(4); + String lastRowText = matcher.group(5); + int lastRow = lastRowText == null ? firstRow : Integer.parseInt(lastRowText); + if (firstRow >= sourceFooterStart && lastRow <= sourceFooterEnd) { + String replacement = matcher.group(1) + matcher.group(2) + (firstRow + rowOffset) + + (lastColumn == null ? "" : ":" + lastColumn + (lastRow + rowOffset)) + matcher.group(6); + matcher.appendReplacement(result, Matcher.quoteReplacement(replacement)); + } else { + matcher.appendReplacement(result, Matcher.quoteReplacement(matcher.group())); + } + } + matcher.appendTail(result); + return result.toString(); + } + + private String removeSheetDimension(String xml) { + Matcher matcher = DIMENSION_PATTERN.matcher(xml); + StringBuffer result = new StringBuffer(); + while (matcher.find()) { + matcher.appendReplacement(result, ""); + } + matcher.appendTail(result); + return result.toString(); + } + + private String readUtf8(InputStream input) throws IOException { + StringBuilder result = new StringBuilder(16384); + char[] buffer = new char[8192]; + java.io.Reader reader = new java.io.InputStreamReader(input, java.nio.charset.StandardCharsets.UTF_8); + for (int size; (size = reader.read(buffer)) != -1; ) { + result.append(buffer, 0, size); + } + return result.toString(); + } + + private void writeUtf8(OutputStream output, String value) throws IOException { + output.write(value.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } + + private String numberText(Number value) { + return value instanceof BigDecimal ? ((BigDecimal) value).toPlainString() : value.toString(); + } + + private String xmlEscape(String value) { + return value.replace("&", "&").replace("<", "<").replace(">", ">") + .replace("\"", """).replace("'", "'"); + } + + /** + * 只为当前内存中的一批明细查询费用,避免一次加载整张账单的费用数据。 + */ + private void attachFees(List details) { + List detailIds = new ArrayList<>(); + Map> feesByDetailId = new HashMap<>(); + for (ArBillDetail detail : details) { + detailIds.add(detail.getDetailId()); + feesByDetailId.put(detail.getDetailId(), new ArrayList<>()); + } + for (ArBillDetailFee fee : detailFeeMapper.selectByDetailIds(detailIds)) { + List fees = feesByDetailId.get(fee.getDetailId()); + if (fees != null) { + fees.add(fee); + } + } + for (ArBillDetail detail : details) { + detail.setFees(feesByDetailId.get(detail.getDetailId())); + } + } + + private void moveToTarget(Path source, Path target) throws IOException { + try { + Files.move(source, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException e) { + Files.move(source, target, StandardCopyOption.REPLACE_EXISTING); + } + } + + private String exportRemark(ArBillDetail detail) { return StringUtils.isEmpty(detail.getRemark()) ? detail.getGoodsDescription() : detail.getRemark(); } - private String exportPeriod(ArBill bill) - { - if (bill.getBillPeriodEnd() == null) - { - return ""; + private String exportPeriod(ArBill bill) { + Date start = bill.getBillPeriodStart(); + Date end = bill.getBillPeriodEnd(); + SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd"); + if (start != null && end != null) { + return format.format(start) + " 至 " + format.format(end); } - return "截至 " + new SimpleDateFormat("yyyy-MM-dd").format(bill.getBillPeriodEnd()); + if (start != null) { + return format.format(start) + " 起"; + } + return end == null ? "" : "截至 " + format.format(end); } - private BigDecimal exportUnitPrice(ArBillDetail detail) - { + private BigDecimal exportUnitPrice(ArBillDetail detail) { BigDecimal direct = firstFeeUnitPrice(detail.getFees()); - if (direct != null) - { + if (direct != null) { return direct; } BigDecimal freight = feeAmount(detail, "BASIC_FREIGHT"); - if (freight == null || detail.getChargeWeight() == null || detail.getChargeWeight().compareTo(BigDecimal.ZERO) <= 0) - { + if (freight == null || detail.getChargeWeight() == null || detail.getChargeWeight().compareTo(BigDecimal.ZERO) <= 0) { return null; } return freight.divide(detail.getChargeWeight(), 4, java.math.RoundingMode.HALF_UP); } - private BigDecimal firstFeeUnitPrice(List fees) - { - if (fees == null) - { + private BigDecimal firstFeeUnitPrice(List fees) { + if (fees == null) { return null; } - for (ArBillDetailFee fee : fees) - { - if (fee.getUnitPrice() != null) - { + for (ArBillDetailFee fee : fees) { + if (fee.getUnitPrice() != null) { return fee.getUnitPrice(); } } return null; } - private BigDecimal exportProcessingFee(ArBillDetail detail) - { + private BigDecimal exportProcessingFee(ArBillDetail detail) { BigDecimal total = BigDecimal.ZERO; boolean found = false; - if (detail.getFees() != null) - { - for (ArBillDetailFee fee : detail.getFees()) - { + if (detail.getFees() != null) { + for (ArBillDetailFee fee : detail.getFees()) { String name = fee.getFeeName() == null ? "" : fee.getFeeName(); - if ("REGISTRATION_FEE".equalsIgnoreCase(fee.getFeeType()) || name.contains("处理费")) - { + if ("REGISTRATION_FEE".equalsIgnoreCase(fee.getFeeType()) || name.contains("处理费")) { total = total.add(nvl(fee.getAmount())); found = true; } @@ -772,44 +1504,35 @@ public class ArBillServiceImpl implements IArBillService return found ? total : null; } - private String exportFormula(ArBillDetail detail) - { + private String exportFormula(ArBillDetail detail) { String formula = joinFeeText(detail.getFees(), true); - if (!StringUtils.isEmpty(formula)) - { + if (!StringUtils.isEmpty(formula)) { return formula; } boolean freight = feeAmount(detail, "BASIC_FREIGHT") != null; boolean processing = exportProcessingFee(detail) != null; boolean surcharge = hasFeeType(detail.getFees(), "SURCHARGE"); - if (!freight) - { + if (!freight) { return ""; } return "结算重量*公斤价" + (processing ? "+处理费" : "") + (surcharge ? "+附加费" : ""); } - private String exportFeeTypes(ArBillDetail detail) - { + private String exportFeeTypes(ArBillDetail detail) { return joinFeeText(detail.getFees(), false); } - private String joinFeeText(List fees, boolean formula) - { - if (fees == null || fees.isEmpty()) - { + private String joinFeeText(List fees, boolean formula) { + if (fees == null || fees.isEmpty()) { return ""; } StringBuilder result = new StringBuilder(); - for (ArBillDetailFee fee : fees) - { + for (ArBillDetailFee fee : fees) { String value = formula ? fee.getCalculationFormula() : (StringUtils.isEmpty(fee.getFeeName()) ? fee.getFeeType() : fee.getFeeName()); - if (StringUtils.isEmpty(value)) - { + if (StringUtils.isEmpty(value)) { continue; } - if (result.length() > 0) - { + if (result.length() > 0) { result.append("、"); } result.append(value); @@ -817,18 +1540,14 @@ public class ArBillServiceImpl implements IArBillService return result.toString(); } - private BigDecimal feeAmount(ArBillDetail detail, String feeType) - { - if (detail.getFees() == null) - { + private BigDecimal feeAmount(ArBillDetail detail, String feeType) { + if (detail.getFees() == null) { return null; } BigDecimal total = BigDecimal.ZERO; boolean found = false; - for (ArBillDetailFee fee : detail.getFees()) - { - if (feeType.equalsIgnoreCase(fee.getFeeType())) - { + for (ArBillDetailFee fee : detail.getFees()) { + if (feeType.equalsIgnoreCase(fee.getFeeType())) { total = total.add(nvl(fee.getAmount())); found = true; } @@ -836,34 +1555,26 @@ public class ArBillServiceImpl implements IArBillService return found ? total : null; } - private boolean hasFeeType(List fees, String feeType) - { - if (fees == null) - { + private boolean hasFeeType(List fees, String feeType) { + if (fees == null) { return false; } - for (ArBillDetailFee fee : fees) - { - if (feeType.equalsIgnoreCase(fee.getFeeType())) - { + for (ArBillDetailFee fee : fees) { + if (feeType.equalsIgnoreCase(fee.getFeeType())) { return true; } } return false; } - private String exportBankAccountNo(String value) - { - if (StringUtils.isEmpty(value)) - { + private String exportBankAccountNo(String value) { + if (StringUtils.isEmpty(value)) { return ""; } String normalized = value.replaceAll("\\s+", ""); StringBuilder result = new StringBuilder(normalized.length() + normalized.length() / 4); - for (int index = 0; index < normalized.length(); index++) - { - if (index > 0 && index % 4 == 0) - { + for (int index = 0; index < normalized.length(); index++) { + if (index > 0 && index % 4 == 0) { result.append(' '); } result.append(normalized.charAt(index)); @@ -871,53 +1582,83 @@ public class ArBillServiceImpl implements IArBillService return result.toString(); } - private BigDecimal decimal(BigDecimal value, int scale) { return value == null ? null : value.setScale(scale, java.math.RoundingMode.HALF_UP); } - private BigDecimal nvl(BigDecimal value) { return value == null ? BigDecimal.ZERO : value; } - private String safeFilePart(String value) { return StringUtils.isEmpty(value) ? "客户" : value.replaceAll("[^a-zA-Z0-9\\u4e00-\\u9fa5_-]", "_"); } - private String maskAccount(String value) { if (StringUtils.isEmpty(value)) return ""; return value.length() <= 4 ? "****" : "****" + value.substring(value.length() - 4); } - private String sha256(byte[] data) throws NoSuchAlgorithmException { byte[] digest = MessageDigest.getInstance("SHA-256").digest(data); StringBuilder sb = new StringBuilder(); for (byte b : digest) sb.append(String.format("%02x", b)); return sb.toString(); } - private boolean isNumber(String value) { if (StringUtils.isEmpty(value)) return false; try { Long.valueOf(value); return true; } catch (NumberFormatException e) { return false; } } - private String safeStatusJson(String status) { Map data = new HashMap<>(); data.put("status", status); return JSON.toJSONString(data); } - private void validatePeriod(ArBillGenerateRequest request) - { - if (request == null || request.getCustomerId() == null || StringUtils.isEmpty(request.getBillName()) - || request.getBillName().trim().isEmpty() - || request.getBillPeriodEnd() == null || request.getSourceBillIds() == null || request.getSourceBillIds().isEmpty()) - { - throw new ServiceException("客户、账单名称、账单截止日和订单选择不能为空"); + private BigDecimal decimal(BigDecimal value, int scale) { + return value == null ? null : value.setScale(scale, java.math.RoundingMode.HALF_UP); + } + + private BigDecimal nvl(BigDecimal value) { + return value == null ? BigDecimal.ZERO : value; + } + + private String safeFilePart(String value) { + return StringUtils.isEmpty(value) ? "客户" : value.replaceAll("[^a-zA-Z0-9\\u4e00-\\u9fa5_-]", "_"); + } + + private String maskAccount(String value) { + if (StringUtils.isEmpty(value)) return ""; + return value.length() <= 4 ? "****" : "****" + value.substring(value.length() - 4); + } + + private String sha256(Path file) throws IOException, NoSuchAlgorithmException { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] buffer = new byte[8192]; + try (InputStream input = Files.newInputStream(file)) { + for (int size; (size = input.read(buffer)) != -1; ) { + digest.update(buffer, 0, size); + } + } + StringBuilder result = new StringBuilder(64); + for (byte value : digest.digest()) { + result.append(String.format("%02x", value)); + } + return result.toString(); + } + + private boolean isNumber(String value) { + if (StringUtils.isEmpty(value)) return false; + try { + Long.valueOf(value); + return true; + } catch (NumberFormatException e) { + return false; } } - private void validatePreviewQuery(ArBillGenerateQuery query) - { - if (query == null || query.getCustomerId() == null || query.getBillPeriodEnd() == null) - { + private String safeStatusJson(String status) { + Map data = new HashMap<>(); + data.put("status", status); + return JSON.toJSONString(data); + } + + private void validatePeriod(ArBillGenerateRequest request) { + if (request == null || request.getCustomerId() == null || StringUtils.isEmpty(request.getBillName()) + || request.getBillName().trim().isEmpty() + || request.getBillPeriodEnd() == null) { + throw new ServiceException("客户、账单名称和账单截止日不能为空"); + } + } + + private void validatePreviewQuery(ArBillGenerateQuery query) { + if (query == null || query.getCustomerId() == null || query.getBillPeriodEnd() == null) { throw new ServiceException("客户和账单截止日不能为空"); } } - private Date earliestBizDate(List sources) - { + private Date earliestBizDate(List sources) { Date earliest = null; - for (PrcOrderBill source : sources) - { - if (source.getBizDate() != null && (earliest == null || source.getBizDate().before(earliest))) - { + for (PrcOrderBill source : sources) { + if (source.getBizDate() != null && (earliest == null || source.getBizDate().before(earliest))) { earliest = source.getBizDate(); } } return earliest == null ? new Date() : earliest; } - private List normalizeSourceBillIds(List sourceBillIds) - { + private List normalizeSourceBillIds(List sourceBillIds) { LinkedHashSet uniqueIds = new LinkedHashSet<>(); - if (sourceBillIds != null) - { - for (String billId : sourceBillIds) - { - if (!StringUtils.isEmpty(billId) && billId.trim().length() <= 36) - { + if (sourceBillIds != null) { + for (String billId : sourceBillIds) { + if (!StringUtils.isEmpty(billId) && billId.trim().length() <= 36) { uniqueIds.add(billId.trim()); } } diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/task/ArBillExportTaskRunner.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/task/ArBillExportTaskRunner.java new file mode 100644 index 00000000..cda87a9d --- /dev/null +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/task/ArBillExportTaskRunner.java @@ -0,0 +1,62 @@ +package com.ruoyi.ar.task; + +import java.util.Date; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +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 org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; +import com.ruoyi.ar.event.ArBillExportTaskEvent; +import com.ruoyi.ar.service.IArBillService; + +/** + * 应收账单导出异步任务执行器。 + * 导出文件写入服务器临时目录,Web 请求只负责提交任务,完成后再提供下载。 + */ +@Component +public class ArBillExportTaskRunner { + private static final Logger log = LoggerFactory.getLogger(ArBillExportTaskRunner.class); + + /** + * 导出超过 60 分钟仍未完成时,视为异常任务。 + */ + private static final long PROCESSING_TIMEOUT_MILLIS = 60L * 60L * 1000L; + + @Autowired + private IArBillService billService; + + @Autowired + @Qualifier("arBillExportTaskExecutor") + private ThreadPoolTaskExecutor exportTaskExecutor; + + /** + * 仅在提交导出任务的事务成功提交后异步执行,避免读取到未提交数据。 + */ + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + /** 事务提交后投递到导出专用线程池,避免占用请求线程。 */ + public void run(ArBillExportTaskEvent event) { + try { + exportTaskExecutor.execute(() -> billService.processExportTask(event.getExportId())); + } catch (TaskRejectedException e) { + billService.failExportTask(event.getExportId(), "导出任务较多,请稍后重新发起导出"); + } + } + + /** + * 定期关闭因应用中断等原因长期未完成的导出任务。 + */ + @Scheduled(cron = "0 0/5 * * * ?") + public void failExpiredTasks() { + Date expiredBefore = new Date(System.currentTimeMillis() - PROCESSING_TIMEOUT_MILLIS); + int count = billService.failExpiredExportTasks(expiredBefore); + if (count > 0) { + log.warn("已关闭 {} 个超时的应收账单导出任务", count); + } + } +} diff --git a/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/task/ArBillGenerateTaskRunner.java b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/task/ArBillGenerateTaskRunner.java new file mode 100644 index 00000000..404e7084 --- /dev/null +++ b/bms-back/ruoyi-system/src/main/java/com/ruoyi/ar/task/ArBillGenerateTaskRunner.java @@ -0,0 +1,103 @@ +package com.ruoyi.ar.task; + +import java.util.Date; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +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 org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; +import com.ruoyi.ar.domain.ArBillGenerateTask; +import com.ruoyi.ar.event.ArBillGenerateTaskEvent; +import com.ruoyi.ar.service.IArBillService; + +/** + * 应收账单异步生成任务调度器。 + * + *

事务提交后立即尝试投递任务;定时补偿负责恢复节点异常、清理超时取消任务, + * 并重新投递仍处于 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 selectPrcOrderBillByCustomerPeriod(@Param("customerId") Long customerId, - @Param("customerCode") String customerCode, @Param("cutoffDate") Date cutoffDate, - @Param("currency") String currency, @Param("arBillStatus") String arBillStatus); + @Param("customerCode") String customerCode, @Param("orderOrTrackingNo") String orderOrTrackingNo, + @Param("excludeOrderOrTrackingNo") String excludeOrderOrTrackingNo, + @Param("cutoffDate") Date cutoffDate, @Param("currency") String currency, + @Param("arBillStatus") String arBillStatus, @Param("excludedBillIds") List excludedBillIds); + + /** 统计应收账单预览结果金额,供全选模式展示。 */ + BigDecimal sumPrcOrderBillIncomeByCustomerPeriod(@Param("customerId") Long customerId, + @Param("customerCode") String customerCode, @Param("orderOrTrackingNo") String orderOrTrackingNo, + @Param("excludeOrderOrTrackingNo") String excludeOrderOrTrackingNo, + @Param("cutoffDate") Date cutoffDate, @Param("currency") String currency, + @Param("arBillStatus") String arBillStatus); + + /** 异步生成账单按 bill_id 游标读取一批来源订单。 */ + List selectPrcOrderBillByCustomerPeriodCursor(@Param("customerId") Long customerId, + @Param("customerCode") String customerCode, @Param("orderOrTrackingNo") String orderOrTrackingNo, + @Param("cutoffDate") Date cutoffDate, @Param("currency") String currency, + @Param("arBillStatus") String arBillStatus, @Param("snapshotCreateTime") Date snapshotCreateTime, + @Param("lastSourceBillId") String lastSourceBillId, @Param("billIds") List billIds, + @Param("excludedBillIds") List excludedBillIds, @Param("limit") int limit); + + int countPrcOrderBillByCustomerPeriod(@Param("customerId") Long customerId, + @Param("customerCode") String customerCode, @Param("orderOrTrackingNo") String orderOrTrackingNo, + @Param("cutoffDate") Date cutoffDate, @Param("currency") String currency, + @Param("arBillStatus") String arBillStatus, @Param("snapshotCreateTime") Date snapshotCreateTime, + @Param("billIds") List billIds, @Param("excludedBillIds") List excludedBillIds); /** 按生成账单时用户勾选的订单读取,并再次校验账单筛选条件。 */ public List selectPrcOrderBillByCustomerCutoffAndIds(@Param("customerId") Long customerId, - @Param("customerCode") String customerCode, @Param("cutoffDate") Date cutoffDate, + @Param("customerCode") String customerCode, @Param("orderOrTrackingNo") String orderOrTrackingNo, + @Param("cutoffDate") Date cutoffDate, @Param("currency") String currency, @Param("billIds") List billIds, @Param("arBillStatus") String arBillStatus); @@ -37,6 +61,9 @@ public interface PrcOrderBillMapper public int releaseArBillOrders(@Param("billIds") List billIds, @Param("operator") String operator, @Param("fromStatus") String fromStatus, @Param("toStatus") String toStatus); + int releaseArBillOrdersByArBillId(@Param("arBillId") Long arBillId, @Param("operator") String operator, + @Param("fromStatus") String fromStatus, @Param("toStatus") String toStatus); + public int insertPrcOrderBill(PrcOrderBill bill); public int updatePrcOrderBill(PrcOrderBill bill); diff --git a/bms-back/ruoyi-system/src/main/resources/mapper/ar/ArBankAccountMapper.xml b/bms-back/ruoyi-system/src/main/resources/mapper/ar/ArBankAccountMapper.xml index c3d00049..c9f26767 100644 --- a/bms-back/ruoyi-system/src/main/resources/mapper/ar/ArBankAccountMapper.xml +++ b/bms-back/ruoyi-system/src/main/resources/mapper/ar/ArBankAccountMapper.xml @@ -1,7 +1,7 @@ + PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> @@ -19,12 +19,21 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" diff --git a/bms-back/ruoyi-system/src/main/resources/mapper/ar/ArBillDetailFeeMapper.xml b/bms-back/ruoyi-system/src/main/resources/mapper/ar/ArBillDetailFeeMapper.xml index ab360a50..f1daee1c 100644 --- a/bms-back/ruoyi-system/src/main/resources/mapper/ar/ArBillDetailFeeMapper.xml +++ b/bms-back/ruoyi-system/src/main/resources/mapper/ar/ArBillDetailFeeMapper.xml @@ -1,8 +1,9 @@ + PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> + @@ -24,39 +25,116 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + + + + - insert into ar_bill_detail_fee ( - detail_id, prc_income_bill_fee_id, fee_no, fee_type, fee_name, sc_id, quantity, unit, unit_price, - amount, currency, calculation_formula, round_mode, remark, create_by, create_time - ) values ( - #{detailId}, #{prcIncomeBillFeeId}, #{feeNo}, #{feeType}, #{feeName}, #{scId}, #{quantity}, #{unit}, - #{unitPrice}, #{amount}, #{currency}, #{calculationFormula}, #{roundMode}, #{remark}, - #{createBy}, sysdate() - ) + insert into ar_bill_detail_fee (detail_id, prc_income_bill_fee_id, fee_no, fee_type, fee_name, sc_id, quantity, + unit, unit_price, + amount, currency, calculation_formula, round_mode, remark, create_by, + create_time) + values (#{detailId}, #{prcIncomeBillFeeId}, #{feeNo}, #{feeType}, #{feeName}, #{scId}, #{quantity}, #{unit}, + #{unitPrice}, #{amount}, #{currency}, #{calculationFormula}, #{roundMode}, #{remark}, + #{createBy}, sysdate()) insert into ar_bill_detail_fee ( - detail_id, prc_income_bill_fee_id, fee_no, fee_type, fee_name, sc_id, quantity, unit, unit_price, - amount, currency, calculation_formula, round_mode, remark, create_by, create_time + detail_id, prc_income_bill_fee_id, fee_no, fee_type, fee_name, sc_id, quantity, unit, unit_price, + amount, currency, calculation_formula, round_mode, remark, create_by, create_time ) values - (#{item.detailId}, #{item.prcIncomeBillFeeId}, #{item.feeNo}, #{item.feeType}, #{item.feeName}, #{item.scId}, - #{item.quantity}, #{item.unit}, #{item.unitPrice}, #{item.amount}, #{item.currency}, - #{item.calculationFormula}, #{item.roundMode}, #{item.remark}, #{item.createBy}, sysdate()) + (#{item.detailId}, #{item.prcIncomeBillFeeId}, #{item.feeNo}, #{item.feeType}, #{item.feeName}, + #{item.scId}, + #{item.quantity}, #{item.unit}, #{item.unitPrice}, #{item.amount}, #{item.currency}, + #{item.calculationFormula}, #{item.roundMode}, #{item.remark}, #{item.createBy}, sysdate()) + + + delete + f + from ar_bill_detail_fee f + inner join ar_bill_detail d on d.detail_id = f.detail_id + where d.ar_bill_id = + #{arBillId} + diff --git a/bms-back/ruoyi-system/src/main/resources/mapper/ar/ArBillDetailMapper.xml b/bms-back/ruoyi-system/src/main/resources/mapper/ar/ArBillDetailMapper.xml index 548d3db3..73acf498 100644 --- a/bms-back/ruoyi-system/src/main/resources/mapper/ar/ArBillDetailMapper.xml +++ b/bms-back/ruoyi-system/src/main/resources/mapper/ar/ArBillDetailMapper.xml @@ -3,6 +3,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> + @@ -38,6 +39,20 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" from ar_bill_detail where ar_bill_id = #{arBillId} order by detail_no + + @@ -78,18 +93,29 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" order by d.detail_no, d.detail_id, f.fee_no, f.detail_fee_id + + - insert into ar_bill_detail ( - ar_bill_id, prc_order_bill_id, detail_no, order_no, tracking_no, receive_time, product_code, - product_name, goods_description, country_code, country_name, dest_postcode, actual_weight, - volume_weight, charge_weight, pkg_length, pkg_width, pkg_height, currency, total_amount, - remark, create_by, create_time - ) values ( - #{arBillId}, #{prcOrderBillId}, #{detailNo}, #{orderNo}, #{trackingNo}, #{receiveTime}, - #{productCode}, #{productName}, #{goodsDescription}, #{countryCode}, #{countryName}, - #{destPostcode}, #{actualWeight}, #{volumeWeight}, #{chargeWeight}, #{pkgLength}, #{pkgWidth}, - #{pkgHeight}, #{currency}, #{totalAmount}, #{remark}, #{createBy}, sysdate() - ) + insert into ar_bill_detail (ar_bill_id, prc_order_bill_id, detail_no, order_no, tracking_no, receive_time, + product_code, + product_name, goods_description, country_code, country_name, dest_postcode, + actual_weight, + volume_weight, charge_weight, pkg_length, pkg_width, pkg_height, currency, + total_amount, + remark, create_by, create_time) + values (#{arBillId}, #{prcOrderBillId}, #{detailNo}, #{orderNo}, #{trackingNo}, #{receiveTime}, + #{productCode}, #{productName}, #{goodsDescription}, #{countryCode}, #{countryName}, + #{destPostcode}, #{actualWeight}, #{volumeWeight}, #{chargeWeight}, #{pkgLength}, #{pkgWidth}, + #{pkgHeight}, #{currency}, #{totalAmount}, #{remark}, #{createBy}, sysdate()) @@ -107,4 +133,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" #{item.currency}, #{item.totalAmount}, #{item.remark}, #{item.createBy}, sysdate()) + + + delete from ar_bill_detail where ar_bill_id = #{arBillId} + diff --git a/bms-back/ruoyi-system/src/main/resources/mapper/ar/ArBillExportMapper.xml b/bms-back/ruoyi-system/src/main/resources/mapper/ar/ArBillExportMapper.xml index 0dde3c2e..19d5ff4d 100644 --- a/bms-back/ruoyi-system/src/main/resources/mapper/ar/ArBillExportMapper.xml +++ b/bms-back/ruoyi-system/src/main/resources/mapper/ar/ArBillExportMapper.xml @@ -3,13 +3,18 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> + + + + + @@ -21,8 +26,24 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" - select export_id, ar_bill_id, file_name, file_type, template_code, template_version, - storage_key, file_size, file_hash, row_count, export_status, export_by, export_time, remark + select export_id, + ar_bill_id, + file_name, + file_type, + template_id, + template_code, + template_version, + template_storage_type, + template_storage_key, + template_hash, + storage_key, + file_size, + file_hash, + row_count, + export_status, + export_by, + export_time, + remark from ar_bill_export @@ -40,13 +61,43 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" order by export_time desc limit 1 + + insert into ar_bill_export ( - ar_bill_id, file_name, file_type, template_code, template_version, storage_key, + ar_bill_id, file_name, file_type, template_id, template_code, template_version, + template_storage_type, template_storage_key, template_hash, storage_key, file_size, file_hash, row_count, export_status, export_by, export_time, remark ) values ( - #{arBillId}, #{fileName}, #{fileType}, #{templateCode}, #{templateVersion}, #{storageKey}, + #{arBillId}, #{fileName}, #{fileType}, #{templateId}, #{templateCode}, #{templateVersion}, + #{templateStorageType}, #{templateStorageKey}, #{templateHash}, #{storageKey}, #{fileSize}, #{fileHash}, #{rowCount}, #{exportStatus}, #{exportBy}, sysdate(), #{remark} ) + + + update ar_bill_export + set storage_key = #{storageKey}, + file_size = #{fileSize}, + file_hash = #{fileHash}, + export_status = 'SUCCESS', + remark = null + where export_id = #{exportId} and export_status = 'PROCESSING' + + + + update ar_bill_export + set export_status = 'FAILED', remark = #{remark} + where export_id = #{exportId} and export_status = 'PROCESSING' + + + + update ar_bill_export + set export_status = 'FAILED', remark = #{remark} + where export_status = 'PROCESSING' and export_time < #{expiredBefore} + diff --git a/bms-back/ruoyi-system/src/main/resources/mapper/ar/ArBillGenerateTaskMapper.xml b/bms-back/ruoyi-system/src/main/resources/mapper/ar/ArBillGenerateTaskMapper.xml new file mode 100644 index 00000000..e1292f63 --- /dev/null +++ b/bms-back/ruoyi-system/src/main/resources/mapper/ar/ArBillGenerateTaskMapper.xml @@ -0,0 +1,175 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + select task_id, + task_no, + ar_bill_id, + selection_mode, + request_payload, + snapshot_create_time, + last_source_bill_id, + total_count, + processed_count, + success_count, + total_amount, + status, + error_message, + request_token, + create_by, + create_time, + started_at, + heartbeat_time, + finished_at + from ar_bill_generate_task + + + + + + + + + + + + + + insert into ar_bill_generate_task (task_no, ar_bill_id, selection_mode, request_payload, snapshot_create_time, + last_source_bill_id, total_count, processed_count, success_count, + total_amount, + status, error_message, request_token, create_by, create_time, started_at, + heartbeat_time, finished_at) + values (#{taskNo}, #{arBillId}, #{selectionMode}, #{requestPayload}, #{snapshotCreateTime}, + #{lastSourceBillId}, #{totalCount}, #{processedCount}, #{successCount}, #{totalAmount}, + #{status}, #{errorMessage}, #{requestToken}, #{createBy}, sysdate(), #{startedAt}, + #{heartbeatTime}, #{finishedAt}) + + + + + update ar_bill_generate_task + set status = 'RUNNING', + started_at = coalesce(started_at, #{startedAt}), + heartbeat_time = #{startedAt} + where task_id = #{taskId} + and status = 'WAITING' + + + + update ar_bill_generate_task + set total_count = #{totalCount}, + heartbeat_time = sysdate() + where task_id = #{taskId} + and status = 'RUNNING' + + + + + update ar_bill_generate_task + set last_source_bill_id = #{lastSourceBillId}, + processed_count = #{processedCount}, + success_count = #{successCount}, + total_amount = #{totalAmount}, + heartbeat_time = #{heartbeatTime} + where task_id = #{taskId} + and status = 'RUNNING' + + + + update ar_bill_generate_task + set status = 'SUCCESS', + finished_at = #{finishedAt}, + heartbeat_time = #{finishedAt} + where task_id = #{taskId} + and status = 'RUNNING' + + + + update ar_bill_generate_task + set status = 'FAILED', + error_message = #{errorMessage}, + finished_at = #{finishedAt}, + heartbeat_time = #{finishedAt} + where task_id = #{taskId} + and status in ('WAITING', 'RUNNING', 'CANCEL_REQUESTED') + + + + update ar_bill_generate_task + set status = 'CANCEL_REQUESTED', + heartbeat_time = sysdate() + where task_id = #{taskId} + and status in ('WAITING', 'RUNNING') + + + + update ar_bill_generate_task + set status = 'CANCELED', + error_message = '用户取消账单生成', + finished_at = #{finishedAt}, + heartbeat_time = #{finishedAt} + where task_id = #{taskId} + and status in ('WAITING', 'RUNNING', 'CANCEL_REQUESTED') + + + + + update ar_bill_generate_task + set status = 'WAITING', + error_message = '任务执行节点已重启,已自动恢复等待处理', + heartbeat_time = null + where status = 'RUNNING' + and heartbeat_time < #{expiredBefore} + + + + + diff --git a/bms-back/ruoyi-system/src/main/resources/mapper/ar/ArBillLogMapper.xml b/bms-back/ruoyi-system/src/main/resources/mapper/ar/ArBillLogMapper.xml index f7909639..fb35c3ef 100644 --- a/bms-back/ruoyi-system/src/main/resources/mapper/ar/ArBillLogMapper.xml +++ b/bms-back/ruoyi-system/src/main/resources/mapper/ar/ArBillLogMapper.xml @@ -1,7 +1,7 @@ + PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> @@ -28,24 +28,27 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" - insert into ar_bill_log ( - ar_bill_id, object_type, object_id, operation_type, operation_desc, from_status, to_status, - before_data, after_data, operator_id, operator_name, operation_source, request_id, - trace_id, result, error_message, client_ip, create_time - ) values ( - #{arBillId}, #{objectType}, #{objectId}, #{operationType}, #{operationDesc}, #{fromStatus}, #{toStatus}, - #{beforeData}, #{afterData}, #{operatorId}, #{operatorName}, #{operationSource}, #{requestId}, - #{traceId}, #{result}, #{errorMessage}, #{clientIp}, sysdate() - ) + insert into ar_bill_log (ar_bill_id, object_type, object_id, operation_type, operation_desc, from_status, + to_status, + before_data, after_data, operator_id, operator_name, operation_source, request_id, + trace_id, result, error_message, client_ip, create_time) + values (#{arBillId}, #{objectType}, #{objectId}, #{operationType}, #{operationDesc}, #{fromStatus}, #{toStatus}, + #{beforeData}, #{afterData}, #{operatorId}, #{operatorName}, #{operationSource}, #{requestId}, + #{traceId}, #{result}, #{errorMessage}, #{clientIp}, sysdate()) diff --git a/bms-back/ruoyi-system/src/main/resources/mapper/ar/ArBillMapper.xml b/bms-back/ruoyi-system/src/main/resources/mapper/ar/ArBillMapper.xml index bbf78cde..5b643707 100644 --- a/bms-back/ruoyi-system/src/main/resources/mapper/ar/ArBillMapper.xml +++ b/bms-back/ruoyi-system/src/main/resources/mapper/ar/ArBillMapper.xml @@ -1,8 +1,9 @@ + PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> + @@ -19,6 +20,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" + @@ -33,21 +35,48 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" - select ar_bill_id, bill_no, bill_name, customer_id, customer_code, customer_name, - bill_period_start, bill_period_end, bill_date, currency, order_count, - total_amount, written_off_amount, unwritten_amount, status, bill_version, - bank_account_name, bank_account_no, bank_name, remark, confirmed_by, - confirmed_time, create_by, create_time, update_by, update_time + select ar_bill_id, + bill_no, + bill_name, + customer_id, + customer_code, + customer_name, + bill_period_start, + bill_period_end, + bill_date, + currency, + order_count, + total_amount, + written_off_amount, + unwritten_amount, + status, + generation_status, + bill_version, + bank_account_name, + bank_account_no, + bank_name, + remark, + confirmed_by, + confirmed_time, + create_by, + create_time, + update_by, + update_time from ar_bill + + - insert into ar_bill ( - bill_no, bill_name, customer_id, customer_code, customer_name, bill_period_start, bill_period_end, - bill_date, currency, order_count, total_amount, written_off_amount, unwritten_amount, - status, bill_version, bank_account_name, bank_account_no, bank_name, remark, - confirmed_by, confirmed_time, create_by, create_time, update_by, update_time - ) values ( - #{billNo}, #{billName}, #{customerId}, #{customerCode}, #{customerName}, #{billPeriodStart}, #{billPeriodEnd}, - #{billDate}, #{currency}, #{orderCount}, #{totalAmount}, #{writtenOffAmount}, #{unwrittenAmount}, - #{status}, #{billVersion}, #{bankAccountName}, #{bankAccountNo}, #{bankName}, #{remark}, - #{confirmedBy}, #{confirmedTime}, #{createBy}, sysdate(), #{updateBy}, sysdate() - ) + insert into ar_bill (bill_no, bill_name, customer_id, customer_code, customer_name, bill_period_start, + bill_period_end, + bill_date, currency, order_count, total_amount, written_off_amount, unwritten_amount, + status, generation_status, bill_version, bank_account_name, bank_account_no, bank_name, + remark, + confirmed_by, confirmed_time, create_by, create_time, update_by, update_time) + values (#{billNo}, #{billName}, #{customerId}, #{customerCode}, #{customerName}, #{billPeriodStart}, + #{billPeriodEnd}, + #{billDate}, #{currency}, #{orderCount}, #{totalAmount}, #{writtenOffAmount}, #{unwrittenAmount}, + #{status}, #{generationStatus}, #{billVersion}, #{bankAccountName}, #{bankAccountNo}, #{bankName}, + #{remark}, + #{confirmedBy}, #{confirmedTime}, #{createBy}, sysdate(), #{updateBy}, sysdate()) update ar_bill - set status = #{status}, remark = #{remark}, update_by = #{operator}, update_time = sysdate(), - confirmed_by = case when #{status} = 'CONFIRMED' then #{operator} else confirmed_by end, + set status = #{status}, + remark = #{remark}, + update_by = #{operator}, + update_time = sysdate(), + confirmed_by = case when #{status} = 'CONFIRMED' then #{operator} else confirmed_by end, confirmed_time = case when #{status} = 'CONFIRMED' then sysdate() else confirmed_time end where ar_bill_id = #{arBillId} and status = #{fromStatus} @@ -114,9 +161,34 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" update ar_bill - set written_off_amount = #{writtenOffAmount}, unwritten_amount = #{unwrittenAmount}, - status = #{status}, update_by = #{operator}, update_time = sysdate() + set written_off_amount = #{writtenOffAmount}, + unwritten_amount = #{unwrittenAmount}, + status = #{status}, + update_by = #{operator}, + update_time = sysdate() where ar_bill_id = #{arBillId} and status = #{fromStatus} + + + update ar_bill + set order_count = #{orderCount}, + total_amount = #{totalAmount}, + unwritten_amount = #{unwrittenAmount}, + bill_period_start = case + when #{billPeriodStart} < bill_period_start then #{billPeriodStart} + else bill_period_start end, + update_by = #{operator}, + update_time = sysdate() + where ar_bill_id = #{arBillId} + + + + update ar_bill + set generation_status = #{generationStatus}, + update_by = #{operator}, + update_time = sysdate(), + remark = case when #{remark} is null or #{remark} = '' then remark else #{remark} end + where ar_bill_id = #{arBillId} + diff --git a/bms-back/ruoyi-system/src/main/resources/mapper/ar/ArExportTemplateMapper.xml b/bms-back/ruoyi-system/src/main/resources/mapper/ar/ArExportTemplateMapper.xml new file mode 100644 index 00000000..a627afb6 --- /dev/null +++ b/bms-back/ruoyi-system/src/main/resources/mapper/ar/ArExportTemplateMapper.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/bms-back/ruoyi-system/src/main/resources/mapper/price/PrcOrderBillMapper.xml b/bms-back/ruoyi-system/src/main/resources/mapper/price/PrcOrderBillMapper.xml index 060e401d..fdd85e36 100644 --- a/bms-back/ruoyi-system/src/main/resources/mapper/price/PrcOrderBillMapper.xml +++ b/bms-back/ruoyi-system/src/main/resources/mapper/price/PrcOrderBillMapper.xml @@ -4,6 +4,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> + + @@ -155,21 +157,107 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" order by create_time desc, bill_id desc - + + + + + + where customer_id = #{customerId} + + and customer_code like concat('%', #{customerCode}, '%') + + + and (order_no like concat('%', #{orderOrTrackingNo}, '%') + or tracking_no like concat('%', #{orderOrTrackingNo}, '%')) + + and biz_date <= #{cutoffDate} + and ar_bill_status = #{arBillStatus} + + and currency = #{currency} + + + and (create_time is null or create_time <= #{snapshotCreateTime}) + + + and bill_id in + + #{billId} + + + + and bill_id not in + + #{excludedBillId} + + + + + + + + + +