This commit is contained in:
wuweihua
2026-09-20 17:12:18 +08:00
52 changed files with 3948 additions and 790 deletions
+10 -1
View File
@@ -194,6 +194,15 @@
<source>${java.version}</source> <source>${java.version}</source>
<target>${java.version}</target> <target>${java.version}</target>
<encoding>${project.build.sourceEncoding}</encoding> <encoding>${project.build.sourceEncoding}</encoding>
<!-- JDK 23+ 默认不再自动发现注解处理器,显式启用 Lombok,避免 @Data 方法未生成。 -->
<proc>full</proc>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.46</version>
</path>
</annotationProcessorPaths>
</configuration> </configuration>
</plugin> </plugin>
<plugin> <plugin>
@@ -229,4 +238,4 @@
</pluginRepository> </pluginRepository>
</pluginRepositories> </pluginRepositories>
</project> </project>
@@ -1,6 +1,7 @@
package com.ruoyi.web.controller.ar; package com.ruoyi.web.controller.ar;
import java.util.List; import java.util.List;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Autowired; 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 org.springframework.web.bind.annotation.RestController;
import com.ruoyi.ar.domain.ArBill; import com.ruoyi.ar.domain.ArBill;
import com.ruoyi.ar.domain.ArBankAccount; import com.ruoyi.ar.domain.ArBankAccount;
import com.ruoyi.ar.domain.ArBillDetailFee;
import com.ruoyi.ar.domain.ArBillExport; import com.ruoyi.ar.domain.ArBillExport;
import com.ruoyi.ar.domain.ArBillGenerateTask;
import com.ruoyi.ar.domain.ArBillLog; import com.ruoyi.ar.domain.ArBillLog;
import com.ruoyi.ar.domain.vo.ArBillActionRequest; import com.ruoyi.ar.domain.vo.ArBillActionRequest;
import com.ruoyi.ar.domain.vo.ArBillGenerateQuery; 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.common.utils.uuid.IdUtils;
import com.ruoyi.price.domain.PrcOrderBill; import com.ruoyi.price.domain.PrcOrderBill;
/** 应收账单管理。 */ /**
* 应收账单 HTTP 接口层。
*
* <p>负责账单列表、账单生成、确认/作废、异步导出、日志和详情分页接口;
* 具体业务规则统一下沉到 {@code IArBillService}。</p>
*/
@RestController @RestController
@RequestMapping("/ar/bill") @RequestMapping("/ar/bill")
@Validated @Validated
public class ArBillController extends BaseController public class ArBillController extends BaseController {
{
@Autowired @Autowired
private IArBillService billService; private IArBillService billService;
/**
* 查询账单列表;分页参数由 BaseController.startPage 交给 MyBatis PageHelper 处理。
*/
@PreAuthorize("@ss.hasPermi('ar:bill:list')") @PreAuthorize("@ss.hasPermi('ar:bill:list')")
@GetMapping("/list") @GetMapping("/list")
public TableDataInfo list(ArBillQuery query) public TableDataInfo list(ArBillQuery query) {
{
startPage(); startPage();
List<ArBill> list = billService.selectArBillList(query); List<ArBill> list = billService.selectArBillList(query);
return getDataTable(list); return getDataTable(list);
} }
/**
* 查询账单头信息;账单明细通过独立分页接口按需加载。
*/
@PreAuthorize("@ss.hasPermi('ar:bill:query')") @PreAuthorize("@ss.hasPermi('ar:bill:query')")
@GetMapping("/{arBillId}") @GetMapping("/{arBillId}")
public AjaxResult getInfo(@PathVariable Long arBillId) public AjaxResult getInfo(@PathVariable Long arBillId) {
{
return success(billService.selectArBillById(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<ArBillDetailFee> fees = billService.selectArBillDetailFees(arBillId, detailId);
return success(fees);
}
/**
* 查询指定币种的公司默认收款账户,账号返回脱敏值。
*/
@PreAuthorize("@ss.hasPermi('ar:bill:generate')") @PreAuthorize("@ss.hasPermi('ar:bill:generate')")
@GetMapping("/default-bank-account") @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); ArBankAccount account = billService.getDefaultBankAccount(currency);
return success(account); return success(account);
} }
/** 查询生成账单前的订单计费结果。 */ /**
* 查询生成账单前的订单计费结果。
*/
@PreAuthorize("@ss.hasPermi('ar:bill:generate')") @PreAuthorize("@ss.hasPermi('ar:bill:generate')")
@GetMapping("/generate/preview") @GetMapping("/generate/preview")
public TableDataInfo preview(ArBillGenerateQuery query) public AjaxResult preview(ArBillGenerateQuery query) {
{ startPage();
List<PrcOrderBill> list = billService.previewBill(query); List<PrcOrderBill> 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) @Log(title = "应收账单", businessType = BusinessType.INSERT)
@PreAuthorize("@ss.hasPermi('ar:bill:generate')") @PreAuthorize("@ss.hasPermi('ar:bill:generate')")
@PostMapping("/generate") @PostMapping("/generate")
public AjaxResult generate(@Valid @RequestBody ArBillGenerateRequest request, 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); String requestId = requestId(request.getRequestId(), headerRequestId);
ArBill bill = billService.generateBill(request, getUsername(), requestId); ArBillGenerateTask task = billService.generateBill(request, getUsername(), requestId);
return success(bill).put("request_id", 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) @Log(title = "应收账单", businessType = BusinessType.UPDATE)
@PreAuthorize("@ss.hasPermi('ar:bill:confirm')") @PreAuthorize("@ss.hasPermi('ar:bill:confirm')")
@PutMapping("/{arBillId}/confirm") @PutMapping("/{arBillId}/confirm")
public AjaxResult confirm(@PathVariable Long arBillId, @RequestBody(required = false) ArBillActionRequest request, 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); String requestId = requestId(request == null ? null : request.getRequestId(), headerRequestId);
return success(billService.confirmBill(arBillId, request, getUsername(), requestId)).put("request_id", requestId); return success(billService.confirmBill(arBillId, request, getUsername(), requestId)).put("request_id", requestId);
} }
/**
* 作废尚未发生核销的账单;账单明细保留作为历史快照。
*/
@Log(title = "应收账单", businessType = BusinessType.UPDATE) @Log(title = "应收账单", businessType = BusinessType.UPDATE)
@PreAuthorize("@ss.hasPermi('ar:bill:void')") @PreAuthorize("@ss.hasPermi('ar:bill:void')")
@PutMapping("/{arBillId}/void") @PutMapping("/{arBillId}/void")
public AjaxResult voidBill(@PathVariable Long arBillId, @RequestBody ArBillActionRequest request, 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); String requestId = requestId(request == null ? null : request.getRequestId(), headerRequestId);
return success(billService.voidBill(arBillId, request, getUsername(), requestId)).put("request_id", requestId); return success(billService.voidBill(arBillId, request, getUsername(), requestId)).put("request_id", requestId);
} }
/**
* 仅提交导出任务;前端通过导出任务状态接口轮询并在完成后下载。
*/
@Log(title = "应收账单导出", businessType = BusinessType.EXPORT) @Log(title = "应收账单导出", businessType = BusinessType.EXPORT)
@PreAuthorize("@ss.hasPermi('ar:bill:export')") @PreAuthorize("@ss.hasPermi('ar:bill:export')")
@PostMapping("/{arBillId}/export") @PostMapping("/{arBillId}/export")
public AjaxResult export(@PathVariable Long arBillId, 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); String requestId = requestId(null, headerRequestId);
return success(billService.exportBill(arBillId, getUsername(), requestId)).put("request_id", requestId); return success(billService.exportBill(arBillId, getUsername(), requestId)).put("request_id", requestId);
} }
/**
* 查询当前账单的导出历史和任务状态。
*/
@PreAuthorize("@ss.hasPermi('ar:bill:query')") @PreAuthorize("@ss.hasPermi('ar:bill:query')")
@GetMapping("/{arBillId}/exports") @GetMapping("/{arBillId}/exports")
public AjaxResult exports(@PathVariable Long arBillId) public AjaxResult exports(@PathVariable Long arBillId) {
{
return success(billService.selectExports(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')") @PreAuthorize("@ss.hasPermi('ar:bill:export')")
@GetMapping("/export/{exportId}/download") @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); billService.downloadExport(exportId, response);
} }
/**
* 分页查询账单操作日志。
*/
@PreAuthorize("@ss.hasPermi('ar:log:list')") @PreAuthorize("@ss.hasPermi('ar:log:list')")
@GetMapping("/log/list") @GetMapping("/log/list")
public TableDataInfo logs(ArBillLog query) public TableDataInfo logs(ArBillLog query) {
{
startPage(); startPage();
return getDataTable(billService.selectLogList(query)); 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(bodyRequestId)) return bodyRequestId;
if (StringUtils.isNotEmpty(headerRequestId)) return headerRequestId; if (StringUtils.isNotEmpty(headerRequestId)) return headerRequestId;
return IdUtils.fastSimpleUUID(); return IdUtils.fastSimpleUUID();
@@ -29,8 +29,8 @@ spring:
connectTimeout: 30000 connectTimeout: 30000
# 配置网络超时时间 # 配置网络超时时间
socketTimeout: 60000 socketTimeout: 60000
# 配置间隔多久才进行一次检测,检测需要关闭的空闲连接,单位是毫秒 # 配置间隔多久检测一次空闲连接,避免远程数据库断开后连接池继续复用失效连接,单位是毫秒
timeBetweenEvictionRunsMillis: 60000 timeBetweenEvictionRunsMillis: 30000
# 配置一个连接在池中最小生存的时间,单位是毫秒 # 配置一个连接在池中最小生存的时间,单位是毫秒
minEvictableIdleTimeMillis: 300000 minEvictableIdleTimeMillis: 300000
# 配置一个连接在池中最大生存的时间,单位是毫秒 # 配置一个连接在池中最大生存的时间,单位是毫秒
@@ -38,7 +38,8 @@ spring:
# 配置检测连接是否有效 # 配置检测连接是否有效
validationQuery: SELECT 1 FROM DUAL validationQuery: SELECT 1 FROM DUAL
testWhileIdle: true testWhileIdle: true
testOnBorrow: false # 异步账单任务借用连接时校验连接有效性,避免拿到已被数据库断开的连接
testOnBorrow: true
testOnReturn: false testOnReturn: false
webStatFilter: webStatFilter:
enabled: true enabled: true
@@ -59,4 +60,4 @@ spring:
merge-sql: true merge-sql: true
wall: wall:
config: config:
multi-statement-allow: true multi-statement-allow: true
+3 -1
View File
@@ -26,9 +26,11 @@
<dependency> <dependency>
<groupId>org.projectlombok</groupId> <groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId> <artifactId>lombok</artifactId>
<!-- Java 26 编译环境使用新版 Lombok,确保模型类的 getter/setter 正常生成。 -->
<version>1.18.46</version>
<optional>true</optional> <optional>true</optional>
</dependency> </dependency>
</dependencies> </dependencies>
</project> </project>
@@ -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;
}
}
@@ -2,13 +2,15 @@ package com.ruoyi.ar.domain;
import java.io.Serializable; import java.io.Serializable;
import java.util.Date; import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data; import lombok.Data;
/** 公司收款账户配置。 */ /**
* 公司收款账户配置。
*/
@Data @Data
public class ArBankAccount implements Serializable public class ArBankAccount implements Serializable {
{
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
private Long bankAccountId; private Long bankAccountId;
@@ -3,41 +3,72 @@ package com.ruoyi.ar.domain;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.Date; import java.util.Date;
import java.util.List; import java.util.List;
import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonFormat;
import org.springframework.format.annotation.DateTimeFormat; import org.springframework.format.annotation.DateTimeFormat;
import com.ruoyi.common.core.domain.BaseEntity; import com.ruoyi.common.core.domain.BaseEntity;
import lombok.Data; import lombok.Data;
import lombok.EqualsAndHashCode; import lombok.EqualsAndHashCode;
/** 正式应收账单。 */ /**
* 正式应收账单。
*/
@Data @Data
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
public class ArBill extends BaseEntity public class ArBill extends BaseEntity {
{
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
/**
* 账单头主键。
*/
private Long arBillId; private Long arBillId;
/**
* 对外展示的账单编号。
*/
private String billNo; private String billNo;
/**
* 财务填写的账单名称。
*/
private String billName; private String billName;
/**
* 账单所属客户主键。
*/
private Long customerId; private Long customerId;
private String customerCode; private String customerCode;
private String customerName; private String customerName;
@DateTimeFormat(pattern = "yyyy-MM-dd") @DateTimeFormat(pattern = "yyyy-MM-dd")
@JsonFormat(pattern = "yyyy-MM-dd") @JsonFormat(pattern = "yyyy-MM-dd")
/** 账单所属期开始日期,导出模板展示用。 */
private Date billPeriodStart; private Date billPeriodStart;
@DateTimeFormat(pattern = "yyyy-MM-dd") @DateTimeFormat(pattern = "yyyy-MM-dd")
@JsonFormat(pattern = "yyyy-MM-dd") @JsonFormat(pattern = "yyyy-MM-dd")
/** 账单所属期结束日期,同时作为生成订单的截止日期。 */
private Date billPeriodEnd; private Date billPeriodEnd;
@DateTimeFormat(pattern = "yyyy-MM-dd") @DateTimeFormat(pattern = "yyyy-MM-dd")
@JsonFormat(pattern = "yyyy-MM-dd") @JsonFormat(pattern = "yyyy-MM-dd")
private Date billDate; private Date billDate;
private String currency; private String currency;
private Integer orderCount; private Integer orderCount;
/**
* 账单明细应收金额合计。
*/
private BigDecimal totalAmount; private BigDecimal totalAmount;
/**
* 已通过余额核销的金额。
*/
private BigDecimal writtenOffAmount; private BigDecimal writtenOffAmount;
/**
* 尚未核销的金额,核销入口按此字段判断是否可操作。
*/
private BigDecimal unwrittenAmount; private BigDecimal unwrittenAmount;
/** 状态:DRAFT草稿、CONFIRMED已确认、PARTIAL_WRITTEN_OFF部分核销、WRITTEN_OFF已核销、VOID已作废。 */ /**
* 状态:DRAFT草稿、CONFIRMED已确认、PARTIAL_WRITTEN_OFF部分核销、WRITTEN_OFF已核销、VOID已作废。
*/
private String status; private String status;
/**
* 生成状态:GENERATING生成中、READY可用、FAILED生成失败。
*/
private String generationStatus;
private Integer billVersion; private Integer billVersion;
private String bankAccountName; private String bankAccountName;
private String bankAccountNo; private String bankAccountNo;
@@ -47,7 +78,16 @@ public class ArBill extends BaseEntity
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date confirmedTime; private Date confirmedTime;
/**
* 账单明细;详情接口分页查询时通常不直接填充该集合。
*/
private List<ArBillDetail> details; private List<ArBillDetail> details;
/**
* 当前账单的导出历史。
*/
private List<ArBillExport> exports; private List<ArBillExport> exports;
/**
* 当前账单的操作日志。
*/
private List<ArBillLog> logs; private List<ArBillLog> logs;
} }
@@ -4,13 +4,15 @@ import java.io.Serializable;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.Date; import java.util.Date;
import java.util.List; import java.util.List;
import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data; import lombok.Data;
/** 账单订单快照。 */ /**
* 账单订单快照。
*/
@Data @Data
public class ArBillDetail implements Serializable public class ArBillDetail implements Serializable {
{
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
private Long detailId; private Long detailId;
@@ -3,13 +3,15 @@ package com.ruoyi.ar.domain;
import java.io.Serializable; import java.io.Serializable;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.Date; import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data; import lombok.Data;
/** 账单费用快照。 */ /**
* 账单费用快照。
*/
@Data @Data
public class ArBillDetailFee implements Serializable public class ArBillDetailFee implements Serializable {
{
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
private Long detailFeeId; private Long detailFeeId;
@@ -2,26 +2,49 @@ package com.ruoyi.ar.domain;
import java.io.Serializable; import java.io.Serializable;
import java.util.Date; import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data; import lombok.Data;
/** 账单导出文件元数据。 */ /**
* 账单导出文件元数据。
*/
@Data @Data
public class ArBillExport implements Serializable public class ArBillExport implements Serializable {
{
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
private Long exportId; private Long exportId;
private Long arBillId; private Long arBillId;
private String fileName; private String fileName;
private String fileType; private String fileType;
/**
* 提交导出任务时绑定的模板版本,避免后续模板变更影响历史任务。
*/
private Long templateId;
private String templateCode; private String templateCode;
private String templateVersion; private String templateVersion;
private String templateStorageType;
private String templateStorageKey;
private String templateHash;
/**
* 生成文件的实际存储键,下载接口据此读取文件。
*/
private String storageKey; private String storageKey;
/**
* 文件字节数,便于前端展示和运维排查。
*/
private Long fileSize; private Long fileSize;
/**
* 文件摘要,用于校验文件完整性。
*/
private String fileHash; private String fileHash;
/**
* 导出的明细行数。
*/
private Integer rowCount; private Integer rowCount;
/** 导出状态:PROCESSING生成中、SUCCESS成功可下载、FAILED失败。 */ /**
* 导出状态:PROCESSING生成中、SUCCESS成功可下载、FAILED失败。
*/
private String exportStatus; private String exportStatus;
private String exportBy; private String exportBy;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@@ -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;
}
@@ -2,23 +2,31 @@ package com.ruoyi.ar.domain;
import java.io.Serializable; import java.io.Serializable;
import java.util.Date; import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data; import lombok.Data;
/** 应收模块追加式审计日志。 */ /**
* 应收模块追加式审计日志。
*/
@Data @Data
public class ArBillLog implements Serializable public class ArBillLog implements Serializable {
{
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
private Long logId; private Long logId;
private Long arBillId; private Long arBillId;
/** 对象类型:BILL应收账单、EXPORT账单导出、BALANCE_WRITEOFF余额核销。 */ /**
* 对象类型:BILL应收账单、EXPORT账单导出、BALANCE_WRITEOFF余额核销。
*/
private String objectType; private String objectType;
private String objectId; private String objectId;
/** 对象业务标识,例如账单号、收款单号或核销单号。 */ /**
* 对象业务标识,例如账单号、收款单号或核销单号。
*/
private String objectDisplay; private String objectDisplay;
/** 操作类型,取值由 ArLogOperationType 维护。 */ /**
* 操作类型,取值由 ArLogOperationType 维护。
*/
private String operationType; private String operationType;
private String operationDesc; private String operationDesc;
private String fromStatus; private String fromStatus;
@@ -27,11 +35,15 @@ public class ArBillLog implements Serializable
private String afterData; private String afterData;
private String operatorId; private String operatorId;
private String operatorName; private String operatorName;
/** 操作来源:WEB网页端、API接口、JOB定时任务、IMPORT数据导入。 */ /**
* 操作来源:WEB网页端、API接口、JOB定时任务、IMPORT数据导入。
*/
private String operationSource; private String operationSource;
private String requestId; private String requestId;
private String traceId; private String traceId;
/** 操作结果:SUCCESS成功、FAILED失败。 */ /**
* 操作结果:SUCCESS成功、FAILED失败。
*/
private String result; private String result;
private String errorMessage; private String errorMessage;
private String clientIp; private String clientIp;
@@ -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;
}
@@ -16,4 +16,10 @@ public class ArBillGenerateQuery
private Date billPeriodEnd; private Date billPeriodEnd;
private String currency; private String currency;
/** 订单号或运单号关键词,按 OR 条件模糊查询。 */
private String orderOrTrackingNo;
/** 全选模式下,排除弹窗使用的附加订单号或运单号关键词。 */
private String excludeOrderOrTrackingNo;
} }
@@ -4,8 +4,8 @@ import java.util.Date;
import java.util.List; import java.util.List;
import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonFormat;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
import com.ruoyi.ar.enums.ArBillSelectionMode;
import lombok.Data; import lombok.Data;
/** 从订单计费结果生成正式账单的参数。 */ /** 从订单计费结果生成正式账单的参数。 */
@@ -21,14 +21,21 @@ public class ArBillGenerateRequest
@NotNull(message = "账单截止日不能为空") @NotNull(message = "账单截止日不能为空")
@JsonFormat(pattern = "yyyy-MM-dd") @JsonFormat(pattern = "yyyy-MM-dd")
private Date billPeriodEnd; private Date billPeriodEnd;
@NotEmpty(message = "请至少选择一条订单") /** 手动模式选中的来源订单 ID;全选模式为空。 */
private List<String> sourceBillIds; private List<String> sourceBillIds;
/** 选择模式:EXPLICIT 手动选择,ALL_MATCHED 全选查询结果。 */
private String selectionMode = ArBillSelectionMode.EXPLICIT.getCode();
/** 全选模式中用户明确排除的来源订单 ID。 */
private List<String> excludedSourceBillIds;
@JsonFormat(pattern = "yyyy-MM-dd") @JsonFormat(pattern = "yyyy-MM-dd")
private Date billDate; private Date billDate;
private String currency = "CNY"; private String currency = "CNY";
/** 订单号或运单号关键词,生成时用于再次校验筛选条件。 */
private String orderOrTrackingNo;
private String bankAccountName; private String bankAccountName;
private String bankAccountNo; private String bankAccountNo;
private String bankName; private String bankName;
private String remark; private String remark;
/** 前端幂等号;重复提交同一请求时复用原任务。 */
private String requestId; private String requestId;
} }
@@ -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()
{
}
}
@@ -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()
{
}
}
@@ -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;
}
}
@@ -28,16 +28,19 @@ public enum ArBillStatus
return label; return label;
} }
/** 只有草稿账单允许确认。 */
public boolean canConfirm() public boolean canConfirm()
{ {
return this == DRAFT; return this == DRAFT;
} }
/** 草稿或已确认账单允许作废;已核销账单不可直接作废。 */
public boolean canVoid() public boolean canVoid()
{ {
return this == DRAFT || this == CONFIRMED; return this == DRAFT || this == CONFIRMED;
} }
/** 已确认或部分核销账单允许继续核销。 */
public boolean canWriteOff() public boolean canWriteOff()
{ {
return this == CONFIRMED || this == PARTIAL_WRITTEN_OFF; return this == CONFIRMED || this == PARTIAL_WRITTEN_OFF;
@@ -0,0 +1,21 @@
package com.ruoyi.ar.event;
/**
* 应收账单导出任务已提交事件。
*
* <p>由事务提交后监听器处理,确保异步线程读取到已落库的导出任务。</p>
*/
public class ArBillExportTaskEvent
{
private final Long exportId;
public ArBillExportTaskEvent(Long exportId)
{
this.exportId = exportId;
}
public Long getExportId()
{
return exportId;
}
}
@@ -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;
}
}
@@ -3,7 +3,6 @@ package com.ruoyi.ar.mapper;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
import com.ruoyi.ar.domain.ArBankAccount; import com.ruoyi.ar.domain.ArBankAccount;
public interface ArBankAccountMapper public interface ArBankAccountMapper {
{
ArBankAccount selectDefaultByCurrency(@Param("currency") String currency); ArBankAccount selectDefaultByCurrency(@Param("currency") String currency);
} }
@@ -1,15 +1,23 @@
package com.ruoyi.ar.mapper; package com.ruoyi.ar.mapper;
import java.util.List; import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.ruoyi.ar.domain.ArBillDetailFee; import com.ruoyi.ar.domain.ArBillDetailFee;
public interface ArBillDetailFeeMapper public interface ArBillDetailFeeMapper {
{
List<ArBillDetailFee> selectByDetailId(Long detailId); List<ArBillDetailFee> selectByDetailId(Long detailId);
List<ArBillDetailFee> selectByBillIdAndDetailId(@Param("arBillId") Long arBillId,
@Param("detailId") Long detailId);
List<ArBillDetailFee> selectByBillId(Long arBillId); List<ArBillDetailFee> selectByBillId(Long arBillId);
List<ArBillDetailFee> selectByDetailIds(@Param("detailIds") List<Long> detailIds);
int insert(ArBillDetailFee fee); int insert(ArBillDetailFee fee);
int insertBatch(List<ArBillDetailFee> fees); int insertBatch(List<ArBillDetailFee> fees);
int deleteByBillId(Long arBillId);
} }
@@ -1,16 +1,27 @@
package com.ruoyi.ar.mapper; package com.ruoyi.ar.mapper;
import java.util.List; import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.ruoyi.ar.domain.ArBillDetail; import com.ruoyi.ar.domain.ArBillDetail;
public interface ArBillDetailMapper public interface ArBillDetailMapper {
{
List<ArBillDetail> selectByBillId(Long arBillId); List<ArBillDetail> selectByBillId(Long arBillId);
/** 查询账单明细及费用明细,避免详情页逐层查询。 */ List<ArBillDetail> selectByBillIdAndSourceIds(@Param("arBillId") Long arBillId,
@Param("sourceBillIds") List<String> sourceBillIds);
/**
* 查询账单明细及费用明细,避免详情页逐层查询。
*/
List<ArBillDetail> selectByBillIdWithFees(Long arBillId); List<ArBillDetail> selectByBillIdWithFees(Long arBillId);
List<ArBillDetail> selectExportPage(@Param("arBillId") Long arBillId,
@Param("lastDetailNo") Integer lastDetailNo, @Param("pageSize") Integer pageSize);
int insert(ArBillDetail detail); int insert(ArBillDetail detail);
int insertBatch(List<ArBillDetail> details); int insertBatch(List<ArBillDetail> details);
int deleteByBillId(Long arBillId);
} }
@@ -1,15 +1,25 @@
package com.ruoyi.ar.mapper; package com.ruoyi.ar.mapper;
import java.util.Date;
import java.util.List; import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.ruoyi.ar.domain.ArBillExport; import com.ruoyi.ar.domain.ArBillExport;
public interface ArBillExportMapper public interface ArBillExportMapper {
{
List<ArBillExport> selectByBillId(Long arBillId); List<ArBillExport> selectByBillId(Long arBillId);
ArBillExport selectById(Long exportId); ArBillExport selectById(Long exportId);
ArBillExport selectLatestSuccessByBillId(Long arBillId); ArBillExport selectLatestSuccessByBillId(Long arBillId);
ArBillExport selectProcessingByBillId(Long arBillId);
int insert(ArBillExport export); 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);
} }
@@ -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<ArBillGenerateTask> selectActiveByCreateBy(@Param("createBy") String createBy);
List<ArBillGenerateTask> 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<ArBillGenerateTask> selectCancelRequestedBefore(@Param("expiredBefore") Date expiredBefore,
@Param("limit") int limit);
}
@@ -3,35 +3,49 @@ package com.ruoyi.ar.mapper;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.Date; import java.util.Date;
import java.util.List; import java.util.List;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
import com.ruoyi.ar.domain.ArBill; import com.ruoyi.ar.domain.ArBill;
import com.ruoyi.ar.domain.vo.ArBillQuery; import com.ruoyi.ar.domain.vo.ArBillQuery;
public interface ArBillMapper public interface ArBillMapper {
{
List<ArBill> selectArBillList(ArBillQuery query); List<ArBill> selectArBillList(ArBillQuery query);
ArBill selectArBillById(Long arBillId); ArBill selectArBillById(Long arBillId);
ArBill selectArBillByBillNo(String billNo); ArBill selectArBillByBillNo(String billNo);
ArBill selectGeneratingByCustomerPeriod(@Param("customerId") Long customerId,
@Param("currency") String currency, @Param("cutoffDate") Date cutoffDate);
Integer selectMaxBillVersion(@Param("customerId") Long customerId, 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); ArBill selectArBillByIdForUpdate(Long arBillId);
/** 查询客户可核销账单(已确认/部分核销),按账单日期升序,供余额核销选择 */ /**
* 查询客户可核销账单(已确认/部分核销),按账单日期升序,供余额核销选择
*/
List<ArBill> selectUnsettledByCustomer(@Param("customerId") Long customerId); List<ArBill> selectUnsettledByCustomer(@Param("customerId") Long customerId);
int insertArBill(ArBill bill); int insertArBill(ArBill bill);
int updateArBillStatus(@Param("arBillId") Long arBillId, @Param("status") String status, int updateArBillStatus(@Param("arBillId") Long arBillId, @Param("status") String status,
@Param("remark") String remark, @Param("operator") String operator, @Param("remark") String remark, @Param("operator") String operator,
@Param("fromStatus") String fromStatus); @Param("fromStatus") String fromStatus);
int updateArBillAmounts(@Param("arBillId") Long arBillId, int updateArBillAmounts(@Param("arBillId") Long arBillId,
@Param("writtenOffAmount") BigDecimal writtenOffAmount, @Param("writtenOffAmount") BigDecimal writtenOffAmount,
@Param("unwrittenAmount") BigDecimal unwrittenAmount, @Param("unwrittenAmount") BigDecimal unwrittenAmount,
@Param("status") String status, @Param("operator") String operator, @Param("status") String status, @Param("operator") String operator,
@Param("fromStatus") String fromStatus); @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);
} }
@@ -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);
}
@@ -1,16 +1,16 @@
package com.ruoyi.ar.service; package com.ruoyi.ar.service;
import java.util.List; import java.util.List;
import com.ruoyi.ar.domain.ArBillLog; import com.ruoyi.ar.domain.ArBillLog;
import com.ruoyi.ar.enums.ArLogObjectType; import com.ruoyi.ar.enums.ArLogObjectType;
import com.ruoyi.ar.enums.ArLogOperationType; import com.ruoyi.ar.enums.ArLogOperationType;
public interface IArBillLogService public interface IArBillLogService {
{
List<ArBillLog> selectLogList(ArBillLog query); List<ArBillLog> selectLogList(ArBillLog query);
void record(ArBillLog log); void record(ArBillLog log);
void recordFailure(Long arBillId, ArLogObjectType objectType, String objectId, ArLogOperationType operationType, 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);
} }
@@ -1,9 +1,14 @@
package com.ruoyi.ar.service; package com.ruoyi.ar.service;
import java.math.BigDecimal;
import java.util.Date;
import java.util.List; import java.util.List;
import com.ruoyi.ar.domain.ArBill; import com.ruoyi.ar.domain.ArBill;
import com.ruoyi.ar.domain.ArBankAccount; 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.ArBillExport;
import com.ruoyi.ar.domain.ArBillGenerateTask;
import com.ruoyi.ar.domain.ArBillLog; import com.ruoyi.ar.domain.ArBillLog;
import com.ruoyi.ar.domain.vo.ArBillGenerateRequest; import com.ruoyi.ar.domain.vo.ArBillGenerateRequest;
import com.ruoyi.ar.domain.vo.ArBillGenerateQuery; 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.ar.domain.vo.ArBillQuery;
import com.ruoyi.price.domain.PrcOrderBill; import com.ruoyi.price.domain.PrcOrderBill;
/**
* 应收账单业务接口。
* 查询接口面向列表/详情页面,生成和导出接口面向异步任务执行器;
* Controller 不直接操作 Mapper,统一由这里维护业务状态和事务边界。
*/
public interface IArBillService public interface IArBillService
{ {
List<ArBill> selectArBillList(ArBillQuery query); List<ArBill> selectArBillList(ArBillQuery query);
ArBill selectArBillById(Long arBillId); ArBill selectArBillById(Long arBillId);
List<ArBillDetail> selectArBillDetails(Long arBillId);
List<ArBillDetailFee> selectArBillDetailFees(Long arBillId, Long detailId);
ArBankAccount getDefaultBankAccount(String currency); ArBankAccount getDefaultBankAccount(String currency);
List<PrcOrderBill> previewBill(ArBillGenerateQuery query); List<PrcOrderBill> 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<ArBillGenerateTask> 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<ArBillGenerateTask> selectWaitingGenerateTasks(int limit);
ArBill confirmBill(Long arBillId, ArBillActionRequest request, String operator, String requestId); 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 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<ArBillExport> selectExports(Long arBillId); List<ArBillExport> selectExports(Long arBillId);
void downloadExport(Long exportId, jakarta.servlet.http.HttpServletResponse response) throws Exception; void downloadExport(Long exportId, jakarta.servlet.http.HttpServletResponse response) throws Exception;
@@ -7,6 +7,7 @@ import java.time.format.DateTimeFormatter;
import java.util.Date; import java.util.Date;
import java.util.List; import java.util.List;
import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.ThreadLocalRandom;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -39,8 +40,7 @@ import com.ruoyi.customer.customermoney.domain.CustomerBalanceFlow;
* @date 2026-09-16 * @date 2026-09-16
*/ */
@Service @Service
public class ArBalanceWriteoffServiceImpl implements IArBalanceWriteoffService public class ArBalanceWriteoffServiceImpl implements IArBalanceWriteoffService {
{
private static final DateTimeFormatter NO_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS"); private static final DateTimeFormatter NO_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS");
private static final int MONEY_SCALE = 2; private static final int MONEY_SCALE = 2;
@@ -56,55 +56,55 @@ public class ArBalanceWriteoffServiceImpl implements IArBalanceWriteoffService
@Autowired @Autowired
private ICustomerAccountApi customerAccountApi; private ICustomerAccountApi customerAccountApi;
/**
* 按客户查询允许余额核销的未结清账单。
*/
@Override @Override
public List<ArBill> selectUnsettledBills(Long customerId) public List<ArBill> selectUnsettledBills(Long customerId) {
{ if (customerId == null) {
if (customerId == null)
{
return List.of(); return List.of();
} }
return billMapper.selectUnsettledByCustomer(customerId); return billMapper.selectUnsettledByCustomer(customerId);
} }
/**
* 查询账单余额核销记录列表。
*/
@Override @Override
public List<ArBalanceWriteoff> selectWriteoffList(ArBalanceWriteoff query) public List<ArBalanceWriteoff> selectWriteoffList(ArBalanceWriteoff query) {
{
return writeoffMapper.selectWriteoffList(query); return writeoffMapper.selectWriteoffList(query);
} }
/**
* 执行余额核销主流程:锁账单、登记核销单、扣减客户余额、更新账单金额和状态。
* 任一步失败都会回滚当前事务,避免核销单、资金流水和账单状态不一致。
*/
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public ArBalanceWriteoff writeoff(ArBalanceWriteoff cmd) public ArBalanceWriteoff writeoff(ArBalanceWriteoff cmd) {
{ if (cmd == null || cmd.getArBillId() == null) {
if (cmd == null || cmd.getArBillId() == null)
{
throw new ServiceException("请选择要核销的账单"); throw new ServiceException("请选择要核销的账单");
} }
BigDecimal amount = cmd.getWriteoffAmount(); BigDecimal amount = cmd.getWriteoffAmount();
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
{
throw new ServiceException("核销金额必须大于0"); throw new ServiceException("核销金额必须大于0");
} }
amount = amount.setScale(MONEY_SCALE, RoundingMode.HALF_UP); amount = amount.setScale(MONEY_SCALE, RoundingMode.HALF_UP);
// 1. 行锁锁定账单(加锁顺序:账单 -> 账户,与反核销保持一致) // 1. 行锁锁定账单(加锁顺序:账单 -> 账户,与反核销保持一致)
ArBill bill = billMapper.selectArBillByIdForUpdate(cmd.getArBillId()); ArBill bill = billMapper.selectArBillByIdForUpdate(cmd.getArBillId());
if (bill == null) if (bill == null) {
{
throw new ServiceException("应收账单不存在"); throw new ServiceException("应收账单不存在");
} }
ArBillStatus status = ArBillStatus.of(bill.getStatus()); 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())); 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("账单与所选客户不匹配"); throw new ServiceException("账单与所选客户不匹配");
} }
BigDecimal unwritten = nvl(bill.getUnwrittenAmount()); BigDecimal unwritten = nvl(bill.getUnwrittenAmount());
if (amount.compareTo(unwritten) > 0) if (amount.compareTo(unwritten) > 0) {
{
throw new ServiceException("核销金额超过账单未核销金额:" + unwritten.toPlainString()); throw new ServiceException("核销金额超过账单未核销金额:" + unwritten.toPlainString());
} }
@@ -130,7 +130,7 @@ public class ArBalanceWriteoffServiceImpl implements IArBalanceWriteoffService
// 3. 扣减账户余额并释放等额已用额度,写 BILL_PAY 扣款流水(余额不足在此处拦截) // 3. 扣减账户余额并释放等额已用额度,写 BILL_PAY 扣款流水(余额不足在此处拦截)
String flowRemark = "余额核销账单:" + bill.getBillNo() String flowRemark = "余额核销账单:" + bill.getBillNo()
+ (StringUtils.hasText(writeoff.getRemark()) ? "(" + writeoff.getRemark() + ")" : ""); + (StringUtils.hasText(writeoff.getRemark()) ? "(" + writeoff.getRemark() + ")" : "");
BalanceChangeCommand payCmd = new BalanceChangeCommand(); BalanceChangeCommand payCmd = new BalanceChangeCommand();
payCmd.setCustomerId(bill.getCustomerId()); payCmd.setCustomerId(bill.getCustomerId());
payCmd.setCurrency(currency); payCmd.setCurrency(currency);
@@ -147,40 +147,38 @@ public class ArBalanceWriteoffServiceImpl implements IArBalanceWriteoffService
BigDecimal newUnwritten = unwritten.subtract(amount); BigDecimal newUnwritten = unwritten.subtract(amount);
String nextStatus = billStatus(written, nvl(bill.getTotalAmount()), fromStatus); String nextStatus = billStatus(written, nvl(bill.getTotalAmount()), fromStatus);
if (billMapper.updateArBillAmounts(bill.getArBillId(), written, newUnwritten, if (billMapper.updateArBillAmounts(bill.getArBillId(), written, newUnwritten,
nextStatus, operator, fromStatus) != 1) nextStatus, operator, fromStatus) != 1) {
{
throw new ServiceException("账单状态已发生变化,请刷新后重试"); throw new ServiceException("账单状态已发生变化,请刷新后重试");
} }
recordLog(bill.getArBillId(), writeoff.getWriteoffId(), ArLogOperationType.BALANCE_WRITEOFF, recordLog(bill.getArBillId(), writeoff.getWriteoffId(), ArLogOperationType.BALANCE_WRITEOFF,
"账户余额核销账单 " + amount.toPlainString() + " " + currency, "账户余额核销账单 " + amount.toPlainString() + " " + currency,
fromStatus, nextStatus, operator, requestId); fromStatus, nextStatus, operator, requestId);
writeoff.setAccountId(flow.getAccountId()); writeoff.setAccountId(flow.getAccountId());
writeoff.setBalanceFlowId(flow.getId()); writeoff.setBalanceFlowId(flow.getId());
return writeoff; return writeoff;
} }
/**
* 执行反核销主流程:锁定核销单和账单、回补客户余额、恢复账单未核销金额,
* 最后将原核销记录标记为已取消。
*/
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void cancelWriteoff(Long writeoffId, String remark) public void cancelWriteoff(Long writeoffId, String remark) {
{ if (writeoffId == null) {
if (writeoffId == null)
{
throw new ServiceException("核销记录不存在"); throw new ServiceException("核销记录不存在");
} }
// 1. 锁核销单 -> 锁账单(与核销相同的加锁顺序) // 1. 锁核销单 -> 锁账单(与核销相同的加锁顺序)
ArBalanceWriteoff writeoff = writeoffMapper.selectByIdForUpdate(writeoffId); ArBalanceWriteoff writeoff = writeoffMapper.selectByIdForUpdate(writeoffId);
if (writeoff == null) if (writeoff == null) {
{
throw new ServiceException("核销记录不存在"); throw new ServiceException("核销记录不存在");
} }
if (!ArBalanceWriteoffStatus.EFFECTIVE.getCode().equals(writeoff.getStatus())) if (!ArBalanceWriteoffStatus.EFFECTIVE.getCode().equals(writeoff.getStatus())) {
{
throw new ServiceException("只有有效状态的核销记录可以反核销"); throw new ServiceException("只有有效状态的核销记录可以反核销");
} }
ArBill bill = billMapper.selectArBillByIdForUpdate(writeoff.getArBillId()); ArBill bill = billMapper.selectArBillByIdForUpdate(writeoff.getArBillId());
if (bill == null) if (bill == null) {
{
throw new ServiceException("核销关联的账单不存在"); throw new ServiceException("核销关联的账单不存在");
} }
@@ -204,44 +202,40 @@ public class ArBalanceWriteoffServiceImpl implements IArBalanceWriteoffService
// 3. 账单核销金额回退,状态按剩余已核销额重算(归零则回到已确认) // 3. 账单核销金额回退,状态按剩余已核销额重算(归零则回到已确认)
BigDecimal written = nvl(bill.getWrittenOffAmount()).subtract(nvl(writeoff.getWriteoffAmount())); BigDecimal written = nvl(bill.getWrittenOffAmount()).subtract(nvl(writeoff.getWriteoffAmount()));
if (written.compareTo(BigDecimal.ZERO) < 0) if (written.compareTo(BigDecimal.ZERO) < 0) {
{
written = BigDecimal.ZERO; written = BigDecimal.ZERO;
} }
BigDecimal newUnwritten = nvl(bill.getTotalAmount()).subtract(written); BigDecimal newUnwritten = nvl(bill.getTotalAmount()).subtract(written);
String nextStatus = billStatus(written, nvl(bill.getTotalAmount()), fromStatus); String nextStatus = billStatus(written, nvl(bill.getTotalAmount()), fromStatus);
if (billMapper.updateArBillAmounts(bill.getArBillId(), written, newUnwritten, if (billMapper.updateArBillAmounts(bill.getArBillId(), written, newUnwritten,
nextStatus, operator, fromStatus) != 1) nextStatus, operator, fromStatus) != 1) {
{
throw new ServiceException("账单状态已发生变化,请刷新后重试"); throw new ServiceException("账单状态已发生变化,请刷新后重试");
} }
// 4. 核销单置为CANCELLED并登记回补流水 // 4. 核销单置为CANCELLED并登记回补流水
if (writeoffMapper.updateCancelled(writeoffId, reverseFlow.getId(), operator, cancelRemark, 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("核销记录状态已变化,请刷新后重试"); throw new ServiceException("核销记录状态已变化,请刷新后重试");
} }
recordLog(bill.getArBillId(), writeoffId, ArLogOperationType.CANCEL_BALANCE_WRITEOFF, recordLog(bill.getArBillId(), writeoffId, ArLogOperationType.CANCEL_BALANCE_WRITEOFF,
"反核销回补账单:" + cancelRemark, "反核销回补账单:" + cancelRemark,
fromStatus, nextStatus, operator, requestId); 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) return ArBillStatus.DRAFT.getCode().equals(current)
? ArBillStatus.DRAFT.getCode() : ArBillStatus.CONFIRMED.getCode(); ? ArBillStatus.DRAFT.getCode() : ArBillStatus.CONFIRMED.getCode();
} }
return written.compareTo(total) >= 0 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, 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(); ArBillLog log = new ArBillLog();
log.setArBillId(billId); log.setArBillId(billId);
log.setObjectType(ArLogObjectType.BALANCE_WRITEOFF.getCode()); log.setObjectType(ArLogObjectType.BALANCE_WRITEOFF.getCode());
@@ -258,14 +252,12 @@ public class ArBalanceWriteoffServiceImpl implements IArBalanceWriteoffService
logService.record(log); logService.record(log);
} }
private String generateNo() private String generateNo() {
{
return "BWO" + LocalDateTime.now().format(NO_FORMATTER) 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; return value == null ? BigDecimal.ZERO : value;
} }
} }
@@ -1,6 +1,7 @@
package com.ruoyi.ar.service.impl; package com.ruoyi.ar.service.impl;
import java.util.List; import java.util.List;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Propagation;
@@ -15,29 +16,27 @@ import com.ruoyi.ar.service.IArBillLogService;
import com.ruoyi.common.utils.uuid.IdUtils; import com.ruoyi.common.utils.uuid.IdUtils;
@Service @Service
public class ArBillLogServiceImpl implements IArBillLogService public class ArBillLogServiceImpl implements IArBillLogService {
{
@Autowired @Autowired
private ArBillLogMapper logMapper; private ArBillLogMapper logMapper;
@Override @Override
public List<ArBillLog> selectLogList(ArBillLog query) public List<ArBillLog> selectLogList(ArBillLog query) {
{
return logMapper.selectList(query); return logMapper.selectList(query);
} }
@Override @Override
public void record(ArBillLog log) public void record(ArBillLog log) {
{
logMapper.insert(log); logMapper.insert(log);
} }
/** 失败日志必须独立提交,不能随业务异常一起回滚。 */ /**
* 失败日志必须独立提交,不能随业务异常一起回滚。
*/
@Override @Override
@Transactional(propagation = Propagation.REQUIRES_NEW) @Transactional(propagation = Propagation.REQUIRES_NEW)
public void recordFailure(Long arBillId, ArLogObjectType objectType, String objectId, ArLogOperationType operationType, 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(); ArBillLog log = new ArBillLog();
log.setArBillId(arBillId); log.setArBillId(arBillId);
log.setObjectType(objectType.getCode()); log.setObjectType(objectType.getCode());
File diff suppressed because it is too large Load Diff
@@ -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);
}
}
}
@@ -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;
/**
* 应收账单异步生成任务调度器。
*
* <p>事务提交后立即尝试投递任务;定时补偿负责恢复节点异常、清理超时取消任务,
* 并重新投递仍处于 WAITING 状态的任务。</p>
*/
@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);
}
}
}
}
@@ -20,12 +20,36 @@ public interface PrcOrderBillMapper
/** 应收账单生成读取指定客户截止日期前的未出账订单计费快照。 */ /** 应收账单生成读取指定客户截止日期前的未出账订单计费快照。 */
public List<PrcOrderBill> selectPrcOrderBillByCustomerPeriod(@Param("customerId") Long customerId, public List<PrcOrderBill> selectPrcOrderBillByCustomerPeriod(@Param("customerId") Long customerId,
@Param("customerCode") String customerCode, @Param("cutoffDate") Date cutoffDate, @Param("customerCode") String customerCode, @Param("orderOrTrackingNo") String orderOrTrackingNo,
@Param("currency") String currency, @Param("arBillStatus") String arBillStatus); @Param("excludeOrderOrTrackingNo") String excludeOrderOrTrackingNo,
@Param("cutoffDate") Date cutoffDate, @Param("currency") String currency,
@Param("arBillStatus") String arBillStatus, @Param("excludedBillIds") List<String> 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<PrcOrderBill> 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<String> billIds,
@Param("excludedBillIds") List<String> 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<String> billIds, @Param("excludedBillIds") List<String> excludedBillIds);
/** 按生成账单时用户勾选的订单读取,并再次校验账单筛选条件。 */ /** 按生成账单时用户勾选的订单读取,并再次校验账单筛选条件。 */
public List<PrcOrderBill> selectPrcOrderBillByCustomerCutoffAndIds(@Param("customerId") Long customerId, public List<PrcOrderBill> 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<String> billIds, @Param("currency") String currency, @Param("billIds") List<String> billIds,
@Param("arBillStatus") String arBillStatus); @Param("arBillStatus") String arBillStatus);
@@ -37,6 +61,9 @@ public interface PrcOrderBillMapper
public int releaseArBillOrders(@Param("billIds") List<String> billIds, @Param("operator") String operator, public int releaseArBillOrders(@Param("billIds") List<String> billIds, @Param("operator") String operator,
@Param("fromStatus") String fromStatus, @Param("toStatus") String toStatus); @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 insertPrcOrderBill(PrcOrderBill bill);
public int updatePrcOrderBill(PrcOrderBill bill); public int updatePrcOrderBill(PrcOrderBill bill);
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8" ?> <?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper <!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd"> "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.ar.mapper.ArBankAccountMapper"> <mapper namespace="com.ruoyi.ar.mapper.ArBankAccountMapper">
<resultMap id="ArBankAccountResult" type="ArBankAccount"> <resultMap id="ArBankAccountResult" type="ArBankAccount">
<id property="bankAccountId" column="bank_account_id"/> <id property="bankAccountId" column="bank_account_id"/>
@@ -19,12 +19,21 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</resultMap> </resultMap>
<select id="selectDefaultByCurrency" resultMap="ArBankAccountResult"> <select id="selectDefaultByCurrency" resultMap="ArBankAccountResult">
select bank_account_id, account_name, account_no, bank_name, currency, select bank_account_id,
is_default, status, remark, create_by, create_time, update_by, update_time account_name,
account_no,
bank_name,
currency,
is_default,
status,
remark,
create_by,
create_time,
update_by,
update_time
from ar_bank_account from ar_bank_account
where currency = #{currency} where currency = #{currency}
and status = '0' and status = '0'
order by is_default desc, bank_account_id order by is_default desc, bank_account_id limit 1
limit 1
</select> </select>
</mapper> </mapper>
@@ -1,8 +1,9 @@
<?xml version="1.0" encoding="UTF-8" ?> <?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper <!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd"> "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.ar.mapper.ArBillDetailFeeMapper"> <mapper namespace="com.ruoyi.ar.mapper.ArBillDetailFeeMapper">
<!-- 账单明细费用项读写;批量查询仅接收当前明细批次的 ID。 -->
<resultMap id="ArBillDetailFeeResult" type="ArBillDetailFee"> <resultMap id="ArBillDetailFeeResult" type="ArBillDetailFee">
<id property="detailFeeId" column="detail_fee_id"/> <id property="detailFeeId" column="detail_fee_id"/>
<result property="detailId" column="detail_id"/> <result property="detailId" column="detail_id"/>
@@ -24,39 +25,116 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</resultMap> </resultMap>
<select id="selectByDetailId" parameterType="long" resultMap="ArBillDetailFeeResult"> <select id="selectByDetailId" parameterType="long" resultMap="ArBillDetailFeeResult">
select detail_fee_id, detail_id, prc_income_bill_fee_id, fee_no, fee_type, fee_name, sc_id, quantity, select detail_fee_id,
unit, unit_price, amount, currency, calculation_formula, round_mode, remark, create_by, create_time detail_id,
from ar_bill_detail_fee where detail_id = #{detailId} order by fee_no, detail_fee_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
from ar_bill_detail_fee
where detail_id = #{detailId}
order by fee_no, detail_fee_id
</select>
<select id="selectByBillIdAndDetailId" resultMap="ArBillDetailFeeResult">
select f.detail_fee_id,
f.detail_id,
f.prc_income_bill_fee_id,
f.fee_no,
f.fee_type,
f.fee_name,
f.sc_id,
f.quantity,
f.unit,
f.unit_price,
f.amount,
f.currency,
f.calculation_formula,
f.round_mode,
f.remark,
f.create_by,
f.create_time
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}
and f.detail_id = #{detailId}
order by f.fee_no, f.detail_fee_id
</select> </select>
<select id="selectByBillId" parameterType="long" resultMap="ArBillDetailFeeResult"> <select id="selectByBillId" parameterType="long" resultMap="ArBillDetailFeeResult">
select detail_fee_id, detail_id, prc_income_bill_fee_id, fee_no, fee_type, fee_name, sc_id, quantity, select detail_fee_id,
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
from ar_bill_detail_fee from ar_bill_detail_fee
where detail_id in (select detail_id from ar_bill_detail where ar_bill_id = #{arBillId}) where detail_id in (select detail_id from ar_bill_detail where ar_bill_id = #{arBillId})
order by detail_id, fee_no, detail_fee_id order by detail_id, fee_no, detail_fee_id
</select> </select>
<select id="selectByDetailIds" resultMap="ArBillDetailFeeResult">
select detail_fee_id, 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
from ar_bill_detail_fee
where detail_id in
<foreach collection="detailIds" item="detailId" open="(" separator="," close=")">
#{detailId}
</foreach>
order by detail_id, fee_no, detail_fee_id
</select>
<insert id="insert" parameterType="ArBillDetailFee" useGeneratedKeys="true" keyProperty="detailFeeId"> <insert id="insert" parameterType="ArBillDetailFee" useGeneratedKeys="true" keyProperty="detailFeeId">
insert into ar_bill_detail_fee ( insert into ar_bill_detail_fee (detail_id, prc_income_bill_fee_id, fee_no, fee_type, fee_name, sc_id, quantity,
detail_id, prc_income_bill_fee_id, fee_no, fee_type, fee_name, sc_id, quantity, unit, unit_price, unit, unit_price,
amount, currency, calculation_formula, round_mode, remark, create_by, create_time amount, currency, calculation_formula, round_mode, remark, create_by,
) values ( create_time)
#{detailId}, #{prcIncomeBillFeeId}, #{feeNo}, #{feeType}, #{feeName}, #{scId}, #{quantity}, #{unit}, values (#{detailId}, #{prcIncomeBillFeeId}, #{feeNo}, #{feeType}, #{feeName}, #{scId}, #{quantity}, #{unit},
#{unitPrice}, #{amount}, #{currency}, #{calculationFormula}, #{roundMode}, #{remark}, #{unitPrice}, #{amount}, #{currency}, #{calculationFormula}, #{roundMode}, #{remark},
#{createBy}, sysdate() #{createBy}, sysdate())
)
</insert> </insert>
<insert id="insertBatch" parameterType="java.util.List" useGeneratedKeys="true" keyProperty="detailFeeId"> <insert id="insertBatch" parameterType="java.util.List" useGeneratedKeys="true" keyProperty="detailFeeId">
insert into ar_bill_detail_fee ( 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, 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 amount, currency, calculation_formula, round_mode, remark, create_by, create_time
) values ) values
<foreach collection="list" item="item" separator=","> <foreach collection="list" item="item" separator=",">
(#{item.detailId}, #{item.prcIncomeBillFeeId}, #{item.feeNo}, #{item.feeType}, #{item.feeName}, #{item.scId}, (#{item.detailId}, #{item.prcIncomeBillFeeId}, #{item.feeNo}, #{item.feeType}, #{item.feeName},
#{item.quantity}, #{item.unit}, #{item.unitPrice}, #{item.amount}, #{item.currency}, #{item.scId},
#{item.calculationFormula}, #{item.roundMode}, #{item.remark}, #{item.createBy}, sysdate()) #{item.quantity}, #{item.unit}, #{item.unitPrice}, #{item.amount}, #{item.currency},
#{item.calculationFormula}, #{item.roundMode}, #{item.remark}, #{item.createBy}, sysdate())
</foreach> </foreach>
</insert> </insert>
<delete id="deleteByBillId" parameterType="long">
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}
</delete>
</mapper> </mapper>
@@ -3,6 +3,7 @@
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd"> "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.ar.mapper.ArBillDetailMapper"> <mapper namespace="com.ruoyi.ar.mapper.ArBillDetailMapper">
<!-- 账单明细读写;详情页分页查询,导出任务按游标批量查询。 -->
<resultMap id="ArBillDetailResult" type="ArBillDetail"> <resultMap id="ArBillDetailResult" type="ArBillDetail">
<id property="detailId" column="detail_id"/> <id property="detailId" column="detail_id"/>
<result property="arBillId" column="ar_bill_id"/> <result property="arBillId" column="ar_bill_id"/>
@@ -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 from ar_bill_detail where ar_bill_id = #{arBillId} order by detail_no
</select> </select>
<select id="selectByBillIdAndSourceIds" resultMap="ArBillDetailResult">
select detail_id, 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
from ar_bill_detail
where ar_bill_id = #{arBillId}
and prc_order_bill_id in
<foreach collection="sourceBillIds" item="sourceBillId" open="(" separator="," close=")">
#{sourceBillId}
</foreach>
order by detail_no
</select>
<resultMap id="ArBillDetailWithFeesResult" type="ArBillDetail" extends="ArBillDetailResult"> <resultMap id="ArBillDetailWithFeesResult" type="ArBillDetail" extends="ArBillDetailResult">
<collection property="fees" ofType="ArBillDetailFee"> <collection property="fees" ofType="ArBillDetailFee">
<id property="detailFeeId" column="fee_detail_id"/> <id property="detailFeeId" column="fee_detail_id"/>
@@ -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 order by d.detail_no, d.detail_id, f.fee_no, f.detail_fee_id
</select> </select>
<select id="selectExportPage" resultMap="ArBillDetailResult">
select detail_id, 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
from ar_bill_detail
where ar_bill_id = #{arBillId} and detail_no &gt; #{lastDetailNo}
order by detail_no
limit #{pageSize}
</select>
<insert id="insert" parameterType="ArBillDetail" useGeneratedKeys="true" keyProperty="detailId"> <insert id="insert" parameterType="ArBillDetail" useGeneratedKeys="true" keyProperty="detailId">
insert into ar_bill_detail ( insert into ar_bill_detail (ar_bill_id, prc_order_bill_id, detail_no, order_no, tracking_no, receive_time,
ar_bill_id, prc_order_bill_id, detail_no, order_no, tracking_no, receive_time, product_code, product_code,
product_name, goods_description, country_code, country_name, dest_postcode, actual_weight, product_name, goods_description, country_code, country_name, dest_postcode,
volume_weight, charge_weight, pkg_length, pkg_width, pkg_height, currency, total_amount, actual_weight,
remark, create_by, create_time volume_weight, charge_weight, pkg_length, pkg_width, pkg_height, currency,
) values ( total_amount,
#{arBillId}, #{prcOrderBillId}, #{detailNo}, #{orderNo}, #{trackingNo}, #{receiveTime}, remark, create_by, create_time)
#{productCode}, #{productName}, #{goodsDescription}, #{countryCode}, #{countryName}, values (#{arBillId}, #{prcOrderBillId}, #{detailNo}, #{orderNo}, #{trackingNo}, #{receiveTime},
#{destPostcode}, #{actualWeight}, #{volumeWeight}, #{chargeWeight}, #{pkgLength}, #{pkgWidth}, #{productCode}, #{productName}, #{goodsDescription}, #{countryCode}, #{countryName},
#{pkgHeight}, #{currency}, #{totalAmount}, #{remark}, #{createBy}, sysdate() #{destPostcode}, #{actualWeight}, #{volumeWeight}, #{chargeWeight}, #{pkgLength}, #{pkgWidth},
) #{pkgHeight}, #{currency}, #{totalAmount}, #{remark}, #{createBy}, sysdate())
</insert> </insert>
<insert id="insertBatch" parameterType="java.util.List" useGeneratedKeys="true" keyProperty="detailId"> <insert id="insertBatch" parameterType="java.util.List" useGeneratedKeys="true" keyProperty="detailId">
@@ -107,4 +133,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
#{item.currency}, #{item.totalAmount}, #{item.remark}, #{item.createBy}, sysdate()) #{item.currency}, #{item.totalAmount}, #{item.remark}, #{item.createBy}, sysdate())
</foreach> </foreach>
</insert> </insert>
<delete id="deleteByBillId" parameterType="long">
delete from ar_bill_detail where ar_bill_id = #{arBillId}
</delete>
</mapper> </mapper>
@@ -3,13 +3,18 @@
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd"> "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.ar.mapper.ArBillExportMapper"> <mapper namespace="com.ruoyi.ar.mapper.ArBillExportMapper">
<!-- 导出任务和历史记录;PROCESSING/SUCCESS/FAILED 由异步执行器更新。 -->
<resultMap id="ArBillExportResult" type="ArBillExport"> <resultMap id="ArBillExportResult" type="ArBillExport">
<id property="exportId" column="export_id"/> <id property="exportId" column="export_id"/>
<result property="arBillId" column="ar_bill_id"/> <result property="arBillId" column="ar_bill_id"/>
<result property="fileName" column="file_name"/> <result property="fileName" column="file_name"/>
<result property="fileType" column="file_type"/> <result property="fileType" column="file_type"/>
<result property="templateId" column="template_id"/>
<result property="templateCode" column="template_code"/> <result property="templateCode" column="template_code"/>
<result property="templateVersion" column="template_version"/> <result property="templateVersion" column="template_version"/>
<result property="templateStorageType" column="template_storage_type"/>
<result property="templateStorageKey" column="template_storage_key"/>
<result property="templateHash" column="template_hash"/>
<result property="storageKey" column="storage_key"/> <result property="storageKey" column="storage_key"/>
<result property="fileSize" column="file_size"/> <result property="fileSize" column="file_size"/>
<result property="fileHash" column="file_hash"/> <result property="fileHash" column="file_hash"/>
@@ -21,8 +26,24 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</resultMap> </resultMap>
<sql id="selectExportColumns"> <sql id="selectExportColumns">
select export_id, ar_bill_id, file_name, file_type, template_code, template_version, select export_id,
storage_key, file_size, file_hash, row_count, export_status, export_by, export_time, remark 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 from ar_bill_export
</sql> </sql>
@@ -40,13 +61,43 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
order by export_time desc limit 1 order by export_time desc limit 1
</select> </select>
<select id="selectProcessingByBillId" parameterType="long" resultMap="ArBillExportResult">
<include refid="selectExportColumns"/>
where ar_bill_id = #{arBillId} and export_status = 'PROCESSING'
order by export_id desc limit 1
</select>
<insert id="insert" parameterType="ArBillExport" useGeneratedKeys="true" keyProperty="exportId"> <insert id="insert" parameterType="ArBillExport" useGeneratedKeys="true" keyProperty="exportId">
insert into ar_bill_export ( 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 file_size, file_hash, row_count, export_status, export_by, export_time, remark
) values ( ) values (
#{arBillId}, #{fileName}, #{fileType}, #{templateCode}, #{templateVersion}, #{storageKey}, #{arBillId}, #{fileName}, #{fileType}, #{templateId}, #{templateCode}, #{templateVersion},
#{templateStorageType}, #{templateStorageKey}, #{templateHash}, #{storageKey},
#{fileSize}, #{fileHash}, #{rowCount}, #{exportStatus}, #{exportBy}, sysdate(), #{remark} #{fileSize}, #{fileHash}, #{rowCount}, #{exportStatus}, #{exportBy}, sysdate(), #{remark}
) )
</insert> </insert>
<update id="markSuccess" parameterType="ArBillExport">
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>
<update id="markFailed">
update ar_bill_export
set export_status = 'FAILED', remark = #{remark}
where export_id = #{exportId} and export_status = 'PROCESSING'
</update>
<update id="markExpiredProcessingAsFailed">
update ar_bill_export
set export_status = 'FAILED', remark = #{remark}
where export_status = 'PROCESSING' and export_time &lt; #{expiredBefore}
</update>
</mapper> </mapper>
@@ -0,0 +1,175 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.ar.mapper.ArBillGenerateTaskMapper">
<!-- 生成任务表是异步任务的持久化队列;状态更新均带前置状态条件,保证多节点抢占幂等。 -->
<resultMap id="ArBillGenerateTaskResult" type="ArBillGenerateTask">
<id property="taskId" column="task_id"/>
<result property="taskNo" column="task_no"/>
<result property="arBillId" column="ar_bill_id"/>
<result property="selectionMode" column="selection_mode"/>
<result property="requestPayload" column="request_payload"/>
<result property="snapshotCreateTime" column="snapshot_create_time"/>
<result property="lastSourceBillId" column="last_source_bill_id"/>
<result property="totalCount" column="total_count"/>
<result property="processedCount" column="processed_count"/>
<result property="successCount" column="success_count"/>
<result property="totalAmount" column="total_amount"/>
<result property="status" column="status"/>
<result property="errorMessage" column="error_message"/>
<result property="requestToken" column="request_token"/>
<result property="createBy" column="create_by"/>
<result property="createTime" column="create_time"/>
<result property="startedAt" column="started_at"/>
<result property="heartbeatTime" column="heartbeat_time"/>
<result property="finishedAt" column="finished_at"/>
</resultMap>
<sql id="columns">
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
</sql>
<select id="selectById" parameterType="long" resultMap="ArBillGenerateTaskResult">
<include refid="columns"/>
where task_id = #{taskId}
</select>
<select id="selectByRequestToken" parameterType="string" resultMap="ArBillGenerateTaskResult">
<include refid="columns"/>
where request_token = #{requestToken}
</select>
<!-- 前端恢复页面时查询当前用户仍需展示的任务。 -->
<select id="selectActiveByCreateBy" resultMap="ArBillGenerateTaskResult">
<include refid="columns"/>
where create_by = #{createBy}
and status in ('WAITING', 'RUNNING', 'CANCEL_REQUESTED')
order by create_time desc, task_id desc
</select>
<!-- 定时调度器按创建时间顺序取待处理任务。 -->
<select id="selectWaiting" resultMap="ArBillGenerateTaskResult">
<include refid="columns"/>
where status = 'WAITING'
order by create_time asc, task_id asc
limit #{limit}
</select>
<insert id="insert" parameterType="ArBillGenerateTask" useGeneratedKeys="true" keyProperty="taskId">
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})
</insert>
<!-- 条件更新抢占任务;返回 1 才表示当前节点真正拿到执行权。 -->
<update id="markRunning">
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>
<update id="updateTotalCount">
update ar_bill_generate_task
set total_count = #{totalCount},
heartbeat_time = sysdate()
where task_id = #{taskId}
and status = 'RUNNING'
</update>
<!-- 每批成功后写入游标、计数和心跳;游标用于故障恢复后继续扫描。 -->
<update id="updateProgress">
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>
<update id="markSuccess">
update ar_bill_generate_task
set status = 'SUCCESS',
finished_at = #{finishedAt},
heartbeat_time = #{finishedAt}
where task_id = #{taskId}
and status = 'RUNNING'
</update>
<update id="markFailed">
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>
<update id="markCancelRequested">
update ar_bill_generate_task
set status = 'CANCEL_REQUESTED',
heartbeat_time = sysdate()
where task_id = #{taskId}
and status in ('WAITING', 'RUNNING')
</update>
<update id="markCanceled">
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>
<!-- 执行节点宕机时恢复无心跳的 RUNNING 任务,供调度器重新投递。 -->
<update id="resetExpiredRunning">
update ar_bill_generate_task
set status = 'WAITING',
error_message = '任务执行节点已重启,已自动恢复等待处理',
heartbeat_time = null
where status = 'RUNNING'
and heartbeat_time &lt; #{expiredBefore}
</update>
<!-- 取消请求长时间未完成时由补偿任务取出并执行数据清理。 -->
<select id="selectCancelRequestedBefore" resultMap="ArBillGenerateTaskResult">
<include refid="columns"/>
where status = 'CANCEL_REQUESTED'
and coalesce(heartbeat_time, create_time) &lt; #{expiredBefore}
order by create_time asc, task_id asc
limit #{limit}
</select>
</mapper>
@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8" ?> <?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper <!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd"> "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.ar.mapper.ArBillLogMapper"> <mapper namespace="com.ruoyi.ar.mapper.ArBillLogMapper">
<resultMap id="ArBillLogResult" type="ArBillLog"> <resultMap id="ArBillLogResult" type="ArBillLog">
<id property="logId" column="log_id"/> <id property="logId" column="log_id"/>
@@ -28,24 +28,27 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<select id="selectList" parameterType="ArBillLog" resultMap="ArBillLogResult"> <select id="selectList" parameterType="ArBillLog" resultMap="ArBillLogResult">
select l.log_id, l.ar_bill_id, l.object_type, l.object_id, l.operation_type, l.operation_desc, select l.log_id, l.ar_bill_id, l.object_type, l.object_id, l.operation_type, l.operation_desc,
case l.object_type case l.object_type
when 'BILL' then b.bill_no when 'BILL' then b.bill_no
when 'EXPORT' then coalesce(e.file_name, b.bill_no) when 'EXPORT' then coalesce(e.file_name, b.bill_no)
when 'BALANCE_WRITEOFF' then bw.writeoff_no when 'BALANCE_WRITEOFF' then bw.writeoff_no
else null else null
end as object_display, end as object_display,
l.from_status, l.to_status, l.before_data, l.after_data, l.operator_id, l.operator_name, l.from_status, l.to_status, l.before_data, l.after_data, l.operator_id, l.operator_name,
l.operation_source, l.request_id, l.trace_id, l.result, l.error_message, l.client_ip, l.create_time l.operation_source, l.request_id, l.trace_id, l.result, l.error_message, l.client_ip, l.create_time
from ar_bill_log l from ar_bill_log l
left join ar_bill b on b.ar_bill_id = l.ar_bill_id left join ar_bill b on b.ar_bill_id = l.ar_bill_id
left join ar_bill_export e on e.export_id = case when l.object_type = 'EXPORT' then cast(l.object_id as unsigned) end left join ar_bill_export e on e.export_id = case when l.object_type = 'EXPORT' then cast(l.object_id as
left join ar_balance_writeoff bw on bw.writeoff_id = case when l.object_type = 'BALANCE_WRITEOFF' then cast(l.object_id as unsigned) end unsigned) end
left join ar_balance_writeoff bw on bw.writeoff_id = case when l.object_type = 'BALANCE_WRITEOFF' then
cast(l.object_id as unsigned) end
<where> <where>
<if test="arBillId != null">and l.ar_bill_id = #{arBillId}</if> <if test="arBillId != null">and l.ar_bill_id = #{arBillId}</if>
<if test="objectType != null and objectType != ''">and l.object_type = #{objectType}</if> <if test="objectType != null and objectType != ''">and l.object_type = #{objectType}</if>
<if test="objectId != null and objectId != ''">and l.object_id = #{objectId}</if> <if test="objectId != null and objectId != ''">and l.object_id = #{objectId}</if>
<if test="operationType != null and operationType != ''">and l.operation_type = #{operationType}</if> <if test="operationType != null and operationType != ''">and l.operation_type = #{operationType}</if>
<if test="operationSource != null and operationSource != ''">and l.operation_source = #{operationSource}</if> <if test="operationSource != null and operationSource != ''">and l.operation_source = #{operationSource}
</if>
<if test="requestId != null and requestId != ''">and l.request_id = #{requestId}</if> <if test="requestId != null and requestId != ''">and l.request_id = #{requestId}</if>
<if test="result != null and result != ''">and l.result = #{result}</if> <if test="result != null and result != ''">and l.result = #{result}</if>
</where> </where>
@@ -53,23 +56,39 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</select> </select>
<select id="selectSuccessByRequestId" resultMap="ArBillLogResult"> <select id="selectSuccessByRequestId" resultMap="ArBillLogResult">
select log_id, ar_bill_id, object_type, object_id, operation_type, operation_desc, select log_id,
from_status, to_status, before_data, after_data, operator_id, operator_name, ar_bill_id,
operation_source, request_id, trace_id, result, error_message, client_ip, create_time 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
from ar_bill_log from ar_bill_log
where request_id = #{requestId} and object_type = #{objectType} and result = #{result} where request_id = #{requestId}
and object_type = #{objectType}
and result = #{result}
order by log_id limit 1 order by log_id limit 1
</select> </select>
<insert id="insert" parameterType="ArBillLog" useGeneratedKeys="true" keyProperty="logId"> <insert id="insert" parameterType="ArBillLog" useGeneratedKeys="true" keyProperty="logId">
insert into ar_bill_log ( insert into ar_bill_log (ar_bill_id, object_type, object_id, operation_type, operation_desc, from_status,
ar_bill_id, object_type, object_id, operation_type, operation_desc, from_status, to_status, to_status,
before_data, after_data, operator_id, operator_name, operation_source, request_id, before_data, after_data, operator_id, operator_name, operation_source, request_id,
trace_id, result, error_message, client_ip, create_time trace_id, result, error_message, client_ip, create_time)
) values ( values (#{arBillId}, #{objectType}, #{objectId}, #{operationType}, #{operationDesc}, #{fromStatus}, #{toStatus},
#{arBillId}, #{objectType}, #{objectId}, #{operationType}, #{operationDesc}, #{fromStatus}, #{toStatus}, #{beforeData}, #{afterData}, #{operatorId}, #{operatorName}, #{operationSource}, #{requestId},
#{beforeData}, #{afterData}, #{operatorId}, #{operatorName}, #{operationSource}, #{requestId}, #{traceId}, #{result}, #{errorMessage}, #{clientIp}, sysdate())
#{traceId}, #{result}, #{errorMessage}, #{clientIp}, sysdate()
)
</insert> </insert>
</mapper> </mapper>
@@ -1,8 +1,9 @@
<?xml version="1.0" encoding="UTF-8" ?> <?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper <!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd"> "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.ar.mapper.ArBillMapper"> <mapper namespace="com.ruoyi.ar.mapper.ArBillMapper">
<!-- 账单头查询、状态流转和金额/生成进度更新。带 ForUpdate 的查询用于核销并发控制。 -->
<resultMap id="ArBillResult" type="ArBill"> <resultMap id="ArBillResult" type="ArBill">
<id property="arBillId" column="ar_bill_id"/> <id property="arBillId" column="ar_bill_id"/>
<result property="billNo" column="bill_no"/> <result property="billNo" column="bill_no"/>
@@ -19,6 +20,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<result property="writtenOffAmount" column="written_off_amount"/> <result property="writtenOffAmount" column="written_off_amount"/>
<result property="unwrittenAmount" column="unwritten_amount"/> <result property="unwrittenAmount" column="unwritten_amount"/>
<result property="status" column="status"/> <result property="status" column="status"/>
<result property="generationStatus" column="generation_status"/>
<result property="billVersion" column="bill_version"/> <result property="billVersion" column="bill_version"/>
<result property="bankAccountName" column="bank_account_name"/> <result property="bankAccountName" column="bank_account_name"/>
<result property="bankAccountNo" column="bank_account_no"/> <result property="bankAccountNo" column="bank_account_no"/>
@@ -33,21 +35,48 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</resultMap> </resultMap>
<sql id="selectArBillColumns"> <sql id="selectArBillColumns">
select ar_bill_id, bill_no, bill_name, customer_id, customer_code, customer_name, select ar_bill_id,
bill_period_start, bill_period_end, bill_date, currency, order_count, bill_no,
total_amount, written_off_amount, unwritten_amount, status, bill_version, bill_name,
bank_account_name, bank_account_no, bank_name, remark, confirmed_by, customer_id,
confirmed_time, create_by, create_time, update_by, update_time 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 from ar_bill
</sql> </sql>
<select id="selectArBillList" parameterType="com.ruoyi.ar.domain.vo.ArBillQuery" resultMap="ArBillResult"> <select id="selectArBillList" parameterType="com.ruoyi.ar.domain.vo.ArBillQuery" resultMap="ArBillResult">
<include refid="selectArBillColumns"/> <include refid="selectArBillColumns"/>
<where> <where>
and (generation_status is null or generation_status = 'READY')
<if test="billNo != null and billNo != ''">and bill_no like concat('%', #{billNo}, '%')</if> <if test="billNo != null and billNo != ''">and bill_no like concat('%', #{billNo}, '%')</if>
<if test="billName != null and billName != ''">and bill_name like concat('%', #{billName}, '%')</if> <if test="billName != null and billName != ''">and bill_name like concat('%', #{billName}, '%')</if>
<if test="customerCode != null and customerCode != ''">and customer_code like concat('%', #{customerCode}, '%')</if> <if test="customerCode != null and customerCode != ''">and customer_code like concat('%', #{customerCode},
<if test="customerName != null and customerName != ''">and customer_name like concat('%', #{customerName}, '%')</if> '%')
</if>
<if test="customerName != null and customerName != ''">and customer_name like concat('%', #{customerName},
'%')
</if>
<if test="customerId != null">and customer_id = #{customerId}</if> <if test="customerId != null">and customer_id = #{customerId}</if>
<if test="status != null and status != ''">and status = #{status}</if> <if test="status != null and status != ''">and status = #{status}</if>
<if test="currency != null and currency != ''">and currency = #{currency}</if> <if test="currency != null and currency != ''">and currency = #{currency}</if>
@@ -59,11 +88,23 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
</select> </select>
<select id="selectArBillById" parameterType="long" resultMap="ArBillResult"> <select id="selectArBillById" parameterType="long" resultMap="ArBillResult">
<include refid="selectArBillColumns"/> where ar_bill_id = #{arBillId} <include refid="selectArBillColumns"/>
where ar_bill_id = #{arBillId}
</select> </select>
<select id="selectArBillByBillNo" parameterType="string" resultMap="ArBillResult"> <select id="selectArBillByBillNo" parameterType="string" resultMap="ArBillResult">
<include refid="selectArBillColumns"/> where bill_no = #{billNo} <include refid="selectArBillColumns"/>
where bill_no = #{billNo}
</select>
<select id="selectGeneratingByCustomerPeriod" resultMap="ArBillResult">
<include refid="selectArBillColumns"/>
where customer_id = #{customerId}
and currency = #{currency}
and bill_period_end = #{cutoffDate}
and generation_status = 'GENERATING'
order by ar_bill_id desc
limit 1
</select> </select>
<select id="selectMaxBillVersion" resultType="int"> <select id="selectMaxBillVersion" resultType="int">
@@ -72,41 +113,47 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
where customer_id = #{customerId} where customer_id = #{customerId}
and bill_period_end = #{cutoffDate} and bill_period_end = #{cutoffDate}
and currency = #{currency} and currency = #{currency}
order by bill_version desc order by bill_version desc limit 1
limit 1 for
for update update
</select> </select>
<select id="selectArBillByIdForUpdate" parameterType="long" resultMap="ArBillResult"> <select id="selectArBillByIdForUpdate" parameterType="long" resultMap="ArBillResult">
<include refid="selectArBillColumns"/> where ar_bill_id = #{arBillId} for update <include refid="selectArBillColumns"/>
where ar_bill_id = #{arBillId} for update
</select> </select>
<select id="selectUnsettledByCustomer" resultMap="ArBillResult"> <select id="selectUnsettledByCustomer" resultMap="ArBillResult">
<include refid="selectArBillColumns"/> <include refid="selectArBillColumns"/>
where customer_id = #{customerId} where customer_id = #{customerId}
and status in ('CONFIRMED', 'PARTIAL_WRITTEN_OFF') and status in ('CONFIRMED', 'PARTIAL_WRITTEN_OFF')
and unwritten_amount &gt; 0 and (generation_status is null or generation_status = 'READY')
and unwritten_amount &gt; 0
order by bill_date asc, ar_bill_id asc order by bill_date asc, ar_bill_id asc
</select> </select>
<insert id="insertArBill" parameterType="ArBill" useGeneratedKeys="true" keyProperty="arBillId"> <insert id="insertArBill" parameterType="ArBill" useGeneratedKeys="true" keyProperty="arBillId">
insert into ar_bill ( insert into ar_bill (bill_no, bill_name, customer_id, customer_code, customer_name, bill_period_start,
bill_no, bill_name, customer_id, customer_code, customer_name, bill_period_start, bill_period_end, bill_period_end,
bill_date, currency, order_count, total_amount, written_off_amount, unwritten_amount, bill_date, currency, order_count, total_amount, written_off_amount, unwritten_amount,
status, bill_version, bank_account_name, bank_account_no, bank_name, remark, status, generation_status, bill_version, bank_account_name, bank_account_no, bank_name,
confirmed_by, confirmed_time, create_by, create_time, update_by, update_time remark,
) values ( confirmed_by, confirmed_time, create_by, create_time, update_by, update_time)
#{billNo}, #{billName}, #{customerId}, #{customerCode}, #{customerName}, #{billPeriodStart}, #{billPeriodEnd}, values (#{billNo}, #{billName}, #{customerId}, #{customerCode}, #{customerName}, #{billPeriodStart},
#{billDate}, #{currency}, #{orderCount}, #{totalAmount}, #{writtenOffAmount}, #{unwrittenAmount}, #{billPeriodEnd},
#{status}, #{billVersion}, #{bankAccountName}, #{bankAccountNo}, #{bankName}, #{remark}, #{billDate}, #{currency}, #{orderCount}, #{totalAmount}, #{writtenOffAmount}, #{unwrittenAmount},
#{confirmedBy}, #{confirmedTime}, #{createBy}, sysdate(), #{updateBy}, sysdate() #{status}, #{generationStatus}, #{billVersion}, #{bankAccountName}, #{bankAccountNo}, #{bankName},
) #{remark},
#{confirmedBy}, #{confirmedTime}, #{createBy}, sysdate(), #{updateBy}, sysdate())
</insert> </insert>
<update id="updateArBillStatus"> <update id="updateArBillStatus">
update ar_bill update ar_bill
set status = #{status}, remark = #{remark}, update_by = #{operator}, update_time = sysdate(), set status = #{status},
confirmed_by = case when #{status} = 'CONFIRMED' then #{operator} else confirmed_by end, 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 confirmed_time = case when #{status} = 'CONFIRMED' then sysdate() else confirmed_time end
where ar_bill_id = #{arBillId} where ar_bill_id = #{arBillId}
and status = #{fromStatus} and status = #{fromStatus}
@@ -114,9 +161,34 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<update id="updateArBillAmounts"> <update id="updateArBillAmounts">
update ar_bill update ar_bill
set written_off_amount = #{writtenOffAmount}, unwritten_amount = #{unwrittenAmount}, set written_off_amount = #{writtenOffAmount},
status = #{status}, update_by = #{operator}, update_time = sysdate() unwritten_amount = #{unwrittenAmount},
status = #{status},
update_by = #{operator},
update_time = sysdate()
where ar_bill_id = #{arBillId} where ar_bill_id = #{arBillId}
and status = #{fromStatus} and status = #{fromStatus}
</update> </update>
<update id="updateArBillProgress">
update ar_bill
set order_count = #{orderCount},
total_amount = #{totalAmount},
unwritten_amount = #{unwrittenAmount},
bill_period_start = case
when #{billPeriodStart} &lt; bill_period_start then #{billPeriodStart}
else bill_period_start end,
update_by = #{operator},
update_time = sysdate()
where ar_bill_id = #{arBillId}
</update>
<update id="updateArBillGenerationStatus">
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}
</update>
</mapper> </mapper>
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.ar.mapper.ArExportTemplateMapper">
<!-- 模板管理查询;导出提交时会把选中的模板版本快照到导出任务。 -->
<resultMap id="ArExportTemplateResult" type="ArExportTemplate">
<id property="templateId" column="template_id"/>
<result property="templateCode" column="template_code"/>
<result property="templateName" column="template_name"/>
<result property="businessType" column="business_type"/>
<result property="templateVersion" column="template_version"/>
<result property="storageType" column="storage_type"/>
<result property="storageKey" column="storage_key"/>
<result property="fileHash" column="file_hash"/>
<result property="status" column="status"/>
<result property="isDefault" column="is_default"/>
<result property="effectiveStart" column="effective_start"/>
<result property="effectiveEnd" column="effective_end"/>
<result property="remark" column="remark"/>
</resultMap>
<select id="selectDefaultEnabled" resultMap="ArExportTemplateResult">
select template_id,
template_code,
template_name,
business_type,
template_version,
storage_type,
storage_key,
file_hash,
status,
is_default,
effective_start,
effective_end,
remark
from ar_export_template
where business_type = #{businessType}
and status = 'ENABLED'
and is_default = '1'
and (effective_start is null or effective_start &lt;= #{effectiveDate})
and (effective_end is null or effective_end &gt;= #{effectiveDate})
order by effective_start desc, template_id desc limit 1
</select>
</mapper>
@@ -4,6 +4,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd"> "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.price.mapper.PrcOrderBillMapper"> <mapper namespace="com.ruoyi.price.mapper.PrcOrderBillMapper">
<!-- 应收账单生成依赖的来源计费订单查询;本项目的 prc_bill 需求表映射为 prc_order_bill。 -->
<resultMap type="PrcOrderBill" id="PrcOrderBillResult"> <resultMap type="PrcOrderBill" id="PrcOrderBillResult">
<id property="billId" column="bill_id" /> <id property="billId" column="bill_id" />
<result property="billKey" column="bill_key" /> <result property="billKey" column="bill_key" />
@@ -155,21 +157,107 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
order by create_time desc, bill_id desc order by create_time desc, bill_id desc
</select> </select>
<!-- The repository's rename_bill_tables.sql maps the requirement's prc_bill to prc_order_bill. --> <!-- rename_bill_tables.sql 已将需求文档中的 prc_bill 映射为本项目的 prc_order_bill。 -->
<!-- 生成弹窗预览:按客户、截止日、币种和订单/运单号 OR 条件分页查询未入账订单。 -->
<select id="selectPrcOrderBillByCustomerPeriod" resultMap="PrcOrderBillResult"> <select id="selectPrcOrderBillByCustomerPeriod" resultMap="PrcOrderBillResult">
<include refid="selectPrcOrderBillVo"/> <include refid="selectPrcOrderBillVo"/>
where customer_id = #{customerId} where customer_id = #{customerId}
<if test="customerCode != null and customerCode != ''"> <if test="customerCode != null and customerCode != ''">
and customer_code like concat('%', #{customerCode}, '%') and customer_code like concat('%', #{customerCode}, '%')
</if>
<if test="orderOrTrackingNo != null and orderOrTrackingNo != ''">
and (order_no like concat('%', #{orderOrTrackingNo}, '%')
or tracking_no like concat('%', #{orderOrTrackingNo}, '%'))
</if>
<if test="excludeOrderOrTrackingNo != null and excludeOrderOrTrackingNo != ''">
and (order_no like concat('%', #{excludeOrderOrTrackingNo}, '%')
or tracking_no like concat('%', #{excludeOrderOrTrackingNo}, '%'))
</if> </if>
and biz_date &lt;= #{cutoffDate} and biz_date &lt;= #{cutoffDate}
and ar_bill_status = #{arBillStatus} and ar_bill_status = #{arBillStatus}
<if test="currency != null and currency != ''"> <if test="currency != null and currency != ''">
and currency = #{currency} and currency = #{currency}
</if> </if>
<if test="excludedBillIds != null and excludedBillIds.size() > 0">
and bill_id not in
<foreach collection="excludedBillIds" item="excludedBillId" open="(" separator="," close=")">
#{excludedBillId}
</foreach>
</if>
order by biz_date, order_no, bill_id order by biz_date, order_no, bill_id
</select> </select>
<select id="sumPrcOrderBillIncomeByCustomerPeriod" resultType="java.math.BigDecimal">
select coalesce(sum(income_total), 0)
from prc_order_bill
where customer_id = #{customerId}
<if test="customerCode != null and customerCode != ''">
and customer_code like concat('%', #{customerCode}, '%')
</if>
<if test="orderOrTrackingNo != null and orderOrTrackingNo != ''">
and (order_no like concat('%', #{orderOrTrackingNo}, '%')
or tracking_no like concat('%', #{orderOrTrackingNo}, '%'))
</if>
<if test="excludeOrderOrTrackingNo != null and excludeOrderOrTrackingNo != ''">
and (order_no like concat('%', #{excludeOrderOrTrackingNo}, '%')
or tracking_no like concat('%', #{excludeOrderOrTrackingNo}, '%'))
</if>
and biz_date &lt;= #{cutoffDate}
and ar_bill_status = #{arBillStatus}
<if test="currency != null and currency != ''">
and currency = #{currency}
</if>
</select>
<sql id="customerPeriodConditions">
where customer_id = #{customerId}
<if test="customerCode != null and customerCode != ''">
and customer_code like concat('%', #{customerCode}, '%')
</if>
<if test="orderOrTrackingNo != null and orderOrTrackingNo != ''">
and (order_no like concat('%', #{orderOrTrackingNo}, '%')
or tracking_no like concat('%', #{orderOrTrackingNo}, '%'))
</if>
and biz_date &lt;= #{cutoffDate}
and ar_bill_status = #{arBillStatus}
<if test="currency != null and currency != ''">
and currency = #{currency}
</if>
<if test="snapshotCreateTime != null">
and (create_time is null or create_time &lt;= #{snapshotCreateTime})
</if>
<if test="billIds != null and billIds.size() > 0">
and bill_id in
<foreach collection="billIds" item="billId" open="(" separator="," close=")">
#{billId}
</foreach>
</if>
<if test="excludedBillIds != null and excludedBillIds.size() > 0">
and bill_id not in
<foreach collection="excludedBillIds" item="excludedBillId" open="(" separator="," close=")">
#{excludedBillId}
</foreach>
</if>
</sql>
<!-- 异步生成使用 bill_id 游标批量读取,避免深分页和一次加载全量订单。 -->
<select id="selectPrcOrderBillByCustomerPeriodCursor" resultMap="PrcOrderBillResult">
<include refid="selectPrcOrderBillVo"/>
<include refid="customerPeriodConditions"/>
<if test="lastSourceBillId != null and lastSourceBillId != ''">
and bill_id &gt; #{lastSourceBillId}
</if>
order by bill_id
limit #{limit}
</select>
<!-- 全选模式先统计总数,用于任务进度和空范围校验。 -->
<select id="countPrcOrderBillByCustomerPeriod" resultType="int">
select count(1)
from prc_order_bill
<include refid="customerPeriodConditions"/>
</select>
<select id="selectPrcOrderBillByCustomerCutoffAndIds" resultMap="PrcOrderBillResult"> <select id="selectPrcOrderBillByCustomerCutoffAndIds" resultMap="PrcOrderBillResult">
<include refid="selectPrcOrderBillVo"/> <include refid="selectPrcOrderBillVo"/>
where customer_id = #{customerId} where customer_id = #{customerId}
@@ -182,6 +270,10 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="customerCode != null and customerCode != ''"> <if test="customerCode != null and customerCode != ''">
and customer_code like concat('%', #{customerCode}, '%') and customer_code like concat('%', #{customerCode}, '%')
</if> </if>
<if test="orderOrTrackingNo != null and orderOrTrackingNo != ''">
and (order_no like concat('%', #{orderOrTrackingNo}, '%')
or tracking_no like concat('%', #{orderOrTrackingNo}, '%'))
</if>
<if test="currency != null and currency != ''"> <if test="currency != null and currency != ''">
and currency = #{currency} and currency = #{currency}
</if> </if>
@@ -280,6 +372,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
where bill_id = #{billId} where bill_id = #{billId}
</update> </update>
<!-- 抢占来源订单:只有未入账订单可以变更为已入账,防止重复生成。 -->
<update id="claimArBillOrders"> <update id="claimArBillOrders">
update prc_order_bill update prc_order_bill
set ar_bill_status = #{toStatus}, updated_by = #{operator}, update_time = sysdate() set ar_bill_status = #{toStatus}, updated_by = #{operator}, update_time = sysdate()
@@ -299,4 +392,12 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
#{billId} #{billId}
</foreach> </foreach>
</update> </update>
<update id="releaseArBillOrdersByArBillId">
update prc_order_bill p
inner join ar_bill_detail d on d.prc_order_bill_id = p.bill_id
set p.ar_bill_status = #{toStatus}, p.updated_by = #{operator}, p.update_time = sysdate()
where d.ar_bill_id = #{arBillId}
and p.ar_bill_status = #{fromStatus}
</update>
</mapper> </mapper>
+211
View File
@@ -22,6 +22,7 @@ CREATE TABLE IF NOT EXISTS ar_bill (
written_off_amount DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '已核销金额缓存', written_off_amount DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '已核销金额缓存',
unwritten_amount DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '未核销金额缓存', unwritten_amount DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '未核销金额缓存',
status VARCHAR(30) NOT NULL COMMENT '状态:DRAFT=草稿,CONFIRMED=已确认,PARTIAL_WRITTEN_OFF=部分核销,WRITTEN_OFF=已核销,VOID=已作废', status VARCHAR(30) NOT NULL COMMENT '状态:DRAFT=草稿,CONFIRMED=已确认,PARTIAL_WRITTEN_OFF=部分核销,WRITTEN_OFF=已核销,VOID=已作废',
generation_status VARCHAR(20) NOT NULL DEFAULT 'READY' COMMENT '生成状态:GENERATING=生成中,READY=可用,FAILED=生成失败',
bill_version INT(11) NOT NULL DEFAULT 1 COMMENT '账单版本', bill_version INT(11) NOT NULL DEFAULT 1 COMMENT '账单版本',
bank_account_name VARCHAR(100) DEFAULT NULL COMMENT '收款账户名称快照', bank_account_name VARCHAR(100) DEFAULT NULL COMMENT '收款账户名称快照',
bank_account_no VARCHAR(128) DEFAULT NULL COMMENT '收款账号快照,应用层脱敏展示', bank_account_no VARCHAR(128) DEFAULT NULL COMMENT '收款账号快照,应用层脱敏展示',
@@ -40,6 +41,34 @@ CREATE TABLE IF NOT EXISTS ar_bill (
KEY idx_ar_bill_status (status) KEY idx_ar_bill_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='正式应收账单主表'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='正式应收账单主表';
CREATE TABLE IF NOT EXISTS ar_bill_generate_task (
task_id BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '生成任务ID',
task_no VARCHAR(50) NOT NULL COMMENT '生成任务编号',
ar_bill_id BIGINT(20) NOT NULL COMMENT '应收账单ID',
selection_mode VARCHAR(20) NOT NULL COMMENT '选择模式:EXPLICIT=手动选择,ALL_MATCHED=全选查询结果',
request_payload LONGTEXT NOT NULL COMMENT '生成请求快照JSON',
snapshot_create_time DATETIME DEFAULT NULL COMMENT '来源订单创建时间快照',
last_source_bill_id VARCHAR(36) DEFAULT NULL COMMENT '来源订单游标',
total_count INT(11) NOT NULL DEFAULT -1 COMMENT '待处理订单数,-1表示尚未统计',
processed_count INT(11) NOT NULL DEFAULT 0 COMMENT '已处理订单数',
success_count INT(11) NOT NULL DEFAULT 0 COMMENT '成功订单数',
total_amount DECIMAL(18,2) NOT NULL DEFAULT 0.00 COMMENT '当前生成金额',
status VARCHAR(30) NOT NULL COMMENT '任务状态:WAITING=待处理,RUNNING=生成中,SUCCESS=生成成功,FAILED=生成失败,CANCEL_REQUESTED=取消中,CANCELED=已取消',
error_message VARCHAR(1000) DEFAULT NULL COMMENT '错误信息',
request_token VARCHAR(64) NOT NULL COMMENT '请求幂等标识',
create_by VARCHAR(64) NOT NULL COMMENT '创建人',
create_time DATETIME NOT NULL COMMENT '创建时间',
started_at DATETIME DEFAULT NULL COMMENT '开始时间',
heartbeat_time DATETIME DEFAULT NULL COMMENT '最近心跳时间',
finished_at DATETIME DEFAULT NULL COMMENT '完成时间',
PRIMARY KEY (task_id),
UNIQUE KEY uk_ar_bill_generate_task_no (task_no),
UNIQUE KEY uk_ar_bill_generate_request_token (request_token),
KEY idx_ar_bill_generate_status (status, create_time),
KEY idx_ar_bill_generate_creator (create_by, status, create_time),
KEY idx_ar_bill_generate_bill (ar_bill_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='应收账单异步生成任务';
CREATE TABLE IF NOT EXISTS ar_bill_detail ( CREATE TABLE IF NOT EXISTS ar_bill_detail (
detail_id BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '账单订单明细ID', detail_id BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '账单订单明细ID',
ar_bill_id BIGINT(20) NOT NULL COMMENT '应收账单ID', ar_bill_id BIGINT(20) NOT NULL COMMENT '应收账单ID',
@@ -98,13 +127,54 @@ CREATE TABLE IF NOT EXISTS ar_bill_detail_fee (
CONSTRAINT fk_ar_detail_fee_detail FOREIGN KEY (detail_id) REFERENCES ar_bill_detail (detail_id) CONSTRAINT fk_ar_detail_fee_detail FOREIGN KEY (detail_id) REFERENCES ar_bill_detail (detail_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='应收账单费用快照'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='应收账单费用快照';
CREATE TABLE IF NOT EXISTS ar_export_template (
template_id BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '导出模板ID',
template_code VARCHAR(50) NOT NULL COMMENT '模板编码,例如 AR_CUSTOMER_BILL',
template_name VARCHAR(100) NOT NULL COMMENT '模板名称',
business_type VARCHAR(50) NOT NULL COMMENT '业务类型,例如 AR_BILL',
template_version VARCHAR(50) NOT NULL COMMENT '模板版本,已启用版本不可覆盖',
storage_type VARCHAR(20) NOT NULL DEFAULT 'CLASSPATH' COMMENT '存储类型:CLASSPATH=应用内置资源,FILE=服务器文件,OSS=对象存储,MINIO=MinIO对象存储',
storage_key VARCHAR(500) NOT NULL COMMENT '模板文件存储Key或classpath资源路径',
file_hash VARCHAR(128) DEFAULT NULL COMMENT '模板文件SHA-256校验值',
status VARCHAR(20) NOT NULL DEFAULT 'ENABLED' COMMENT '状态:ENABLED=启用,DISABLED=停用',
is_default CHAR(1) NOT NULL DEFAULT '0' COMMENT '是否默认模板:1是0否',
effective_start DATE DEFAULT NULL COMMENT '生效开始日期',
effective_end DATE DEFAULT NULL COMMENT '生效结束日期',
remark VARCHAR(500) DEFAULT NULL COMMENT '备注',
create_by VARCHAR(64) NOT NULL COMMENT '创建人',
create_time DATETIME NOT NULL COMMENT '创建时间',
update_by VARCHAR(64) DEFAULT NULL COMMENT '更新人',
update_time DATETIME DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (template_id),
UNIQUE KEY uk_ar_export_template_code_version (business_type, template_code, template_version),
KEY idx_ar_export_template_default (business_type, status, is_default, effective_start, effective_end)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='应收账单导出模板管理';
INSERT INTO ar_export_template (
template_code, template_name, business_type, template_version, storage_type, storage_key,
file_hash, status, is_default, create_by, create_time, remark
)
SELECT
'AR_CUSTOMER_BILL', '应收账单默认模板', 'AR_BILL', '1.0', 'CLASSPATH',
'templates/ar/ar_bill_template.xlsx', '735002ac3561e0579b724c0612a2c5ce2d6725bc7fd39c450b0aeff70911a45e', 'ENABLED', '1', 'system', NOW(), '系统内置默认模板'
WHERE NOT EXISTS (
SELECT 1 FROM ar_export_template
WHERE business_type = 'AR_BILL'
AND template_code = 'AR_CUSTOMER_BILL'
AND template_version = '1.0'
);
CREATE TABLE IF NOT EXISTS ar_bill_export ( CREATE TABLE IF NOT EXISTS ar_bill_export (
export_id BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '导出ID', export_id BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '导出ID',
ar_bill_id BIGINT(20) NOT NULL COMMENT '应收账单ID', ar_bill_id BIGINT(20) NOT NULL COMMENT '应收账单ID',
file_name VARCHAR(255) NOT NULL COMMENT '下载展示名称', file_name VARCHAR(255) NOT NULL COMMENT '下载展示名称',
file_type VARCHAR(20) NOT NULL COMMENT 'XLSX/PDF', file_type VARCHAR(20) NOT NULL COMMENT 'XLSX/PDF',
template_id BIGINT(20) DEFAULT NULL COMMENT '提交导出任务时绑定的模板ID',
template_code VARCHAR(50) NOT NULL COMMENT '导出模板标识', template_code VARCHAR(50) NOT NULL COMMENT '导出模板标识',
template_version VARCHAR(50) DEFAULT NULL COMMENT '模板版本', template_version VARCHAR(50) DEFAULT NULL COMMENT '模板版本',
template_storage_type VARCHAR(20) DEFAULT NULL COMMENT '绑定模板存储类型',
template_storage_key VARCHAR(500) DEFAULT NULL COMMENT '绑定模板存储Key',
template_hash VARCHAR(128) DEFAULT NULL COMMENT '绑定模板SHA-256校验值',
storage_key VARCHAR(500) NOT NULL COMMENT '对象存储或本地文件Key', storage_key VARCHAR(500) NOT NULL COMMENT '对象存储或本地文件Key',
file_size BIGINT(20) DEFAULT NULL COMMENT '文件字节数', file_size BIGINT(20) DEFAULT NULL COMMENT '文件字节数',
file_hash VARCHAR(128) DEFAULT NULL COMMENT 'SHA-256校验值', file_hash VARCHAR(128) DEFAULT NULL COMMENT 'SHA-256校验值',
@@ -115,6 +185,7 @@ CREATE TABLE IF NOT EXISTS ar_bill_export (
remark VARCHAR(500) DEFAULT NULL COMMENT '失败原因或备注', remark VARCHAR(500) DEFAULT NULL COMMENT '失败原因或备注',
PRIMARY KEY (export_id), PRIMARY KEY (export_id),
KEY idx_ar_bill_export_bill (ar_bill_id, export_time), KEY idx_ar_bill_export_bill (ar_bill_id, export_time),
KEY idx_ar_bill_export_template (template_id),
KEY idx_ar_bill_export_status (export_status), KEY idx_ar_bill_export_status (export_status),
CONSTRAINT fk_ar_bill_export_bill FOREIGN KEY (ar_bill_id) REFERENCES ar_bill (ar_bill_id) CONSTRAINT fk_ar_bill_export_bill FOREIGN KEY (ar_bill_id) REFERENCES ar_bill (ar_bill_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='应收账单导出文件元数据'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='应收账单导出文件元数据';
@@ -145,4 +216,144 @@ CREATE TABLE IF NOT EXISTS ar_bill_log (
KEY idx_ar_bill_log_request (request_id) KEY idx_ar_bill_log_request (request_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='应收账单操作日志'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='应收账单操作日志';
-- ============================================================
-- 兼容已执行旧版本脚本的增量补齐逻辑
-- 本文件是应收账单模块唯一数据库脚本,新库和存量库均可重复执行。
-- ============================================================
SET @ar_db_name := DATABASE();
-- 1. 异步生成任务依赖字段
SET @ar_has_generation_status := (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @ar_db_name
AND TABLE_NAME = 'ar_bill'
AND COLUMN_NAME = 'generation_status'
);
SET @ar_sql := IF(@ar_has_generation_status = 0,
'ALTER TABLE ar_bill ADD COLUMN generation_status VARCHAR(20) NOT NULL DEFAULT ''READY'' COMMENT ''生成状态:GENERATING=生成中,READY=可用,FAILED=生成失败'' AFTER status',
'SELECT 1');
PREPARE ar_stmt FROM @ar_sql;
EXECUTE ar_stmt;
DEALLOCATE PREPARE ar_stmt;
-- 异步生成任务状态增加中文含义,便于数据库运维和排查。
-- 已存在 status 时只修改定义,不重复新增;兼容历史上已创建但字段不完整的任务表。
SET @ar_has_task_status := (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @ar_db_name
AND TABLE_NAME = 'ar_bill_generate_task'
AND COLUMN_NAME = 'status'
);
SET @ar_sql := IF(@ar_has_task_status = 0,
'ALTER TABLE ar_bill_generate_task ADD COLUMN status VARCHAR(30) NOT NULL COMMENT ''任务状态:WAITING=待处理,RUNNING=生成中,SUCCESS=生成成功,FAILED=生成失败,CANCEL_REQUESTED=取消中,CANCELED=已取消'' AFTER total_amount',
'ALTER TABLE ar_bill_generate_task MODIFY COLUMN status VARCHAR(30) NOT NULL COMMENT ''任务状态:WAITING=待处理,RUNNING=生成中,SUCCESS=生成成功,FAILED=生成失败,CANCEL_REQUESTED=取消中,CANCELED=已取消''');
PREPARE ar_stmt FROM @ar_sql;
EXECUTE ar_stmt;
DEALLOCATE PREPARE ar_stmt;
UPDATE ar_bill
SET generation_status = 'READY'
WHERE generation_status IS NULL;
-- 2. 为异步游标查询补充来源订单索引。
-- 来源表可能尚未由价格模块脚本创建,因此这里做存在性判断。
SET @ar_has_source_table := (
SELECT COUNT(*)
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = @ar_db_name
AND TABLE_NAME = 'prc_order_bill'
);
SET @ar_has_source_cursor_index := (
SELECT COUNT(*)
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = @ar_db_name
AND TABLE_NAME = 'prc_order_bill'
AND INDEX_NAME = 'idx_bill_ar_async_cursor'
);
SET @ar_sql := IF(@ar_has_source_table = 1 AND @ar_has_source_cursor_index = 0,
'ALTER TABLE prc_order_bill ADD INDEX idx_bill_ar_async_cursor (customer_id, currency, ar_bill_status, create_time, bill_id)',
'SELECT 1');
PREPARE ar_stmt FROM @ar_sql;
EXECUTE ar_stmt;
DEALLOCATE PREPARE ar_stmt;
-- 3. 兼容已存在的模板表和导出记录表,补齐模板字段、注释及索引。
ALTER TABLE ar_export_template
MODIFY COLUMN storage_type VARCHAR(20) NOT NULL DEFAULT 'CLASSPATH'
COMMENT '存储类型:CLASSPATH=应用内置资源,FILE=服务器文件,OSS=对象存储,MINIO=MinIO对象存储',
MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'ENABLED'
COMMENT '状态:ENABLED=启用,DISABLED=停用';
SET @ar_has_template_id := (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @ar_db_name
AND TABLE_NAME = 'ar_bill_export'
AND COLUMN_NAME = 'template_id'
);
SET @ar_sql := IF(@ar_has_template_id = 0,
'ALTER TABLE ar_bill_export ADD COLUMN template_id BIGINT(20) DEFAULT NULL COMMENT ''提交导出任务时绑定的模板ID'' AFTER file_type',
'SELECT 1');
PREPARE ar_stmt FROM @ar_sql;
EXECUTE ar_stmt;
DEALLOCATE PREPARE ar_stmt;
SET @ar_has_template_storage_type := (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @ar_db_name
AND TABLE_NAME = 'ar_bill_export'
AND COLUMN_NAME = 'template_storage_type'
);
SET @ar_sql := IF(@ar_has_template_storage_type = 0,
'ALTER TABLE ar_bill_export ADD COLUMN template_storage_type VARCHAR(20) DEFAULT NULL COMMENT ''绑定模板存储类型'' AFTER template_version',
'SELECT 1');
PREPARE ar_stmt FROM @ar_sql;
EXECUTE ar_stmt;
DEALLOCATE PREPARE ar_stmt;
SET @ar_has_template_storage_key := (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @ar_db_name
AND TABLE_NAME = 'ar_bill_export'
AND COLUMN_NAME = 'template_storage_key'
);
SET @ar_sql := IF(@ar_has_template_storage_key = 0,
'ALTER TABLE ar_bill_export ADD COLUMN template_storage_key VARCHAR(500) DEFAULT NULL COMMENT ''绑定模板存储Key'' AFTER template_storage_type',
'SELECT 1');
PREPARE ar_stmt FROM @ar_sql;
EXECUTE ar_stmt;
DEALLOCATE PREPARE ar_stmt;
SET @ar_has_template_hash := (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @ar_db_name
AND TABLE_NAME = 'ar_bill_export'
AND COLUMN_NAME = 'template_hash'
);
SET @ar_sql := IF(@ar_has_template_hash = 0,
'ALTER TABLE ar_bill_export ADD COLUMN template_hash VARCHAR(128) DEFAULT NULL COMMENT ''绑定模板SHA-256校验值'' AFTER template_storage_key',
'SELECT 1');
PREPARE ar_stmt FROM @ar_sql;
EXECUTE ar_stmt;
DEALLOCATE PREPARE ar_stmt;
SET @ar_has_template_index := (
SELECT COUNT(*)
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = @ar_db_name
AND TABLE_NAME = 'ar_bill_export'
AND INDEX_NAME = 'idx_ar_bill_export_template'
);
SET @ar_sql := IF(@ar_has_template_index = 0,
'ALTER TABLE ar_bill_export ADD INDEX idx_ar_bill_export_template (template_id)',
'SELECT 1');
PREPARE ar_stmt FROM @ar_sql;
EXECUTE ar_stmt;
DEALLOCATE PREPARE ar_stmt;
SET FOREIGN_KEY_CHECKS = 1; SET FOREIGN_KEY_CHECKS = 1;
+30 -1
View File
@@ -1,5 +1,6 @@
import request from '@/utils/request' import request from '@/utils/request'
// 应收账单接口统一放在这里;生成和导出均为异步任务,提交接口不等待后台处理完成。
export function listArBill(query) { export function listArBill(query) {
return request({ url: '/ar/bill/list', method: 'get', params: query }) return request({ url: '/ar/bill/list', method: 'get', params: query })
} }
@@ -8,12 +9,36 @@ export function getArBill(arBillId) {
return request({ url: '/ar/bill/' + arBillId, method: 'get', timeout: 30000 }) return request({ url: '/ar/bill/' + arBillId, method: 'get', timeout: 30000 })
} }
export function listArBillDetails(arBillId, query = {}) {
return request({ url: '/ar/bill/' + arBillId + '/details', method: 'get', params: query })
}
export function listArBillDetailFees(arBillId, detailId) {
return request({ url: '/ar/bill/' + arBillId + '/details/' + detailId + '/fees', method: 'get' })
}
export function getDefaultArBankAccount(currency = 'CNY') { export function getDefaultArBankAccount(currency = 'CNY') {
return request({ url: '/ar/bill/default-bank-account', method: 'get', params: { currency } }) return request({ url: '/ar/bill/default-bank-account', method: 'get', params: { currency } })
} }
export function generateArBill(data) { export function generateArBill(data) {
return request({ url: '/ar/bill/generate', method: 'post', data, timeout: 60000 }) return request({ url: '/ar/bill/generate', method: 'post', data, timeout: 30000 })
}
export function getArBillGenerateTask(taskId) {
return request({ url: '/ar/bill/generate/task/' + taskId, method: 'get' })
}
export function listActiveArBillGenerateTasks() {
return request({ url: '/ar/bill/generate/tasks/active', method: 'get' })
}
export function cancelArBillGenerateTask(taskId) {
return request({ url: '/ar/bill/generate/task/' + taskId + '/cancel', method: 'post' })
}
export function retryArBillGenerateTask(taskId) {
return request({ url: '/ar/bill/generate/task/' + taskId + '/retry', method: 'post' })
} }
export function previewArBill(query) { export function previewArBill(query) {
@@ -36,6 +61,10 @@ export function listArBillExports(arBillId) {
return request({ url: '/ar/bill/' + arBillId + '/exports', method: 'get' }) return request({ url: '/ar/bill/' + arBillId + '/exports', method: 'get' })
} }
export function getArBillExport(exportId) {
return request({ url: '/ar/bill/export/' + exportId, method: 'get' })
}
export function downloadArBillExport(exportId) { export function downloadArBillExport(exportId) {
return request({ url: '/ar/bill/export/' + exportId + '/download', method: 'get', responseType: 'blob' }) return request({ url: '/ar/bill/export/' + exportId + '/download', method: 'get', responseType: 'blob' })
} }
+6
View File
@@ -30,6 +30,12 @@ export const AR_BILL_STATUS_TYPES = Object.freeze({
[AR_BILL_STATUS.VOID]: 'danger' [AR_BILL_STATUS.VOID]: 'danger'
}) })
/** 账单生成时的订单选择模式,value 与后端枚举和数据库 code 保持一致。 */
export const AR_BILL_SELECTION_MODE = Object.freeze({
EXPLICIT: 'EXPLICIT', // 手动选择当前预览结果
ALL_MATCHED: 'ALL_MATCHED' // 全选查询结果,再排除指定订单
})
/** 日志状态变化的兼容展示。日志是通用表,除账单状态外还可能保留历史收款状态。 */ /** 日志状态变化的兼容展示。日志是通用表,除账单状态外还可能保留历史收款状态。 */
export const AR_LOG_STATUS_OPTIONS = Object.freeze([ export const AR_LOG_STATUS_OPTIONS = Object.freeze([
...AR_BILL_STATUS_OPTIONS, ...AR_BILL_STATUS_OPTIONS,
@@ -17,17 +17,21 @@
<el-descriptions-item label="收款账号">{{ mask(detail.bankAccountNo) }}</el-descriptions-item> <el-descriptions-item label="收款账号">{{ mask(detail.bankAccountNo) }}</el-descriptions-item>
</el-descriptions> </el-descriptions>
<div class="section-title">订单快照({{ detail.orderCount || 0 }})</div> <!-- 账单明细独立分页;费用明细在展开行时再懒加载。 -->
<el-table :data="detail.details || []" border size="small"> <div class="section-title">账单明细({{ detail.orderCount || 0 }})</div>
<el-table v-loading="detailListLoading" :data="detailRows" row-key="detailId" border size="small" @expand-change="handleExpandChange">
<el-table-column type="expand"> <el-table-column type="expand">
<template #default="{ row }"> <template #default="{ row }">
<el-table :data="row.fees || []" border size="small" class="fee-table"> <div v-loading="row.feeLoading" class="fee-panel">
<el-table-column prop="feeType" label="费用类型" width="140" /> <el-empty v-if="row.feesLoaded && !row.fees?.length" description="暂无费用明细" :image-size="60" />
<el-table-column prop="feeName" label="费用名称" min-width="160" /> <el-table v-else :data="row.fees || []" border size="small" class="fee-table">
<el-table-column prop="amount" label="金额" width="120" align="right"><template #default="{ row: fee }">{{ money(fee.amount) }}</template></el-table-column> <el-table-column prop="feeType" label="费用类型" width="140" />
<el-table-column prop="currency" label="币种" width="80" /> <el-table-column prop="feeName" label="费用名称" min-width="160" />
<el-table-column prop="remark" label="说明" show-overflow-tooltip /> <el-table-column prop="amount" label="金额" width="120" align="right"><template #default="{ row: fee }">{{ money(fee.amount) }}</template></el-table-column>
</el-table> <el-table-column prop="currency" label="币种" width="80" />
<el-table-column prop="remark" label="说明" show-overflow-tooltip />
</el-table>
</div>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="detailNo" label="#" width="55" align="center" /> <el-table-column prop="detailNo" label="#" width="55" align="center" />
@@ -38,12 +42,24 @@
<el-table-column label="重量/尺寸" min-width="170"><template #default="{ row }">{{ row.chargeWeight || '-' }} kg / {{ dimensions(row) }}</template></el-table-column> <el-table-column label="重量/尺寸" min-width="170"><template #default="{ row }">{{ row.chargeWeight || '-' }} kg / {{ dimensions(row) }}</template></el-table-column>
<el-table-column prop="totalAmount" label="订单金额" width="120" align="right"><template #default="{ row }">{{ money(row.totalAmount) }}</template></el-table-column> <el-table-column prop="totalAmount" label="订单金额" width="120" align="right"><template #default="{ row }">{{ money(row.totalAmount) }}</template></el-table-column>
</el-table> </el-table>
<el-pagination
v-if="detailTotal > 0"
class="detail-pagination"
v-model:current-page="detailPageNum"
v-model:page-size="detailPageSize"
:page-sizes="[10, 20, 50]"
layout="total, sizes, prev, pager, next, jumper"
:total="detailTotal"
@current-change="loadDetails"
@size-change="handleDetailSizeChange"
/>
<div class="section-title export-title"><span>导出历史</span><el-button size="small" type="primary" plain icon="Download" @click="$emit('export')">生成新文件</el-button></div> <div class="section-title export-title"><span>导出历史</span><el-button size="small" type="primary" plain icon="Download" :loading="exporting" :disabled="exporting" @click="$emit('export')">{{ exporting ? '生成中' : '生成新文件' }}</el-button></div>
<el-table :data="detail.exports || []" border size="small"> <el-table :data="detail.exports || []" border size="small">
<el-table-column prop="fileName" label="文件名" min-width="250" show-overflow-tooltip /> <el-table-column prop="fileName" label="文件名" min-width="250" show-overflow-tooltip />
<el-table-column prop="templateVersion" label="模板版本" width="100" /> <el-table-column prop="templateVersion" label="模板版本" width="100" />
<el-table-column prop="exportStatus" label="状态" width="100"><template #default="{ row }"><el-tag :type="exportStatusType(row.exportStatus)">{{ exportStatusText(row.exportStatus) }}</el-tag></template></el-table-column> <el-table-column prop="exportStatus" label="状态" width="100"><template #default="{ row }"><el-tag :type="exportStatusType(row.exportStatus)">{{ exportStatusText(row.exportStatus) }}</el-tag></template></el-table-column>
<el-table-column prop="remark" label="失败原因" min-width="180" show-overflow-tooltip />
<el-table-column prop="exportBy" label="导出人" width="100" /> <el-table-column prop="exportBy" label="导出人" width="100" />
<el-table-column prop="exportTime" label="导出时间" width="170" /> <el-table-column prop="exportTime" label="导出时间" width="170" />
<el-table-column label="操作" width="90"><template #default="{ row }"><el-button v-if="row.exportStatus === AR_EXPORT_STATUS.SUCCESS" link type="primary" @click="$emit('download', row)">下载</el-button></template></el-table-column> <el-table-column label="操作" width="90"><template #default="{ row }"><el-button v-if="row.exportStatus === AR_EXPORT_STATUS.SUCCESS" link type="primary" @click="$emit('download', row)">下载</el-button></template></el-table-column>
@@ -54,10 +70,64 @@
</template> </template>
<script setup> <script setup>
import { listArBillDetails, listArBillDetailFees } from '@/api/ar/bill'
import { AR_BILL_STATUS_OPTIONS, AR_BILL_STATUS_TYPES, AR_EXPORT_STATUS, AR_EXPORT_STATUS_OPTIONS, AR_EXPORT_STATUS_TYPES, arEnumLabel } from '@/constants/ar/enums' import { AR_BILL_STATUS_OPTIONS, AR_BILL_STATUS_TYPES, AR_EXPORT_STATUS, AR_EXPORT_STATUS_OPTIONS, AR_EXPORT_STATUS_TYPES, arEnumLabel } from '@/constants/ar/enums'
defineProps({ modelValue: Boolean, detail: { type: Object, default: null }, loading: Boolean }) const props = defineProps({ modelValue: Boolean, detail: { type: Object, default: null }, loading: Boolean })
defineEmits(['update:modelValue', 'download', 'export']) defineEmits(['update:modelValue', 'download', 'export'])
const detailRows = ref([])
const detailTotal = ref(0)
const detailPageNum = ref(1)
// 详情默认每页 10 条,避免几十万条账单明细一次性进入浏览器内存。
const detailPageSize = ref(10)
const detailListLoading = ref(false)
let detailRequestSerial = 0
const exporting = computed(() => props.detail?.exports?.some(item => item.exportStatus === AR_EXPORT_STATUS.PROCESSING))
// 切换账单时重置分页和请求序号,旧账单的慢响应不能覆盖新账单。
watch(() => props.detail?.arBillId, billId => {
detailRequestSerial += 1
detailPageNum.value = 1
detailTotal.value = 0
detailRows.value = []
if (billId) loadDetails()
}, { immediate: true })
// 账单明细接口只返回当前页;分页变化时复用同一账单 ID 重新查询。
function loadDetails() {
const billId = props.detail?.arBillId
if (!billId) return
const serial = ++detailRequestSerial
detailListLoading.value = true
listArBillDetails(billId, { pageNum: detailPageNum.value, pageSize: detailPageSize.value }).then(res => {
if (serial !== detailRequestSerial) return
detailRows.value = (res.rows || []).map(row => ({ ...row, fees: [], feesLoaded: false, feeLoading: false }))
detailTotal.value = Number(res.total) || 0
}).finally(() => {
if (serial === detailRequestSerial) detailListLoading.value = false
})
}
function handleDetailSizeChange(size) {
detailPageSize.value = size
detailPageNum.value = 1
loadDetails()
}
// 费用明细按行懒加载,并用标记避免同一行重复请求。
function handleExpandChange(row, expandedRows) {
if (!expandedRows.includes(row) || row.feesLoaded || row.feeLoading) return
const billId = props.detail?.arBillId
if (!billId || !row.detailId) return
row.feeLoading = true
listArBillDetailFees(billId, row.detailId).then(res => {
row.fees = res.data || []
row.feesLoaded = true
}).finally(() => {
row.feeLoading = false
})
}
function date(value) { return value ? String(value).slice(0, 10) : '-' } function date(value) { return value ? String(value).slice(0, 10) : '-' }
function money(value) { const num = Number(value); return Number.isNaN(num) ? '-' : num.toFixed(2) } function money(value) { const num = Number(value); return Number.isNaN(num) ? '-' : num.toFixed(2) }
function statusText(value) { return arEnumLabel(AR_BILL_STATUS_OPTIONS, value) } function statusText(value) { return arEnumLabel(AR_BILL_STATUS_OPTIONS, value) }
@@ -75,5 +145,7 @@ function mask(value) { if (!value) return '-'; return value.length <= 4 ? '****'
.section-title::before { content: ''; width: 3px; height: 15px; border-radius: 2px; background: var(--el-color-primary); } .section-title::before { content: ''; width: 3px; height: 15px; border-radius: 2px; background: var(--el-color-primary); }
.export-title { justify-content: space-between; } .export-title { justify-content: space-between; }
.export-title::before { margin-right: auto; } .export-title::before { margin-right: auto; }
.detail-pagination { justify-content: flex-end; margin: 12px 0 4px; }
.fee-panel { min-height: 54px; padding: 4px 12px 8px 42px; background: #fafafa; }
.fee-table { background: #fafafa; } .fee-table { background: #fafafa; }
</style> </style>
@@ -6,6 +6,7 @@
<el-col :span="8"><el-form-item label="客户" prop="customerId"><customer-select v-model="generateForm.customerId" placeholder="请选择客户" @change="handleCustomerChange" /></el-form-item></el-col> <el-col :span="8"><el-form-item label="客户" prop="customerId"><customer-select v-model="generateForm.customerId" placeholder="请选择客户" @change="handleCustomerChange" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="币种" prop="currency"><el-select v-model="generateForm.currency" placeholder="请选择币种" style="width: 100%;" @change="handleCurrencyChange"><el-option label="人民币 CNY" value="CNY" /><el-option label="美元 USD" value="USD" /></el-select></el-form-item></el-col> <el-col :span="8"><el-form-item label="币种" prop="currency"><el-select v-model="generateForm.currency" placeholder="请选择币种" style="width: 100%;" @change="handleCurrencyChange"><el-option label="人民币 CNY" value="CNY" /><el-option label="美元 USD" value="USD" /></el-select></el-form-item></el-col>
<el-col :span="8"><el-form-item label="账单截止日" prop="cutoffDate"><el-date-picker v-model="generateForm.cutoffDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择账单截止日" style="width: 100%;" @change="resetPreview" /></el-form-item></el-col> <el-col :span="8"><el-form-item label="账单截止日" prop="cutoffDate"><el-date-picker v-model="generateForm.cutoffDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择账单截止日" style="width: 100%;" @change="resetPreview" /></el-form-item></el-col>
<el-col :span="8"><el-form-item label="订单/运单号"><el-input v-model="generateForm.orderOrTrackingNo" placeholder="请输入订单号或运单号" clearable /></el-form-item></el-col>
</el-row> </el-row>
</el-form> </el-form>
<div class="payee-card" v-loading="bankAccountLoading"> <div class="payee-card" v-loading="bankAccountLoading">
@@ -18,14 +19,47 @@
<span v-else class="payee-empty">当前币种尚未配置公司默认收款账户</span> <span v-else class="payee-empty">当前币种尚未配置公司默认收款账户</span>
</div> </div>
<div class="preview-toolbar"> <div class="preview-toolbar">
<span v-if="previewSearched" class="preview-summary">可选 {{ previewRows.length }} 条订单,已选 <b>{{ selectedRows.length }}</b> 条,应收合计 <b>{{ money(selectedAmount) }}</b></span> <span v-if="previewSearched" class="preview-summary">可选 {{ previewTotal }} 条订单,已选 <b>{{ displaySelectedCount }}</b> 条,应收合计 <b>{{ money(displaySelectedAmount) }}</b></span>
<span v-else class="preview-summary">请选择客户、币种和账单截止日后查询可生成的计费订单</span> <span v-else class="preview-summary">请选择客户、币种和账单截止日后查询可生成的计费订单</span>
<el-button type="primary" plain :loading="previewLoading" :disabled="!bankAccount" @click="queryGeneratePreview">查询</el-button> <el-button type="primary" plain :loading="previewLoading" :disabled="!bankAccount" @click="queryGeneratePreview">查询</el-button>
</div> </div>
<div v-if="previewSearched" class="preview-selection-toolbar">
<el-tooltip content="全选全部查询结果" placement="top">
<el-checkbox
:model-value="allSelected"
:disabled="!previewTotal"
aria-label="全选全部查询结果"
@change="handleSelectAllChange"
>
<el-icon><Select /></el-icon>
</el-checkbox>
</el-tooltip>
<el-tooltip content="切换手动选择" placement="top">
<el-checkbox
:model-value="selectionMode === AR_BILL_SELECTION_MODE.EXPLICIT"
:disabled="!previewTotal || selectionMode === AR_BILL_SELECTION_MODE.EXPLICIT"
aria-label="切换手动选择"
@change="handleManualModeChange"
>
<el-icon><Refresh /></el-icon>
</el-checkbox>
</el-tooltip>
<el-badge v-if="allSelected" :value="excludedRows.length" :hidden="!excludedRows.length" class="exclude-order-badge">
<el-tooltip content="选择排除订单" placement="top">
<el-button circle text :icon="Search" aria-label="选择排除订单" @click="openExcludeDialog" />
</el-tooltip>
</el-badge>
</div>
<div v-if="allSelected && excludedRows.length" class="excluded-order-preview">
<span class="excluded-order-title">排除订单:</span>
<el-tag v-for="row in excludedRows" :key="row.billId" closable size="small" @close="removeExcludedRow(row.billId)">
{{ row.orderNo || row.trackingNo || row.billId }}
</el-tag>
</div>
<el-table ref="previewTableRef" v-loading="previewLoading" :data="previewRows" row-key="billId" border stripe max-height="400" empty-text="暂无符合条件的计费订单" @selection-change="handleSelectionChange"> <el-table ref="previewTableRef" v-loading="previewLoading" :data="previewRows" row-key="billId" border stripe max-height="400" empty-text="暂无符合条件的计费订单" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="50" fixed="left" /> <el-table-column type="selection" width="50" fixed="left" :selectable="isMainRowSelectable" />
<el-table-column prop="orderNo" label="订单号" min-width="150" show-overflow-tooltip /> <el-table-column prop="orderNo" label="订单号" min-width="150" show-overflow-tooltip />
<el-table-column prop="trackingNo" label="跟踪号" min-width="140" show-overflow-tooltip /> <el-table-column prop="trackingNo" label="运单号" min-width="140" show-overflow-tooltip />
<el-table-column prop="customerName" label="客户" min-width="130" show-overflow-tooltip /> <el-table-column prop="customerName" label="客户" min-width="130" show-overflow-tooltip />
<el-table-column prop="bizDate" label="业务日期" width="105" :formatter="formatDateColumn" /> <el-table-column prop="bizDate" label="业务日期" width="105" :formatter="formatDateColumn" />
<el-table-column prop="productName" label="产品" min-width="140" show-overflow-tooltip /> <el-table-column prop="productName" label="产品" min-width="140" show-overflow-tooltip />
@@ -35,20 +69,59 @@
<el-table-column prop="incomeTotal" label="应收金额" width="110" align="right" :formatter="formatNumberColumn" /> <el-table-column prop="incomeTotal" label="应收金额" width="110" align="right" :formatter="formatNumberColumn" />
<el-table-column prop="currency" label="币种" width="75" /> <el-table-column prop="currency" label="币种" width="75" />
</el-table> </el-table>
<el-pagination
v-if="previewTotal > 0"
class="preview-pagination"
:current-page="previewPageNum"
:page-size="previewPageSize"
layout="total, prev, pager, next, jumper"
:total="previewTotal"
@current-change="handlePreviewPageChange"
/>
<template #footer> <template #footer>
<span class="generate-footer-summary">已选 {{ selectedRows.length }} 条订单 · {{ money(selectedAmount) }}</span> <span class="generate-footer-summary">已选 {{ displaySelectedCount }} 条订单 · {{ money(displaySelectedAmount) }}</span>
<el-button type="primary" :loading="generateLoading" :disabled="!bankAccount || !previewSearched || !selectedRows.length" @click="submitGenerate">生成账单</el-button> <el-button type="primary" :loading="generateLoading" :disabled="!bankAccount || !previewSearched || !displaySelectedCount" @click="submitGenerate">生成账单</el-button>
<el-button @click="dialogOpen = false">取消</el-button> <el-button @click="dialogOpen = false">取消</el-button>
</template> </template>
</el-dialog> </el-dialog>
<el-dialog v-model="excludeDialogOpen" title="选择排除订单" width="1100px" append-to-body destroy-on-close @close="handleExcludeDialogClose">
<div class="exclude-toolbar">
<el-input v-model="excludeKeyword" clearable placeholder="请输入订单号或运单号" class="exclude-search-input" />
<el-button type="primary" :loading="excludeLoading" @click="queryExcludeRows">查询</el-button>
</div>
<el-table ref="excludeTableRef" v-loading="excludeLoading" :data="excludeRowsPage" row-key="billId" border stripe max-height="420" empty-text="暂无符合条件的订单" @selection-change="handleExcludeSelectionChange">
<el-table-column type="selection" width="50" fixed="left" />
<el-table-column prop="orderNo" label="订单号" min-width="220" show-overflow-tooltip />
<el-table-column prop="trackingNo" label="运单号" min-width="180" show-overflow-tooltip />
<el-table-column prop="customerName" label="客户" min-width="150" show-overflow-tooltip />
<el-table-column prop="bizDate" label="业务日期" width="120" :formatter="formatDateColumn" />
<el-table-column prop="incomeTotal" label="应收金额" width="130" align="right" :formatter="formatNumberColumn" />
<el-table-column prop="currency" label="币种" width="80" />
</el-table>
<el-pagination
v-if="excludeTotal > 0"
class="exclude-pagination"
:current-page="excludePageNum"
:page-size="excludePageSize"
layout="total, prev, pager, next, jumper"
:total="excludeTotal"
@current-change="handleExcludePageChange"
/>
<template #footer>
<span class="generate-footer-summary">已选择 {{ excludeDraftRows.length }} 条排除订单</span>
<el-button @click="cancelExcludeSelection">取消</el-button>
<el-button type="primary" @click="confirmExcludeSelection">确定</el-button>
</template>
</el-dialog>
<el-dialog v-model="billNameOpen" title="填写账单名称" width="520px" append-to-body destroy-on-close> <el-dialog v-model="billNameOpen" title="填写账单名称" width="520px" append-to-body destroy-on-close>
<el-form ref="billNameFormRef" :model="billNameForm" :rules="billNameRules" label-width="90px"> <el-form ref="billNameFormRef" :model="billNameForm" :rules="billNameRules" label-width="90px">
<el-form-item label="账单名称" prop="billName"> <el-form-item label="账单名称" prop="billName">
<el-input v-model="billNameForm.billName" maxlength="100" show-word-limit placeholder="请输入账单名称" clearable @keyup.enter="confirmGenerate" /> <el-input v-model="billNameForm.billName" maxlength="100" show-word-limit placeholder="请输入账单名称" clearable @keyup.enter="confirmGenerate" />
</el-form-item> </el-form-item>
</el-form> </el-form>
<div class="bill-name-summary">已选择 {{ selectedRows.length }} 条订单,应收合计 {{ money(selectedAmount) }}</div> <div class="bill-name-summary">已选择 {{ displaySelectedCount }} 条订单,应收合计 {{ money(displaySelectedAmount) }}</div>
<template #footer> <template #footer>
<el-button @click="billNameOpen = false">取消</el-button> <el-button @click="billNameOpen = false">取消</el-button>
<el-button type="primary" :loading="generateLoading" @click="confirmGenerate">确认生成</el-button> <el-button type="primary" :loading="generateLoading" @click="confirmGenerate">确认生成</el-button>
@@ -59,6 +132,8 @@
<script setup name="GenerateBillDialog"> <script setup name="GenerateBillDialog">
import { getDefaultArBankAccount, previewArBill, generateArBill } from '@/api/ar/bill' import { getDefaultArBankAccount, previewArBill, generateArBill } from '@/api/ar/bill'
import { Refresh, Search, Select } from '@element-plus/icons-vue'
import { AR_BILL_SELECTION_MODE } from '@/constants/ar/enums'
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
@@ -78,14 +153,39 @@ const generateLoading = ref(false)
const previewLoading = ref(false) const previewLoading = ref(false)
const previewSearched = ref(false) const previewSearched = ref(false)
const previewRows = ref([]) const previewRows = ref([])
const previewTotal = ref(0)
const previewTotalAmount = ref(0)
const previewPageNum = ref(1)
// 主预览列表固定每页 5 条;Map 负责保存跨页勾选结果。
const previewPageSize = 5
const previewTableRef = ref() const previewTableRef = ref()
const selectedRows = ref([]) const selectedRows = ref([])
const selectedRowMap = new Map()
// 选择模式只保存编码,提交时由后端枚举再次校验。
const selectionMode = ref(AR_BILL_SELECTION_MODE.EXPLICIT)
const excludedRows = ref([])
const excludedRowMap = new Map()
const excludeDialogOpen = ref(false)
const excludeLoading = ref(false)
const excludeKeyword = ref('')
const excludeRowsPage = ref([])
const excludeTotal = ref(0)
const excludePageNum = ref(1)
// 全选后的排除弹窗也按 5 条分页,查询条件固定继承主弹窗条件。
const excludePageSize = 5
const excludeTableRef = ref()
const excludeDraftRows = ref([])
let excludeDraftMap = new Map()
let excludeDialogConfirmed = false
let syncingExcludeSelection = false
const previewQuery = ref(null)
let syncingPageSelection = false
const bankAccountLoading = ref(false) const bankAccountLoading = ref(false)
const bankAccount = ref(null) const bankAccount = ref(null)
const generateRequestId = ref('') const generateRequestId = ref('')
const generateFormRef = ref() const generateFormRef = ref()
const billNameFormRef = ref() const billNameFormRef = ref()
const generateForm = reactive({ customerId: '', customerCode: '', cutoffDate: '', currency: 'CNY' }) const generateForm = reactive({ customerId: '', customerCode: '', customerName: '', cutoffDate: '', currency: 'CNY', orderOrTrackingNo: '' })
const billNameForm = reactive({ billName: '' }) const billNameForm = reactive({ billName: '' })
const generateRules = { const generateRules = {
customerId: [{ required: true, message: '客户不能为空', trigger: 'change' }], customerId: [{ required: true, message: '客户不能为空', trigger: 'change' }],
@@ -94,6 +194,12 @@ const generateRules = {
} }
const billNameRules = { billName: [{ required: true, message: '请输入账单名称', trigger: 'blur' }] } const billNameRules = { billName: [{ required: true, message: '请输入账单名称', trigger: 'blur' }] }
const selectedAmount = computed(() => selectedRows.value.reduce((sum, row) => sum + (Number(row.incomeTotal) || 0), 0)) const selectedAmount = computed(() => selectedRows.value.reduce((sum, row) => sum + (Number(row.incomeTotal) || 0), 0))
const excludedAmount = computed(() => excludedRows.value.reduce((sum, row) => sum + (Number(row.incomeTotal) || 0), 0))
const allSelected = computed(() => selectionMode.value === AR_BILL_SELECTION_MODE.ALL_MATCHED)
const displaySelectedCount = computed(() => selectionMode.value === AR_BILL_SELECTION_MODE.ALL_MATCHED
? Math.max(previewTotal.value - excludedRows.value.length, 0) : selectedRows.value.length)
const displaySelectedAmount = computed(() => selectionMode.value === AR_BILL_SELECTION_MODE.ALL_MATCHED
? Math.max(Number(previewTotalAmount.value || 0) - excludedAmount.value, 0) : selectedAmount.value)
watch(() => props.modelValue, visible => { watch(() => props.modelValue, visible => {
if (visible) { if (visible) {
@@ -101,8 +207,9 @@ watch(() => props.modelValue, visible => {
} }
}) })
// 每次打开生成弹窗都重新建立一次查询快照,避免复用上一次的选中订单。
function openGenerate() { function openGenerate() {
Object.assign(generateForm, { customerId: '', customerCode: '', cutoffDate: '', currency: 'CNY' }) Object.assign(generateForm, { customerId: '', customerCode: '', customerName: '', cutoffDate: '', currency: 'CNY', orderOrTrackingNo: '' })
Object.assign(billNameForm, { billName: '' }) Object.assign(billNameForm, { billName: '' })
billNameOpen.value = false billNameOpen.value = false
resetPreview() resetPreview()
@@ -113,6 +220,7 @@ function openGenerate() {
function handleCustomerChange(_, item) { function handleCustomerChange(_, item) {
generateForm.customerCode = item?.customerCode || '' generateForm.customerCode = item?.customerCode || ''
generateForm.customerName = item?.customerName || ''
resetPreview() resetPreview()
} }
@@ -131,9 +239,21 @@ function loadDefaultBankAccount() {
}) })
} }
// 条件变化后清理预览和跨页选中状态;用户点击查询时重新加载第一页。
function resetPreview() { function resetPreview() {
previewRows.value = [] previewRows.value = []
previewTotal.value = 0
previewTotalAmount.value = 0
previewPageNum.value = 1
selectedRows.value = [] selectedRows.value = []
selectedRowMap.clear()
selectionMode.value = AR_BILL_SELECTION_MODE.EXPLICIT
excludedRows.value = []
excludedRowMap.clear()
excludeDraftRows.value = []
excludeDraftMap = new Map()
excludeDialogOpen.value = false
previewQuery.value = null
previewSearched.value = false previewSearched.value = false
generateRequestId.value = '' generateRequestId.value = ''
nextTick(() => previewTableRef.value?.clearSelection()) nextTick(() => previewTableRef.value?.clearSelection())
@@ -143,41 +263,225 @@ function buildGenerateQuery() {
return { return {
customerId: Number(generateForm.customerId), customerId: Number(generateForm.customerId),
customerCode: generateForm.customerCode?.trim() || undefined, customerCode: generateForm.customerCode?.trim() || undefined,
customerName: generateForm.customerName?.trim() || undefined,
billPeriodEnd: generateForm.cutoffDate, billPeriodEnd: generateForm.cutoffDate,
currency: generateForm.currency || undefined currency: generateForm.currency || undefined,
orderOrTrackingNo: generateForm.orderOrTrackingNo?.trim() || undefined
} }
} }
// 任务请求携带完整筛选快照;全选模式只提交排除 ID,后台按条件游标扫描其余订单。
function buildGenerateRequest(requestId) { function buildGenerateRequest(requestId) {
return { return {
...buildGenerateQuery(), ...(previewQuery.value || buildGenerateQuery()),
billName: billNameForm.billName.trim(), billName: billNameForm.billName.trim(),
sourceBillIds: selectedRows.value.map(row => row.billId), selectionMode: selectionMode.value,
sourceBillIds: selectionMode.value === AR_BILL_SELECTION_MODE.ALL_MATCHED ? [] : selectedRows.value.map(row => row.billId),
excludedSourceBillIds: selectionMode.value === AR_BILL_SELECTION_MODE.ALL_MATCHED ? Array.from(excludedRowMap.keys()) : [],
requestId requestId
} }
} }
function buildPreviewQuery(pageNum = previewPageNum.value, pageSize = previewPageSize) {
return { ...(previewQuery.value || buildGenerateQuery()), pageNum, pageSize }
}
// 查询按钮是预览入口,后端返回当前页、总数和全量金额合计。
function queryGeneratePreview() { function queryGeneratePreview() {
generateFormRef.value.validate(valid => { generateFormRef.value.validate(valid => {
if (!valid) return if (!valid) return
const query = buildGenerateQuery()
resetPreview() resetPreview()
previewLoading.value = true previewQuery.value = query
previewArBill(buildGenerateQuery()).then(res => { fetchPreview()
previewRows.value = res.rows || []
previewSearched.value = true
}).finally(() => {
previewLoading.value = false
})
}) })
} }
function fetchPreview() {
previewLoading.value = true
previewArBill(buildPreviewQuery()).then(res => {
syncingPageSelection = true
previewRows.value = res.rows || []
previewTotal.value = Number(res.total) || 0
previewTotalAmount.value = Number(res.totalAmount) || 0
previewSearched.value = true
nextTick(syncPageSelection)
}).finally(() => {
previewLoading.value = false
})
}
function handlePreviewPageChange(page) {
previewPageNum.value = page
fetchPreview()
}
// 当前页变化只更新当前页 ID,selectedRowMap 因此可以跨页保留手动选择。
function handleSelectionChange(rows) { function handleSelectionChange(rows) {
selectedRows.value = rows if (syncingPageSelection || selectionMode.value === AR_BILL_SELECTION_MODE.ALL_MATCHED) return
const currentPageIds = new Set(previewRows.value.map(row => row.billId))
currentPageIds.forEach(billId => selectedRowMap.delete(billId))
rows.forEach(row => selectedRowMap.set(row.billId, row))
selectedRows.value = Array.from(selectedRowMap.values())
if (!generateLoading.value) generateRequestId.value = '' if (!generateLoading.value) generateRequestId.value = ''
} }
// 全选后主表复选框全部禁用,用户通过排除弹窗维护“不生成”的订单。
function handleSelectAllChange(checked) {
if (!checked) {
switchToManualMode()
return
}
selectionMode.value = AR_BILL_SELECTION_MODE.ALL_MATCHED
excludedRows.value = []
excludedRowMap.clear()
selectedRowMap.clear()
selectedRows.value = []
if (!generateLoading.value) generateRequestId.value = ''
nextTick(syncPageSelection)
}
function handleManualModeChange(checked) {
if (checked) switchToManualMode()
}
// 从全选切回手动选择时清除排除集合,恢复主表跨页勾选流程。
function switchToManualMode() {
selectionMode.value = AR_BILL_SELECTION_MODE.EXPLICIT
excludedRows.value = []
excludedRowMap.clear()
selectedRowMap.clear()
selectedRows.value = []
if (!generateLoading.value) generateRequestId.value = ''
nextTick(syncPageSelection)
}
function isMainRowSelectable() {
return selectionMode.value !== AR_BILL_SELECTION_MODE.ALL_MATCHED
}
function syncExcludedRows() {
excludedRows.value = Array.from(excludedRowMap.values())
}
function syncExcludeDraftRows() {
excludeDraftRows.value = Array.from(excludeDraftMap.values())
}
// 排除弹窗继承客户、币种、截止日和主关键字,只额外允许订单号/运单号 OR 查询。
function openExcludeDialog() {
if (!allSelected.value) return
excludeDialogConfirmed = false
excludeDraftMap = new Map(excludedRowMap)
syncExcludeDraftRows()
excludeKeyword.value = ''
excludePageNum.value = 1
excludeRowsPage.value = []
excludeTotal.value = 0
excludeDialogOpen.value = true
fetchExcludeRows()
}
function buildExcludeQuery() {
return {
...(previewQuery.value || {}),
excludeOrderOrTrackingNo: excludeKeyword.value?.trim() || undefined,
pageNum: excludePageNum.value,
pageSize: excludePageSize
}
}
function queryExcludeRows() {
excludePageNum.value = 1
fetchExcludeRows()
}
function fetchExcludeRows() {
if (!previewQuery.value) return
excludeLoading.value = true
previewArBill(buildExcludeQuery()).then(res => {
syncingExcludeSelection = true
excludeRowsPage.value = res.rows || []
excludeTotal.value = Number(res.total) || 0
nextTick(syncExcludePageSelection)
}).finally(() => {
excludeLoading.value = false
})
}
function handleExcludePageChange(page) {
excludePageNum.value = page
fetchExcludeRows()
}
// 排除弹窗使用独立 Map 保存跨页选择,关闭或翻页不会丢失已勾选订单。
function handleExcludeSelectionChange(rows) {
if (syncingExcludeSelection) return
const currentPageIds = new Set(excludeRowsPage.value.map(row => row.billId))
currentPageIds.forEach(billId => excludeDraftMap.delete(billId))
rows.forEach(row => excludeDraftMap.set(row.billId, row))
syncExcludeDraftRows()
}
function syncExcludePageSelection() {
if (!excludeTableRef.value) {
syncingExcludeSelection = false
return
}
syncingExcludeSelection = true
excludeTableRef.value.clearSelection()
excludeRowsPage.value.forEach(row => {
if (excludeDraftMap.has(row.billId)) excludeTableRef.value.toggleRowSelection(row, true)
})
syncingExcludeSelection = false
}
function confirmExcludeSelection() {
excludedRowMap.clear()
excludeDraftMap.forEach((row, billId) => excludedRowMap.set(billId, row))
syncExcludedRows()
if (!generateLoading.value) generateRequestId.value = ''
excludeDialogConfirmed = true
excludeDialogOpen.value = false
}
function removeExcludedRow(billId) {
excludedRowMap.delete(billId)
excludeDraftMap.delete(billId)
syncExcludeDraftRows()
syncExcludedRows()
if (!generateLoading.value) generateRequestId.value = ''
}
function cancelExcludeSelection() {
excludeDialogConfirmed = false
excludeDialogOpen.value = false
}
function handleExcludeDialogClose() {
if (!excludeDialogConfirmed) {
excludeDraftMap = new Map(excludedRowMap)
syncExcludeDraftRows()
}
excludeDialogConfirmed = false
}
// 翻页后把 Map 中的选择同步回 Element Plus 当前页复选框。
function syncPageSelection() {
if (!previewTableRef.value) {
syncingPageSelection = false
return
}
syncingPageSelection = true
previewTableRef.value.clearSelection()
previewRows.value.forEach(row => {
if (selectedRowMap.has(row.billId)) previewTableRef.value.toggleRowSelection(row, true)
})
syncingPageSelection = false
}
function submitGenerate() { function submitGenerate() {
if (!previewSearched.value || !selectedRows.value.length) { if (!previewSearched.value || !displaySelectedCount.value) {
proxy.$modal.msgError('请至少选择一条计费订单') proxy.$modal.msgError('请至少选择一条计费订单')
return return
} }
@@ -186,13 +490,14 @@ function submitGenerate() {
nextTick(() => billNameFormRef.value?.clearValidate()) nextTick(() => billNameFormRef.value?.clearValidate())
} }
// 最终只提交异步任务,不在浏览器等待几十万条订单明细生成完成。
function confirmGenerate() { function confirmGenerate() {
billNameFormRef.value.validate(valid => { billNameFormRef.value.validate(valid => {
if (!valid) return if (!valid) return
if (!generateRequestId.value) generateRequestId.value = createRequestId() if (!generateRequestId.value) generateRequestId.value = createRequestId()
generateLoading.value = true generateLoading.value = true
generateArBill(buildGenerateRequest(generateRequestId.value)).then(res => { generateArBill(buildGenerateRequest(generateRequestId.value)).then(res => {
proxy.$modal.msgSuccess('账单生成成功') proxy.$modal.msgSuccess('账单生成任务已提交,后台正在生成')
billNameOpen.value = false billNameOpen.value = false
dialogOpen.value = false dialogOpen.value = false
emit('success', res.data) emit('success', res.data)
@@ -219,6 +524,16 @@ function createRequestId() { return globalThis.crypto?.randomUUID?.() || `ar-${D
.preview-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: 18px 0 10px; padding: 10px 14px; border-radius: 6px; background: var(--el-fill-color-light); color: var(--el-text-color-regular); } .preview-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: 18px 0 10px; padding: 10px 14px; border-radius: 6px; background: var(--el-fill-color-light); color: var(--el-text-color-regular); }
.preview-summary { flex: 1; font-size: 14px; } .preview-summary { flex: 1; font-size: 14px; }
.preview-summary b { color: var(--el-color-primary); } .preview-summary b { color: var(--el-color-primary); }
.preview-selection-toolbar { display: flex; align-items: center; gap: 6px; min-height: 32px; padding-left: 8px; }
.preview-selection-toolbar :deep(.el-checkbox) { margin-right: 6px; }
.preview-selection-toolbar :deep(.el-checkbox__label) { display: inline-flex; align-items: center; padding-left: 4px; font-size: 16px; }
.exclude-order-badge { margin-left: 4px; }
.excluded-order-preview { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; max-height: 64px; margin: 0 0 8px; padding: 4px 8px; overflow-y: auto; border-radius: 4px; background: var(--el-fill-color-light); }
.excluded-order-title { color: var(--el-text-color-secondary); font-size: 13px; }
.exclude-toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.exclude-search-input { width: 360px; }
.exclude-pagination { justify-content: flex-end; margin: 12px 0 4px; }
.preview-pagination { justify-content: flex-end; margin: 12px 0 4px; }
.bill-name-summary { margin: -4px 0 8px 90px; color: var(--el-text-color-secondary); font-size: 13px; } .bill-name-summary { margin: -4px 0 8px 90px; color: var(--el-text-color-secondary); font-size: 13px; }
.generate-footer-summary { margin-right: auto; color: var(--el-text-color-secondary); font-size: 13px; } .generate-footer-summary { margin-right: auto; color: var(--el-text-color-secondary); font-size: 13px; }
</style> </style>
+374 -5
View File
@@ -1,5 +1,32 @@
<template> <template>
<div class="app-container ar-bill-page"> <div class="app-container ar-bill-page">
<div v-if="generateStatusVisible" class="generate-status-float">
<el-tooltip :content="generateStatusTooltip" placement="bottom">
<el-button class="generate-status-button" type="primary" plain size="small"
:disabled="activeGenerateTask?.status === 'CANCEL_REQUESTED'" @click="cancelActiveGenerateTask">
<span class="generate-progress-ring" :style="{ '--generate-progress': `${generateProgressDegree}deg` }" aria-hidden="true">
<span class="generate-progress-value">{{ generateProgressText }}</span>
</span>
<span class="generate-status-text">
<strong>{{ generateStatusTitle }}</strong>
<small>{{ generateStatusSummary }}</small>
</span>
</el-button>
</el-tooltip>
</div>
<div v-if="exportStatusVisible" class="export-status-float">
<el-tooltip content="点击查看正在生成的导出任务" placement="bottom">
<el-button class="export-status-button" type="primary" plain size="small" @click="openExportTask(activeExportTasks[0])">
<span class="export-progress-ring" aria-hidden="true">
<el-icon class="export-download-icon"><Download /></el-icon>
</span>
<span class="export-status-text">
<strong>导出中</strong>
<small>后台生成中{{ activeExportTasks.length > 1 ? ` · ${activeExportTasks.length}个任务` : '' }}</small>
</span>
</el-button>
</el-tooltip>
</div>
<basic-table ref="tableRef" :columns="columns" :search-columns="searchColumns" :search-label-width="82" :query="queryList" :default-params="defaultSearchParams" row-key="arBillId" :selection="false"> <basic-table ref="tableRef" :columns="columns" :search-columns="searchColumns" :search-label-width="82" :query="queryList" :default-params="defaultSearchParams" row-key="arBillId" :selection="false">
<template #toolbar> <template #toolbar>
<el-col :span="1.5"><el-button type="primary" plain icon="Plus" v-hasPermi="['ar:bill:generate']" @click="generateOpen = true">生成账单</el-button></el-col> <el-col :span="1.5"><el-button type="primary" plain icon="Plus" v-hasPermi="['ar:bill:generate']" @click="generateOpen = true">生成账单</el-button></el-col>
@@ -28,7 +55,7 @@
<script setup name="ArBill"> <script setup name="ArBill">
import { saveAs } from 'file-saver' import { saveAs } from 'file-saver'
import { listArBill, getArBill, confirmArBill, voidArBill, exportArBill, downloadArBillExport } from '@/api/ar/bill' import { listArBill, getArBill, confirmArBill, voidArBill, exportArBill, getArBillExport, downloadArBillExport, getArBillGenerateTask, listActiveArBillGenerateTasks, cancelArBillGenerateTask, retryArBillGenerateTask } from '@/api/ar/bill'
import BillDetail from './components/BillDetail.vue' import BillDetail from './components/BillDetail.vue'
import GenerateBillDialog from './components/GenerateBillDialog.vue' import GenerateBillDialog from './components/GenerateBillDialog.vue'
import { AR_BILL_STATUS, AR_BILL_STATUS_OPTIONS, AR_BILL_STATUS_TYPES } from '@/constants/ar/bill' import { AR_BILL_STATUS, AR_BILL_STATUS_OPTIONS, AR_BILL_STATUS_TYPES } from '@/constants/ar/bill'
@@ -44,7 +71,52 @@ const defaultSearchParams = computed(() => ({
const detailOpen = ref(false) const detailOpen = ref(false)
const detailLoading = ref(false) const detailLoading = ref(false)
const detail = ref(null) const detail = ref(null)
const openedBillId = ref()
const generateOpen = ref(false) const generateOpen = ref(false)
// 任务可能在首次轮询前就完成,至少展示一段时间让用户感知到“正在生成”。
const MIN_GENERATE_STATUS_VISIBLE_MS = 1800
// 每个任务独立保存轮询、收尾定时器和开始展示时间,避免多个账单任务互相覆盖。
const generatePollTimers = new Map()
const generateFinishTimers = new Map()
const generateVisibleSince = new Map()
const generateTasks = reactive(new Map())
const activeGenerateTasks = computed(() => Array.from(generateTasks.values()))
const activeGenerateTask = computed(() => activeGenerateTasks.value[0])
const generateStatusVisible = computed(() => activeGenerateTasks.value.length > 0)
const generateStatusTitle = computed(() => {
const status = activeGenerateTask.value?.status
if (status === 'WAITING') return '账单排队中'
if (status === 'CANCEL_REQUESTED') return '账单取消中'
return '账单生成中'
})
const generateStatusTooltip = computed(() => {
return activeGenerateTask.value?.status === 'CANCEL_REQUESTED'
? '正在清理已生成的账单数据,请稍候'
: '点击取消当前账单生成任务'
})
const generateProgressText = computed(() => {
const task = activeGenerateTask.value
if (!task || Number(task.totalCount) <= 0) return '…'
return `${Math.min(100, Math.floor(Number(task.processedCount || 0) * 100 / Number(task.totalCount)))}%`
})
const generateProgressDegree = computed(() => {
const task = activeGenerateTask.value
if (!task || Number(task.totalCount) <= 0) return 45
return Math.max(0, Math.min(360, Number(task.processedCount || 0) * 360 / Number(task.totalCount)))
})
const generateStatusSummary = computed(() => {
const task = activeGenerateTask.value
if (!task) return '任务提交中'
if (task.status === 'CANCEL_REQUESTED') return '正在清理数据'
if (task.status === 'WAITING') return '等待后台处理'
if (Number(task.totalCount) > 0) return `${Number(task.processedCount || 0)} / ${Number(task.totalCount)} 条`
return '正在准备数据'
})
const exportPollTimers = new Map()
const exportTasks = reactive(new Map())
const activeExportTasks = computed(() => Array.from(exportTasks.values()))
const exportSubmittingCount = ref(0)
const exportStatusVisible = computed(() => exportSubmittingCount.value > 0 || activeExportTasks.value.length > 0)
const statusOptions = AR_BILL_STATUS_OPTIONS const statusOptions = AR_BILL_STATUS_OPTIONS
const customerScoped = computed(() => Boolean(route.query.customerId)) const customerScoped = computed(() => Boolean(route.query.customerId))
const columns = [ const columns = [
@@ -59,12 +131,167 @@ const searchColumns = [
{ label: '状态', prop: 'status', component: 'select', options: statusOptions, placeholder: '全部状态', width: '150px' }, { label: '账单日期', prop: 'billDateRange', component: 'daterange', width: '300px' }, { label: '账单截止日', prop: 'billPeriodEnd', component: 'date', width: '160px' } { label: '状态', prop: 'status', component: 'select', options: statusOptions, placeholder: '全部状态', width: '150px' }, { label: '账单日期', prop: 'billDateRange', component: 'daterange', width: '300px' }, { label: '账单截止日', prop: 'billPeriodEnd', component: 'date', width: '160px' }
] ]
// 将页面日期范围转换为后端查询字段,其他筛选条件原样透传。
function queryList(params) { const query = { ...params }; if (query.billDateRange?.length === 2) { query.billDateStart = query.billDateRange[0]; query.billDateEnd = query.billDateRange[1] }; delete query.billDateRange; return listArBill(query) } function queryList(params) { const query = { ...params }; if (query.billDateRange?.length === 2) { query.billDateStart = query.billDateRange[0]; query.billDateEnd = query.billDateRange[1] }; delete query.billDateRange; return listArBill(query) }
function handleGenerateSuccess(bill) { tableRef.value.refresh(); viewBill(bill) } // 生成弹窗只提交任务,账单明细由后台异步批量写入;这里负责刷新列表并开始轮询。
function viewBill(row) { if (!row?.arBillId) return; detailOpen.value = true; detailLoading.value = true; detail.value = null; getArBill(row.arBillId).then(res => { detail.value = res.data || null }).finally(() => { detailLoading.value = false }) } function handleGenerateSuccess(task) {
tableRef.value?.refresh()
if (!task?.taskId) return
generateVisibleSince.set(task.taskId, Date.now())
generateTasks.set(task.taskId, task)
startGeneratePolling(task.taskId)
}
function startGeneratePolling(taskId) {
stopGeneratePolling(taskId)
// WAITING/RUNNING/CANCEL_REQUESTED 都属于前端持续关注的中间状态。
const poll = () => {
getArBillGenerateTask(taskId).then(res => {
const task = res.data
if (!task) return
generateTasks.set(taskId, task)
if (['SUCCESS', 'FAILED', 'CANCELED'].includes(task.status)) {
finishGenerateTask(taskId, task)
return
}
generatePollTimers.set(taskId, window.setTimeout(poll, 2000))
}).catch(() => {
generatePollTimers.set(taskId, window.setTimeout(poll, 3000))
})
}
generatePollTimers.set(taskId, window.setTimeout(poll, 1000))
}
function finishGenerateTask(taskId, task) {
stopGeneratePolling(taskId)
if (generateFinishTimers.has(taskId)) return
// 即使后台很快返回 SUCCESS,也延迟清理浮层,保证用户能看到结果反馈。
const visibleSince = generateVisibleSince.get(taskId) || Date.now()
const remaining = Math.max(0, MIN_GENERATE_STATUS_VISIBLE_MS - (Date.now() - visibleSince))
const timer = window.setTimeout(() => {
generateFinishTimers.delete(taskId)
generateVisibleSince.delete(taskId)
generateTasks.delete(taskId)
tableRef.value?.refresh()
if (task.status === 'SUCCESS') {
proxy.$modal.msgSuccess('账单生成成功')
if (task.arBillId) viewBill({ arBillId: task.arBillId })
} else if (task.status === 'FAILED') {
proxy.$modal.confirm(`账单生成失败:${task.errorMessage || '未知错误'},是否重新生成?`).then(() => retryGenerateTask(task.taskId)).then(res => {
const retryTask = res.data
if (retryTask?.taskId) {
generateVisibleSince.set(retryTask.taskId, Date.now())
generateTasks.set(retryTask.taskId, retryTask)
startGeneratePolling(retryTask.taskId)
}
}).catch(() => {})
} else {
proxy.$modal.msgWarning('账单生成任务已取消')
}
}, remaining)
generateFinishTimers.set(taskId, timer)
}
function stopGeneratePolling(taskId) {
const timer = generatePollTimers.get(taskId)
if (timer) window.clearTimeout(timer)
generatePollTimers.delete(taskId)
}
function cancelActiveGenerateTask() {
const task = activeGenerateTask.value
if (!task?.taskId) return
proxy.$modal.confirm('账单正在后台生成,确定取消当前任务吗?').then(() => cancelArBillGenerateTask(task.taskId)).then(res => {
const updated = res.data
if (updated) generateTasks.set(updated.taskId, updated)
proxy.$modal.msgSuccess('已提交取消请求')
}).catch(() => {})
}
function loadActiveGenerateTasks() {
listActiveArBillGenerateTasks().then(res => {
;(res.data || []).forEach(task => {
generateTasks.set(task.taskId, task)
if (!generateVisibleSince.has(task.taskId)) generateVisibleSince.set(task.taskId, Date.now())
startGeneratePolling(task.taskId)
})
}).catch(() => {})
}
let detailRequestSerial = 0
function viewBill(row) {
if (!row?.arBillId) return
const serial = ++detailRequestSerial
openedBillId.value = row.arBillId
detailOpen.value = true
detailLoading.value = true
detail.value = null
getArBill(row.arBillId).then(res => {
if (serial === detailRequestSerial) detail.value = res.data || null
}).finally(() => {
if (serial === detailRequestSerial) detailLoading.value = false
})
}
function confirmBill(row) { proxy.$modal.confirm(`确认账单「${row.billNo}」?确认后将进入正式账单状态,请确认订单及费用金额无误后继续。`).then(() => confirmArBill(row.arBillId)).then(() => { proxy.$modal.msgSuccess('账单已确认'); tableRef.value.refresh(); viewBill(row) }).catch(() => {}) } function confirmBill(row) { proxy.$modal.confirm(`确认账单「${row.billNo}」?确认后将进入正式账单状态,请确认订单及费用金额无误后继续。`).then(() => confirmArBill(row.arBillId)).then(() => { proxy.$modal.msgSuccess('账单已确认'); tableRef.value.refresh(); viewBill(row) }).catch(() => {}) }
function voidBill(row) { proxy.$prompt(`请输入账单「${row.billNo}」的作废原因`, '作废账单', { inputPattern: /\S+/, inputErrorMessage: '作废原因不能为空' }).then(({ value }) => voidArBill(row.arBillId, { remark: value })).then(() => { proxy.$modal.msgSuccess('账单已作废'); tableRef.value.refresh(); viewBill(row) }).catch(() => {}) } function voidBill(row) { proxy.$prompt(`请输入账单「${row.billNo}」的作废原因`, '作废账单', { inputPattern: /\S+/, inputErrorMessage: '作废原因不能为空' }).then(({ value }) => voidArBill(row.arBillId, { remark: value })).then(() => { proxy.$modal.msgSuccess('账单已作废'); tableRef.value.refresh(); viewBill(row) }).catch(() => {}) }
function exportBill(row) { if (!row?.arBillId) return; exportArBill(row.arBillId).then(res => { proxy.$modal.msgSuccess('导出成功'); tableRef.value.refresh(); if (detailOpen.value) viewBill(row); if (res.data?.exportId) downloadExport(res.data) }) } function exportBill(row) {
if (!row?.arBillId) return
exportSubmittingCount.value += 1
exportArBill(row.arBillId).then(res => {
const exportTask = res.data
tableRef.value?.refresh()
// 导出同样是异步任务;打开详情展示“生成中”状态及完成后的下载入口。
viewBill(row)
if (exportTask?.exportId) {
exportTasks.set(exportTask.exportId, exportTask)
startExportPolling(exportTask.exportId)
}
}).finally(() => {
exportSubmittingCount.value = Math.max(0, exportSubmittingCount.value - 1)
})
}
onMounted(loadActiveGenerateTasks)
function startExportPolling(exportId) {
stopExportPolling(exportId)
// 导出文件在后台临时目录生成,前端只轮询状态,不等待文件内容返回。
const poll = () => {
getArBillExport(exportId).then(res => {
const exportTask = res.data
if (!exportTask || exportTask.exportStatus === 'PROCESSING') {
if (exportTask) exportTasks.set(exportId, exportTask)
exportPollTimers.set(exportId, window.setTimeout(poll, 1500))
return
}
stopExportPolling(exportId)
exportTasks.delete(exportId)
tableRef.value?.refresh()
if (detailOpen.value && openedBillId.value === exportTask.arBillId) viewBill(exportTask)
if (exportTask.exportStatus === 'SUCCESS') {
proxy.$modal.msgSuccess('导出文件已生成,可在导出历史中下载')
} else {
proxy.$modal.msgError(`账单导出失败:${exportTask.remark || '请重新发起导出'}`)
}
}).catch(() => {
stopExportPolling(exportId)
exportTasks.delete(exportId)
})
}
exportPollTimers.set(exportId, window.setTimeout(poll, 1500))
}
function stopExportPolling(exportId) {
const timer = exportPollTimers.get(exportId)
if (timer) window.clearTimeout(timer)
exportPollTimers.delete(exportId)
}
function openExportTask(task) {
if (!task?.arBillId) return
viewBill({ arBillId: task.arBillId })
}
// 仅下载 SUCCESS 状态的导出任务,文件名优先使用后端保存的名称。
function downloadExport(row) { downloadArBillExport(row.exportId).then(blob => saveAs(blob, row.fileName || `账单_${row.exportId}.xlsx`)) } function downloadExport(row) { downloadArBillExport(row.exportId).then(blob => saveAs(blob, row.fileName || `账单_${row.exportId}.xlsx`)) }
function clearCustomerFilter() { function clearCustomerFilter() {
const query = { ...route.query } const query = { ...route.query }
@@ -72,7 +299,7 @@ function clearCustomerFilter() {
router.replace({ path: route.path, query }) router.replace({ path: route.path, query })
} }
// 从客户余额页重复进入不同客户时,路由组件会被复用,需同步更新表格内部查询条件 // 从客户余额页重复进入不同客户时,路由组件会被复用,需同步更新表格内部查询条件。
watch(() => route.query.customerId, (newId, oldId) => { watch(() => route.query.customerId, (newId, oldId) => {
if (String(newId || '') === String(oldId || '')) return if (String(newId || '') === String(oldId || '')) return
tableRef.value?.setQueryParams({ tableRef.value?.setQueryParams({
@@ -82,6 +309,18 @@ watch(() => route.query.customerId, (newId, oldId) => {
tableRef.value?.handleQuery() tableRef.value?.handleQuery()
}) })
// 页面销毁时清理所有轮询定时器,避免切换菜单后仍访问旧页面状态。
onBeforeUnmount(() => {
generatePollTimers.forEach(timer => window.clearTimeout(timer))
generatePollTimers.clear()
generateFinishTimers.forEach(timer => window.clearTimeout(timer))
generateFinishTimers.clear()
generateVisibleSince.clear()
exportPollTimers.forEach(timer => window.clearTimeout(timer))
exportPollTimers.clear()
exportTasks.clear()
})
function canGoWriteoff(row) { function canGoWriteoff(row) {
if (!row?.customerId) return false if (!row?.customerId) return false
const canWriteoffStatus = row.status === AR_BILL_STATUS.CONFIRMED const canWriteoffStatus = row.status === AR_BILL_STATUS.CONFIRMED
@@ -152,6 +391,136 @@ function statusType(value) { return AR_BILL_STATUS_TYPES[value] || 'info' }
.ar-bill-page .written { color: #67c23a; } .ar-bill-page .written { color: #67c23a; }
.ar-bill-page .unwritten { color: #e6a23c; } .ar-bill-page .unwritten { color: #e6a23c; }
.ar-bill-page .generate-status-float {
position: fixed;
top: 92px;
right: 24px;
z-index: 3001;
}
.ar-bill-page .generate-status-button {
display: inline-flex;
align-items: center;
gap: 10px;
min-width: 184px;
min-height: 68px;
padding: 6px 16px 6px 7px;
border-radius: 36px;
}
.ar-bill-page .generate-progress-ring {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 54px;
width: 54px;
height: 54px;
border-radius: 50%;
background: conic-gradient(
var(--el-color-primary) 0deg var(--generate-progress, 45deg),
var(--el-color-primary-light-8) var(--generate-progress, 45deg) 360deg
);
}
.ar-bill-page .generate-progress-ring::after {
position: absolute;
inset: 5px;
content: '';
border-radius: 50%;
background: var(--el-bg-color);
}
.ar-bill-page .generate-progress-value {
position: relative;
z-index: 1;
color: var(--el-color-primary);
font-size: 12px;
font-weight: 600;
line-height: 1;
}
.ar-bill-page .generate-status-text {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 3px;
line-height: 1.2;
white-space: nowrap;
}
.ar-bill-page .generate-status-text strong { font-size: 14px; font-weight: 600; }
.ar-bill-page .generate-status-text small { color: var(--el-text-color-secondary); font-size: 11px; }
.ar-bill-page .export-status-float {
position: fixed;
top: 170px;
right: 24px;
z-index: 3001;
}
.ar-bill-page .export-status-button {
display: inline-flex;
align-items: center;
gap: 10px;
min-width: 164px;
min-height: 62px;
border-radius: 32px;
padding: 6px 16px 6px 7px;
}
.ar-bill-page .export-progress-ring {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 48px;
width: 48px;
height: 48px;
border-radius: 50%;
background: conic-gradient(var(--el-color-primary) 0deg 285deg, var(--el-color-primary-light-8) 285deg 360deg);
animation: ar-bill-export-spin 1.2s linear infinite;
}
.ar-bill-page .export-progress-ring::after {
position: absolute;
inset: 5px;
content: '';
border-radius: 50%;
background: var(--el-bg-color);
}
.ar-bill-page .export-download-icon {
position: relative;
z-index: 1;
color: var(--el-color-primary);
font-size: 21px;
}
.ar-bill-page .export-status-text {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
line-height: 1.2;
white-space: nowrap;
}
.ar-bill-page .export-status-text strong {
font-size: 14px;
font-weight: 600;
}
.ar-bill-page .export-status-text small {
color: var(--el-text-color-secondary);
font-size: 11px;
}
@keyframes ar-bill-export-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@media (max-width: 1200px) { @media (max-width: 1200px) {
.ar-bill-page :deep(.basic-table > .basic-form) { .ar-bill-page :deep(.basic-table > .basic-form) {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
+2
View File
@@ -19,12 +19,14 @@ import {
} from '@/constants/ar/enums' } from '@/constants/ar/enums'
const tableRef = ref() const tableRef = ref()
// 日志筛选值与后端枚举保持一致,展示文案统一由枚举工具转换。
const searchColumns = [ const searchColumns = [
{ label: '对象类型', prop: 'objectType', component: 'select', options: AR_LOG_OBJECT_TYPE_OPTIONS, width: '130px' }, { label: '对象类型', prop: 'objectType', component: 'select', options: AR_LOG_OBJECT_TYPE_OPTIONS, width: '130px' },
{ label: '操作类型', prop: 'operationType', component: 'select', options: AR_LOG_OPERATION_TYPE_OPTIONS, width: '150px' }, { label: '操作类型', prop: 'operationType', component: 'select', options: AR_LOG_OPERATION_TYPE_OPTIONS, width: '150px' },
{ label: '操作来源', prop: 'operationSource', component: 'select', options: AR_LOG_OPERATION_SOURCE_OPTIONS, width: '130px' }, { label: '操作来源', prop: 'operationSource', component: 'select', options: AR_LOG_OPERATION_SOURCE_OPTIONS, width: '130px' },
{ label: '结果', prop: 'result', component: 'select', options: AR_LOG_RESULT_OPTIONS, width: '120px' } { label: '结果', prop: 'result', component: 'select', options: AR_LOG_RESULT_OPTIONS, width: '120px' }
] ]
// 状态变化列同时展示前后状态,便于追踪确认、作废和核销等生命周期操作。
const columns = [ const columns = [
{ label: '对象类型', prop: 'objectType', width: 110, formatter: row => objectTypeText(row.objectType) }, { label: '对象', prop: 'objectDisplay', minWidth: 220, showOverflowTooltip: true, formatter: row => row.objectDisplay || '-' }, { label: '对象类型', prop: 'objectType', width: 110, formatter: row => objectTypeText(row.objectType) }, { label: '对象', prop: 'objectDisplay', minWidth: 220, showOverflowTooltip: true, formatter: row => row.objectDisplay || '-' },
{ label: '操作', prop: 'operationType', width: 150, formatter: row => operationText(row.operationType) }, { label: '说明', prop: 'operationDesc', minWidth: 220, showOverflowTooltip: true }, { label: '操作', prop: 'operationType', width: 150, formatter: row => operationText(row.operationType) }, { label: '说明', prop: 'operationDesc', minWidth: 220, showOverflowTooltip: true },