✨ 支持应收账单异步作废

This commit is contained in:
caojia
2026-11-12 17:33:21 +08:00
parent 0d0613e810
commit 1ba431540d
16 changed files with 400 additions and 62 deletions
@@ -9,6 +9,17 @@ import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
@Configuration
public class ArBillExportTaskConfig
{
@Bean(name = "arBillVoidTaskExecutor")
public ThreadPoolTaskExecutor arBillVoidTaskExecutor()
{
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(1);
executor.setMaxPoolSize(1);
executor.setQueueCapacity(5);
executor.setThreadNamePrefix("ar-bill-void-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
return executor;
}
/**
* 账单导出会同时占用数据库连接、CPU 和磁盘,最多允许两个任务并发生成。
* 队列满时由提交器将任务标记为失败,避免回退到 Web 请求线程执行。
@@ -70,6 +70,10 @@ public class ArBill extends BaseEntity {
* 状态:DRAFT草稿、CONFIRMED已确认、PARTIAL_WRITTEN_OFF部分核销、WRITTEN_OFF已核销、VOID已作废。
*/
private String status;
/** 非空表示账单有正在后台执行的作废任务。 */
private Long voidTaskId;
/** 最近一次作废失败的原因。 */
private String voidTaskError;
/**
* 生成状态:GENERATING生成中、READY可用、FAILED生成失败。
*/
@@ -0,0 +1,20 @@
package com.bms.receivable.domain;
import java.util.Date;
import lombok.Data;
/** 作废任务在数据库中持久化,后台事务完成前保持 WAITING。 */
@Data
public class ArBillVoidTask {
private Long taskId;
private Long arBillId;
private String fromStatus;
private String reason;
private String status;
private String errorMessage;
private String requestId;
private String createBy;
private Long createUserId;
private Date createTime;
private Date finishedAt;
}
@@ -0,0 +1,3 @@
package com.bms.receivable.event;
public record ArBillVoidTaskEvent(Long taskId) { }
@@ -0,0 +1,16 @@
package com.bms.receivable.mapper;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.bms.receivable.domain.ArBillVoidTask;
public interface ArBillVoidTaskMapper {
int insert(ArBillVoidTask task);
ArBillVoidTask selectById(@Param("taskId") Long taskId);
ArBillVoidTask selectByIdForUpdate(@Param("taskId") Long taskId);
ArBillVoidTask selectActive(@Param("arBillId") Long arBillId);
ArBillVoidTask selectActiveForUpdate(@Param("arBillId") Long arBillId);
List<ArBillVoidTask> selectWaiting(@Param("limit") int limit);
int markSuccess(@Param("taskId") Long taskId);
int markFailed(@Param("taskId") Long taskId, @Param("errorMessage") String errorMessage);
}
@@ -0,0 +1,126 @@
package com.bms.receivable.service;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.transaction.PlatformTransactionManager;
import com.bms.common.exception.ServiceException;
import com.bms.common.utils.SecurityUtils;
import com.bms.receivable.domain.ArBill;
import com.bms.receivable.domain.ArBillLog;
import com.bms.receivable.domain.ArBillVoidTask;
import com.bms.receivable.enums.ArBillGenerationStatus;
import com.bms.receivable.enums.ArBillStatus;
import com.bms.receivable.event.ArBillVoidTaskEvent;
import com.bms.receivable.mapper.ArBillMapper;
import com.bms.receivable.mapper.ArBillVoidTaskMapper;
import com.bms.receivable.security.ArBillScopeResolver;
import com.bms.price.mapper.PrcIncomeBillFeeMapper;
import com.bms.price.mapper.PrcOrderBillMapper;
/** 任务状态持久化;账单行锁保证提交、核销与执行串行。 */
@Service
public class ArBillVoidTaskService {
private final ArBillMapper bills;
private final ArBillVoidTaskMapper tasks;
private final ArBillScopeResolver scope;
private final PrcIncomeBillFeeMapper fees;
private final PrcOrderBillMapper orders;
private final IArBillLogService logs;
private final ApplicationEventPublisher events;
private final TransactionTemplate transaction;
public ArBillVoidTaskService(ArBillMapper bills, ArBillVoidTaskMapper tasks, ArBillScopeResolver scope,
PrcIncomeBillFeeMapper fees, PrcOrderBillMapper orders, IArBillLogService logs,
ApplicationEventPublisher events, PlatformTransactionManager manager) {
this.bills = bills; this.tasks = tasks; this.scope = scope; this.fees = fees;
this.orders = orders; this.logs = logs; this.events = events; this.transaction = new TransactionTemplate(manager);
}
@Transactional(rollbackFor = Exception.class)
public ArBillVoidTask submit(Long billId, String reason, String operator, String requestId) {
if (reason == null || reason.trim().isEmpty()) throw new ServiceException("作废必须填写原因");
if (reason.length() > 500) throw new ServiceException("作废原因不能超过500字");
scope.requireBillAccessible(billId);
ArBillVoidTask active = tasks.selectActive(billId);
if (active != null) return active;
ArBill bill = bills.selectArBillByIdForUpdate(billId);
if (bill == null) throw new ServiceException("应收账单不存在");
active = tasks.selectActiveForUpdate(billId);
if (active != null) return active;
if (!ArBillGenerationStatus.READY.equals(bill.getGenerationStatus())) throw new ServiceException("账单尚未生成完成,暂不能作废");
ArBillStatus status = ArBillStatus.of(bill.getStatus());
if (status == null || !status.canVoid()) throw new ServiceException("当前状态不允许作废");
ArBillVoidTask task = new ArBillVoidTask();
task.setArBillId(billId); task.setFromStatus(bill.getStatus()); task.setReason(reason);
task.setRequestId(requestId); task.setCreateBy(operator); task.setCreateUserId(SecurityUtils.getUserId());
task.setStatus("WAITING");
tasks.insert(task);
events.publishEvent(new ArBillVoidTaskEvent(task.getTaskId()));
return task;
}
public ArBillVoidTask get(Long taskId) {
ArBillVoidTask task = tasks.selectById(taskId);
if (task == null) throw new ServiceException("作废任务不存在");
scope.requireBillAccessible(task.getArBillId());
return task;
}
/** 调用方必须先锁定账单行,保持与任务执行相同的加锁顺序。 */
public void requireNotVoiding(Long billId) {
if (tasks.selectActiveForUpdate(billId) != null) throw new ServiceException("账单正在作废中,请稍后再试");
}
public void execute(Long taskId) {
ArBillVoidTask initial = tasks.selectById(taskId);
if (initial == null || !"WAITING".equals(initial.getStatus())) return;
try {
transaction.executeWithoutResult(s -> {
ArBill bill = bills.selectArBillByIdForUpdate(initial.getArBillId());
ArBillVoidTask task = tasks.selectByIdForUpdate(taskId);
if (task == null || !"WAITING".equals(task.getStatus())) return;
if (bill == null || !task.getFromStatus().equals(bill.getStatus())
|| !ArBillGenerationStatus.READY.equals(bill.getGenerationStatus())) {
throw new ServiceException("账单状态已发生变化,无法作废");
}
if (bills.updateArBillStatus(bill.getArBillId(), "VOID", task.getReason(), task.getCreateBy(), task.getFromStatus()) != 1)
throw new ServiceException("账单状态已发生变化,无法作废");
fees.restoreUnbilledIfNoActiveArBillByArBillId(bill.getArBillId());
orders.refreshArBillStatusByArBillId(bill.getArBillId());
ArBillLog log = new ArBillLog();
log.setArBillId(bill.getArBillId()); log.setObjectType("BILL"); log.setObjectId(String.valueOf(bill.getArBillId()));
log.setOperationType("VOID");
String description = "作废应收账单:" + task.getReason();
log.setOperationDesc(description.substring(0, Math.min(500, description.length())));
log.setFromStatus(task.getFromStatus()); log.setToStatus("VOID"); log.setOperatorId(task.getCreateUserId());
log.setBeforeData("{\"status\":\"" + task.getFromStatus() + "\"}");
log.setAfterData("{\"status\":\"VOID\"}");
log.setOperatorName(task.getCreateBy()); log.setOperationSource("WEB"); log.setRequestId(task.getRequestId()); log.setResult("SUCCESS");
logs.record(log);
tasks.markSuccess(taskId);
});
} catch (Exception e) {
// 事务回滚后重新锁定账单和任务,避免覆盖其他节点已完成的结果。
transaction.executeWithoutResult(s -> {
bills.selectArBillByIdForUpdate(initial.getArBillId());
ArBillVoidTask task = tasks.selectByIdForUpdate(taskId);
if (task != null && "WAITING".equals(task.getStatus())) {
String message = e instanceof ServiceException ? e.getMessage() : "后台处理失败,请稍后重试";
message = message == null ? "后台处理失败,请稍后重试" : message.substring(0, Math.min(500, message.length()));
if (tasks.markFailed(taskId, message) == 1) {
ArBillLog failure = new ArBillLog();
failure.setArBillId(task.getArBillId()); failure.setObjectType("BILL");
failure.setObjectId(String.valueOf(task.getArBillId())); failure.setOperationType("VOID");
failure.setOperationDesc("应收账单作废失败"); failure.setFromStatus(task.getFromStatus());
failure.setOperatorId(task.getCreateUserId()); failure.setOperatorName(task.getCreateBy());
failure.setOperationSource("WEB"); failure.setRequestId(task.getRequestId());
failure.setResult("FAILED"); failure.setErrorMessage(message); logs.record(failure);
}
}
});
throw new IllegalStateException("应收账单作废任务执行失败", e);
}
}
}
@@ -72,8 +72,6 @@ public interface IArBillService
ArBill confirmBill(Long arBillId, ArBillActionRequest request, String operator, String requestId);
ArBill voidBill(Long arBillId, ArBillActionRequest request, String operator, String requestId);
ArBillExport exportBill(Long arBillId, String operator, String requestId);
ArBillExport selectExport(Long exportId);
@@ -84,6 +84,7 @@ import com.bms.receivable.mapper.ArBankAccountMapper;
import com.bms.receivable.mapper.ArExportTemplateMapper;
import com.bms.receivable.service.IArBillLogService;
import com.bms.receivable.service.IArBillService;
import com.bms.receivable.service.ArBillVoidTaskService;
import com.bms.receivable.domain.vo.ArBillActionRequest;
import com.bms.receivable.domain.vo.ArBillGenerateQuery;
import com.bms.receivable.domain.vo.ArBillGenerateRequest;
@@ -204,6 +205,8 @@ public class ArBillServiceImpl implements IArBillService {
@Autowired
private ArBillMapper billMapper;
@Autowired
private ArBillVoidTaskService voidTaskService;
@Autowired
private BmsCustomerMapper customerMapper;
@Autowired
private SysUserMapper sysUserMapper;
@@ -1024,51 +1027,6 @@ public class ArBillServiceImpl implements IArBillService {
return result;
}
/**
* 作废账单并释放来源订单;明细不删除,用于保留历史快照和审计依据。
*/
@Override
@Transactional(rollbackFor = Exception.class)
public ArBill voidBill(Long arBillId, ArBillActionRequest request, String operator, String requestId) {
long startedAt = System.nanoTime();
String remark = request == null ? null : request.getRemark();
if (StringUtils.isEmpty(remark)) {
throw failure(arBillId, ArLogObjectType.BILL, String.valueOf(arBillId), ArLogOperationType.VOID,
null, "作废必须填写原因", operator, requestId);
}
ArBill bill = billMapper.selectArBillByIdForUpdate(arBillId);
billScope.requireBillAccessible(arBillId);
if (bill == null) {
throw failure(arBillId, ArLogObjectType.BILL, String.valueOf(arBillId), ArLogOperationType.VOID,
null, "应收账单不存在", operator, requestId);
}
if (!ArBillGenerationStatus.READY.equals(bill.getGenerationStatus())) {
throw failure(arBillId, ArLogObjectType.BILL, String.valueOf(arBillId), ArLogOperationType.VOID,
bill.getStatus(), "账单尚未生成完成,暂不能作废", operator, requestId);
}
ArBillStatus currentStatus = ArBillStatus.of(bill.getStatus());
if (currentStatus == null || !currentStatus.canVoid()) {
throw failure(arBillId, ArLogObjectType.BILL, String.valueOf(arBillId), ArLogOperationType.VOID,
bill.getStatus(),
"当前状态不允许作废", operator, requestId);
}
if (billMapper.updateArBillStatus(arBillId, ArBillStatus.VOID.getCode(), remark, operator, currentStatus.getCode()) != 1) {
throw failure(arBillId, ArLogObjectType.BILL, String.valueOf(arBillId), ArLogOperationType.VOID,
bill.getStatus(),
"账单状态已发生变化,请刷新后重试", operator, requestId);
}
// 作废后保留历史明细,只释放本账单关联的费用;若费用仍被其他有效账单引用,则继续保持 BILLED。
sourceFeeMapper.restoreUnbilledIfNoActiveArBillByArBillId(arBillId);
sourceBillMapper.refreshArBillStatusByArBillId(arBillId);
recordSuccess(bill, ArLogObjectType.BILL, ArLogOperationType.VOID,
"作废应收账单:" + remark, bill.getStatus(), ArBillStatus.VOID.getCode(), requestId,
SecurityUtils.getUserId(), operator);
ArBill result = selectArBillById(arBillId);
log.info("应收账单作废处理完成,operator={}, requestId={}, arBillId={}, status={}, costMs={}",
safeLogValue(operator), safeLogValue(requestId), arBillId, result.getStatus(), elapsedMillis(startedAt));
return result;
}
/**
* 创建异步导出任务;模板版本在提交时绑定,保证后续模板变更不影响历史导出。
*/
@@ -1078,6 +1036,9 @@ public class ArBillServiceImpl implements IArBillService {
long startedAt = System.nanoTime();
Long exportUserId = SecurityUtils.getUserId();
billScope.requireBillAccessible(arBillId);
ArBill bill = billMapper.selectArBillByIdForUpdate(arBillId);
if (bill == null) throw new ServiceException("应收账单不存在");
voidTaskService.requireNotVoiding(arBillId);
ArBillLog previous = logMapper.selectSuccessByRequestId(requestId, ArLogObjectType.EXPORT.getCode(),
ArLogResult.SUCCESS.getCode());
if (previous != null && isNumber(previous.getObjectId())) {
@@ -1090,11 +1051,6 @@ public class ArBillServiceImpl implements IArBillService {
return previousExport;
}
}
ArBill bill = billMapper.selectArBillById(arBillId);
if (bill == null) {
throw failure(arBillId, ArLogObjectType.EXPORT, String.valueOf(arBillId), ArLogOperationType.EXPORT,
null, "应收账单不存在", operator, requestId);
}
if (!ArBillGenerationStatus.READY.equals(bill.getGenerationStatus())) {
throw failure(arBillId, ArLogObjectType.EXPORT, String.valueOf(arBillId), ArLogOperationType.EXPORT,
bill.getStatus(), "账单尚未生成完成,暂不能导出", operator, requestId);
@@ -0,0 +1,50 @@
package com.bms.receivable.task;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Qualifier;
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.bms.receivable.event.ArBillVoidTaskEvent;
import com.bms.receivable.mapper.ArBillVoidTaskMapper;
import com.bms.receivable.service.ArBillVoidTaskService;
/** WAITING 任务的业务事务回滚时仍可由定时调度恢复。 */
@Component
public class ArBillVoidTaskRunner {
private static final Logger log = LoggerFactory.getLogger(ArBillVoidTaskRunner.class);
private final Set<Long> scheduled = ConcurrentHashMap.newKeySet();
private final ArBillVoidTaskMapper tasks;
private final ArBillVoidTaskService service;
private final ThreadPoolTaskExecutor executor;
public ArBillVoidTaskRunner(ArBillVoidTaskMapper tasks, ArBillVoidTaskService service,
@Qualifier("arBillVoidTaskExecutor") ThreadPoolTaskExecutor executor) {
this.tasks = tasks; this.service = service; this.executor = executor;
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onTask(ArBillVoidTaskEvent event) { submit(event.taskId()); }
@Scheduled(cron = "0/30 * * * * ?")
public void recover() {
try { tasks.selectWaiting(20).forEach(task -> submit(task.getTaskId())); }
catch (Exception e) { log.error("调度应收账单作废任务失败", e); }
}
private void submit(Long taskId) {
if (!scheduled.add(taskId)) return;
try {
executor.execute(() -> {
try { service.execute(taskId); }
catch (Exception e) { log.error("应收账单作废任务执行失败,taskId={}", taskId, e); }
finally { scheduled.remove(taskId); }
});
} catch (Exception e) {
scheduled.remove(taskId);
log.warn("作废任务暂未投递,将由调度器重试,taskId={}", taskId, e);
}
}
}
@@ -24,6 +24,8 @@
<result property="writtenOffAmount" column="written_off_amount"/>
<result property="unwrittenAmount" column="unwritten_amount"/>
<result property="status" column="status"/>
<result property="voidTaskId" column="void_task_id"/>
<result property="voidTaskError" column="void_task_error"/>
<result property="generationStatus" column="generation_status"/>
<result property="billVersion" column="bill_version"/>
<result property="bankAccountName" column="bank_account_name"/>
@@ -111,7 +113,11 @@
</sql>
<select id="selectArBillList" resultMap="ArBillResult">
select b.* from ar_bill b
select b.*, (select vt.task_id from ar_bill_void_task vt
where vt.ar_bill_id = b.ar_bill_id and vt.status = 'WAITING' limit 1) as void_task_id,
(select case when vt.status = 'FAILED' then vt.error_message else null end
from ar_bill_void_task vt where vt.ar_bill_id = b.ar_bill_id order by vt.task_id desc limit 1) as void_task_error
from ar_bill b
<include refid="arBillListFilter"/>
order by create_time desc, ar_bill_id desc
</select>
@@ -246,6 +252,9 @@
confirmed_time = case when #{status} = 'CONFIRMED' then sysdate() else confirmed_time end
where ar_bill_id = #{arBillId}
and status = #{fromStatus}
and (#{status} = 'VOID' or not exists (
select 1 from ar_bill_void_task vt where vt.ar_bill_id = #{arBillId} and vt.status = 'WAITING'
))
</update>
<update id="updateArBillAmounts">
@@ -257,6 +266,9 @@
update_time = sysdate()
where ar_bill_id = #{arBillId}
and status = #{fromStatus}
and not exists (
select 1 from ar_bill_void_task vt where vt.ar_bill_id = #{arBillId} and vt.status = 'WAITING'
)
</update>
<update id="updateArBillProgress">
@@ -0,0 +1,34 @@
<?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.bms.receivable.mapper.ArBillVoidTaskMapper">
<resultMap id="Task" type="com.bms.receivable.domain.ArBillVoidTask">
<id property="taskId" column="task_id"/>
<result property="arBillId" column="ar_bill_id"/>
<result property="fromStatus" column="from_status"/>
<result property="reason" column="reason"/>
<result property="status" column="status"/>
<result property="errorMessage" column="error_message"/>
<result property="requestId" column="request_id"/>
<result property="createBy" column="create_by"/>
<result property="createUserId" column="create_user_id"/>
<result property="createTime" column="create_time"/>
<result property="finishedAt" column="finished_at"/>
</resultMap>
<insert id="insert" useGeneratedKeys="true" keyProperty="taskId">
insert into ar_bill_void_task (ar_bill_id, from_status, reason, status, request_id, create_by, create_user_id, create_time)
values (#{arBillId}, #{fromStatus}, #{reason}, 'WAITING', #{requestId}, #{createBy}, #{createUserId}, sysdate())
</insert>
<select id="selectById" resultMap="Task">select * from ar_bill_void_task where task_id = #{taskId}</select>
<select id="selectByIdForUpdate" resultMap="Task">select * from ar_bill_void_task where task_id = #{taskId} for update</select>
<select id="selectActive" resultMap="Task">
select * from ar_bill_void_task where ar_bill_id = #{arBillId} and status = 'WAITING' limit 1
</select>
<select id="selectActiveForUpdate" resultMap="Task">
select * from ar_bill_void_task where ar_bill_id = #{arBillId} and status = 'WAITING' limit 1 for update
</select>
<select id="selectWaiting" resultMap="Task">
select * from ar_bill_void_task where status = 'WAITING' order by task_id limit #{limit}
</select>
<update id="markSuccess">update ar_bill_void_task set status = 'SUCCESS', finished_at = sysdate() where task_id = #{taskId} and status = 'WAITING'</update>
<update id="markFailed">update ar_bill_void_task set status = 'FAILED', error_message = #{errorMessage}, finished_at = sysdate() where task_id = #{taskId} and status = 'WAITING'</update>
</mapper>