63 Commits

Author SHA1 Message Date
caojia 2d5ffc4e06 Merge branch 'test' 2026-11-12 18:10:16 +08:00
caojia 1ba431540d ✨ 支持应收账单异步作废 2026-11-12 17:33:21 +08:00
wuweihua f03aa7be6c Merge branch 'test' of https://git.lionquexpress.com/wuweihua/BMS
# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
2026-11-12 17:12:21 +08:00
caojia 0d0613e810 ⚡ 移除应收账单确认时的明细校验 2026-11-12 17:08:56 +08:00
caojia 47b95b6bfd 💄 调整应收合计查询按钮显示 2026-11-12 17:08:56 +08:00
caojia 73ce7f4eae Merge branch 'test' 2026-11-12 15:05:39 +08:00
caojia b49600ab9f Merge branch 'test'
# Conflicts:
#	bms-back/bms-system/src/main/java/com/bms/price/service/impl/PrcOrderBillServiceImpl.java
2026-11-11 17:57:33 +08:00
caojia fb41b2a86c Merge branch 'test' 2026-11-11 11:32:25 +08:00
wuweihua fe30b8576b Merge branch 'master' of https://git.lionquexpress.com/wuweihua/BMS 2026-11-10 19:02:52 +08:00
wuweihua 65fcb6895a 修改计费bug 2026-11-10 19:02:47 +08:00
caojia d4f5bb8a4c Merge branch 'test' 2026-11-10 18:11:49 +08:00
caojia e71103af58 Merge branch 'test' 2026-11-10 18:04:49 +08:00
caojia 79cc524c03 Merge branch 'test'
# Conflicts:
#	bms-back/bms-admin/src/main/java/com/bms/web/controller/price/SrvBillAirController.java
#	bms-back/bms-system/src/main/java/com/bms/price/airbill/domain/SrvBillAirFee.java
#	bms-back/bms-system/src/main/java/com/bms/price/airbill/domain/SrvBillAirFeeLog.java
#	bms-back/bms-system/src/main/java/com/bms/price/mapper/PrcOrderBillMapper.java
#	bms-back/bms-system/src/main/java/com/bms/price/mapper/SrvBillAirFeeMapper.java
#	bms-back/bms-system/src/main/java/com/bms/price/service/ISrvBillAirFeeService.java
#	bms-back/bms-system/src/main/java/com/bms/price/service/impl/SrvBillAirFeeServiceImpl.java
#	bms-back/bms-system/src/main/resources/mapper/price/SrvBillAirFeeMapper.xml
#	bms-back/sql/price/air_bill_menu.sql
#	bms-front/src/api/price/airBill.js
#	bms-front/src/views/price/billing/airBill.vue
#	bms-front/src/views/price/billing/components/AirBillFeeDialog.vue
#	bms-front/src/views/price/billing/components/BillDetail.vue
2026-11-10 17:46:55 +08:00
wuweihua efc7ecbd01 增加计费日志显示 2026-11-09 14:26:37 +08:00
wuweihua b8bbf36767 修改删除逻辑 2026-11-09 14:26:36 +08:00
wuweihua 92cb19fbea 合并套餐 2026-11-09 11:45:35 +08:00
wuweihua 4cb82ec107 修改去空格 2026-11-09 11:45:33 +08:00
liuminghui e0dc755ee3 新增提单费用和费用日志 2026-11-09 11:45:27 +08:00
wuweihua 6a02de8069 增加计费列表收货时间查询 2026-11-09 11:45:26 +08:00
wuweihua 974f12429a 回退代码去掉收货查询 2026-11-09 11:45:25 +08:00
wuweihua e06f07b3dd 去掉监控的价格日志 2026-11-09 11:45:24 +08:00
wuweihua 956fb08bea 增加价格变更日志 2026-11-09 11:45:23 +08:00
wuweihua 8d34f84cbb 增加版本号显示 2026-11-09 11:45:22 +08:00
wuweihua b9cdb6c3f9 增加查看计费历史记录 2026-11-09 11:45:20 +08:00
wuweihua f57af555ea 增加查看价格套餐历史版本的变更 2026-11-09 11:45:20 +08:00
caojia 220b01127b 调整模板 2026-11-09 11:29:34 +08:00
caojia aa61dc2d1c 🐛 修正账单预览导出重量精度 2026-11-09 11:29:34 +08:00
caojia 86ed86fc68 🐛 修正账单预览重量精度显示 2026-11-09 11:29:33 +08:00
caojia fba8b786c0 Merge branch 'test' 2026-11-05 17:21:01 +08:00
caojia a4bda4d636 Merge branch 'test' 2026-11-04 18:32:28 +08:00
caojia 00e36cdf3f Merge branch 'test' 2026-11-04 15:20:56 +08:00
caojia 8eb5dca4f0 Merge branch 'test' 2026-11-04 13:45:59 +08:00
caojia b671ebefae Merge branch 'test' 2026-11-04 13:40:30 +08:00
caojia fb5d6d46a8 Merge branch 'test' 2026-11-04 10:27:30 +08:00
wuweihua 039c0d4858 Merge branch 'test' of https://git.lionquexpress.com/wuweihua/BMS
# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
2026-11-03 18:16:08 +08:00
caojia 43299e5329 Merge branch 'test' 2026-11-03 17:39:19 +08:00
caojia 3fe34c471c Merge branch 'test' 2026-11-02 16:54:26 +08:00
caojia 134855fc31 Merge branch 'test' 2026-11-02 10:07:19 +08:00
caojia 01cee41301 Merge branch 'test' 2026-11-02 09:17:44 +08:00
caojia 1f0d757e29 🐛 按当天截止时间筛选账单订单 2026-10-30 15:55:12 +08:00
caojia 762cad2c0d ✨ 新增应收账单日期类型筛选 2026-10-30 15:23:20 +08:00
wuweihua 7ef3f40a4f Merge branch 'test' of https://git.lionquexpress.com/wuweihua/BMS
# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
2026-10-29 18:19:48 +08:00
caojia 58e41b669d Merge branch 'test' 2026-10-29 16:59:06 +08:00
caojia 9a48abbacd Merge branch 'test' 2026-10-28 22:25:05 +08:00
wuweihua f95edbdf0a Merge branch 'master' of D:\wuweihua\BMS with conflicts. 2026-10-28 15:57:58 +08:00
wuweihua b30dfdf675 Merge branch 'test' of https://git.lionquexpress.com/wuweihua/BMS
# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
2026-10-28 15:56:10 +08:00
wuweihua 9a393fe7dc Merge branch 'master' of D:\wuweihua\BMS with conflicts. 2026-10-28 15:32:21 +08:00
wuweihua a2468099c2 Merge branch 'test' of https://git.lionquexpress.com/wuweihua/BMS
# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
2026-10-28 15:30:47 +08:00
caojia cbed4dc2a3 Merge branch 'test' 2026-10-28 09:28:14 +08:00
caojia d66b96b2ff Merge branch 'test' 2026-10-27 18:25:18 +08:00
wuweihua cdfc90556d 隐藏修改计费重 2026-10-27 17:51:40 +08:00
wuweihua dfa90d6e33 Merge branch 'master' of D:\wuweihua\BMS with conflicts. 2026-10-27 16:25:11 +08:00
wuweihua 4a34075022 Merge branch 'test' of https://git.lionquexpress.com/wuweihua/BMS
# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
2026-10-27 16:24:36 +08:00
wuweihua 6a03300f2f Merge branch 'test' of https://git.lionquexpress.com/wuweihua/BMS
# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
2026-10-27 10:31:58 +08:00
liuminghui 7496f8cbc6 Merge branch 'test' 2026-10-27 10:21:57 +08:00
wuweihua 2f2305825a Merge branch 'test' of https://git.lionquexpress.com/wuweihua/BMS
# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
2026-10-27 01:10:31 +08:00
wuweihua b79cd6f995 Merge branch 'master' of D:\wuweihua\BMS with conflicts. 2026-10-27 00:17:37 +08:00
wuweihua 70a3c34c20 Merge branch 'test' of https://git.lionquexpress.com/wuweihua/BMS
# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
2026-10-27 00:16:40 +08:00
caojia 2b5633190e Merge branch 'test' 2026-10-26 18:01:23 +08:00
wuweihua 77dfb4291b Merge remote-tracking branch 'origin/test' 2026-10-26 17:13:44 +08:00
caojia d71b7f04fe Merge branch 'test' 2026-10-26 17:09:01 +08:00
caojia f8ae347846 Merge branch 'test' 2026-10-26 16:39:11 +08:00
wuweihua 94991a1ed3 Merge pull request '修改操作文档' (#1) from test into master
Reviewed-on: #1
2026-10-26 15:23:59 +08:00
20 changed files with 524 additions and 137 deletions
@@ -35,6 +35,7 @@ import com.bms.receivable.domain.vo.ArBillGenerateRequest;
import com.bms.receivable.domain.vo.ArBillListExportRow;
import com.bms.receivable.domain.vo.ArBillQuery;
import com.bms.receivable.service.IArBillService;
import com.bms.receivable.service.ArBillVoidTaskService;
import com.bms.receivable.security.ArBillScopeResolver;
import com.bms.receivable.task.ArBillGeneratePreviewExportRunner;
import com.bms.common.annotation.Log;
@@ -61,6 +62,9 @@ public class ArBillController extends BaseController {
@Autowired
private IArBillService billService;
@Autowired
private ArBillVoidTaskService voidTaskService;
@Autowired
private ArBillScopeResolver billScope;
@@ -297,7 +301,13 @@ public class ArBillController extends BaseController {
public AjaxResult voidBill(@PathVariable Long arBillId, @RequestBody ArBillActionRequest request,
@RequestHeader(value = "X-Request-Id", required = false) String headerRequestId) {
String requestId = requestId(request == null ? null : request.getRequestId(), headerRequestId);
return success(billService.voidBill(arBillId, request, getUsername(), requestId)).put("request_id", requestId);
return success(voidTaskService.submit(arBillId, request == null ? null : request.getRemark(), getUsername(), requestId)).put("request_id", requestId);
}
@PreAuthorize("@ss.hasPermi('ar:bill:list')")
@GetMapping("/void/task/{taskId}")
public AjaxResult voidTask(@PathVariable Long taskId) {
return success(voidTaskService.get(taskId));
}
/**
@@ -0,0 +1,122 @@
package com.bms.receivable;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.nullable;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import java.math.BigDecimal;
import java.util.List;
import java.util.Set;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.util.ReflectionTestUtils;
import com.bms.common.core.domain.entity.SysUser;
import com.bms.common.core.domain.model.LoginUser;
import com.bms.common.exception.ServiceException;
import com.bms.receivable.domain.ArBill;
import com.bms.receivable.domain.ArBillLog;
import com.bms.receivable.mapper.ArBillDetailMapper;
import com.bms.receivable.mapper.ArBillExportMapper;
import com.bms.receivable.mapper.ArBillMapper;
import com.bms.receivable.security.ArBillScopeResolver;
import com.bms.receivable.service.IArBillLogService;
import com.bms.receivable.service.impl.ArBillServiceImpl;
class ArBillConfirmTest {
private final ArBillMapper billMapper = mock(ArBillMapper.class);
private final ArBillDetailMapper detailMapper = mock(ArBillDetailMapper.class);
private final ArBillExportMapper exportMapper = mock(ArBillExportMapper.class);
private final ArBillScopeResolver scope = mock(ArBillScopeResolver.class);
private final IArBillLogService logService = mock(IArBillLogService.class);
private final ArBillServiceImpl service = new ArBillServiceImpl();
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(service, "billMapper", billMapper);
ReflectionTestUtils.setField(service, "detailMapper", detailMapper);
ReflectionTestUtils.setField(service, "exportMapper", exportMapper);
ReflectionTestUtils.setField(service, "billScope", scope);
ReflectionTestUtils.setField(service, "logService", logService);
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(
new LoginUser(7L, 1L, new SysUser(), Set.of()), null));
}
@AfterEach
void clearSecurity() {
SecurityContextHolder.clearContext();
}
@Test
void readyDraftCanBeConfirmedWithoutReadingDetails() {
ArBill draft = bill("DRAFT", "READY");
draft.setTotalAmount(new BigDecimal("123.45"));
ArBill confirmed = bill("CONFIRMED", "READY");
when(billMapper.selectArBillByIdForUpdate(11L)).thenReturn(draft);
when(billMapper.updateArBillStatus(11L, "CONFIRMED", null, "finance", "DRAFT")).thenReturn(1);
when(billMapper.selectArBillByIdScoped(11L, null)).thenReturn(confirmed);
when(exportMapper.selectByBillId(11L)).thenReturn(List.of());
assertSame(confirmed, service.confirmBill(11L, null, "finance", "request-1"));
verify(scope).requireBillAccessible(11L);
verifyNoInteractions(detailMapper);
verify(logService).record(any(ArBillLog.class));
}
@Test
void unfinishedOrNonDraftBillStillCannotBeConfirmed() {
when(billMapper.selectArBillByIdForUpdate(11L)).thenReturn(
bill("DRAFT", "GENERATING"), bill("CONFIRMED", "READY"));
ServiceException unfinished = assertThrows(ServiceException.class,
() -> service.confirmBill(11L, null, "finance", "request-1"));
assertEquals("账单尚未生成完成,暂不能确认", unfinished.getMessage());
ServiceException notDraft = assertThrows(ServiceException.class,
() -> service.confirmBill(11L, null, "finance", "request-2"));
assertEquals("只有草稿账单可以确认,当前状态为 CONFIRMED", notDraft.getMessage());
verify(billMapper, never()).updateArBillStatus(eq(11L), any(), nullable(String.class), any(), any());
verifyNoInteractions(detailMapper);
}
@Test
void inaccessibleBillStillCannotBeConfirmed() {
when(billMapper.selectArBillByIdForUpdate(11L)).thenReturn(bill("DRAFT", "READY"));
doThrow(new ServiceException("账单不存在或无权访问")).when(scope).requireBillAccessible(11L);
assertThrows(ServiceException.class, () -> service.confirmBill(11L, null, "finance", "request-1"));
verify(billMapper, never()).updateArBillStatus(eq(11L), any(), nullable(String.class), any(), any());
verifyNoInteractions(detailMapper);
}
@Test
void concurrentStatusChangeStillPreventsConfirmation() {
when(billMapper.selectArBillByIdForUpdate(11L)).thenReturn(bill("DRAFT", "READY"));
ServiceException error = assertThrows(ServiceException.class,
() -> service.confirmBill(11L, null, "finance", "request-1"));
assertEquals("账单状态已发生变化,请刷新后重试", error.getMessage());
verify(logService, never()).record(any(ArBillLog.class));
verifyNoInteractions(detailMapper);
}
private static ArBill bill(String status, String generationStatus) {
ArBill bill = new ArBill();
bill.setArBillId(11L);
bill.setStatus(status);
bill.setGenerationStatus(generationStatus);
return bill;
}
}
@@ -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) { }
@@ -16,11 +16,6 @@ public interface ArBillDetailMapper {
List<ArBillDetail> selectByBillIdAndSourceIds(@Param("arBillId") Long arBillId,
@Param("sourceBillIds") List<String> sourceBillIds);
/**
* 查询账单明细及费用明细,避免详情页逐层查询。
*/
List<ArBillDetail> selectByBillIdWithFees(Long arBillId);
List<ArBillDetail> selectExportPage(@Param("arBillId") Long arBillId,
@Param("lastDetailNo") Integer lastDetailNo, @Param("pageSize") Integer pageSize);
@@ -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;
@@ -985,7 +988,7 @@ public class ArBillServiceImpl implements IArBillService {
}
/**
* 确认已完成生成且明细金额校验通过的草稿账单。
* 确认已完成生成的草稿账单。
*/
@Override
@Transactional(rollbackFor = Exception.class)
@@ -1007,11 +1010,6 @@ public class ArBillServiceImpl implements IArBillService {
bill.getStatus(),
"只有草稿账单可以确认,当前状态为 " + bill.getStatus(), operator, requestId);
}
if (!validateDetails(arBillId, bill.getTotalAmount())) {
throw failure(arBillId, ArLogObjectType.BILL, String.valueOf(arBillId), ArLogOperationType.CONFIRM,
bill.getStatus(),
"账单费用明细汇总与订单或账单总额不一致", operator, requestId);
}
if (billMapper.updateArBillStatus(arBillId, ArBillStatus.CONFIRMED.getCode(),
StringUtils.isEmpty(request == null ? null : request.getRemark()) ? bill.getRemark() : request.getRemark(),
operator, ArBillStatus.DRAFT.getCode()) != 1) {
@@ -1029,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;
}
/**
* 创建异步导出任务;模板版本在提交时绑定,保证后续模板变更不影响历史导出。
*/
@@ -1083,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())) {
@@ -1095,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);
@@ -1313,26 +1264,6 @@ public class ArBillServiceImpl implements IArBillService {
return message.length() > 500 ? message.substring(0, 500) : message;
}
private boolean validateDetails(Long arBillId, BigDecimal billTotal) {
List<ArBillDetail> details = detailMapper.selectByBillIdWithFees(arBillId);
BigDecimal detailTotal = BigDecimal.ZERO;
for (ArBillDetail detail : details) {
BigDecimal feeTotal = BigDecimal.ZERO;
List<ArBillDetailFee> fees = detail.getFees();
if (fees == null) {
fees = new ArrayList<ArBillDetailFee>();
}
for (ArBillDetailFee fee : fees) {
feeTotal = feeTotal.add(nvl(fee.getAmount()));
}
if (feeTotal.compareTo(nvl(detail.getTotalAmount())) != 0) {
return false;
}
detailTotal = detailTotal.add(nvl(detail.getTotalAmount()));
}
return !details.isEmpty() && detailTotal.compareTo(nvl(billTotal)) == 0;
}
private ArBillDetail toDetail(ArBill bill, PrcOrderBill source, PrcIncomeBill sourceIncomeBill,
int detailNo, String operator) {
ArBillDetail detail = new ArBillDetail();
@@ -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);
}
}
}
@@ -69,49 +69,6 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
order by detail_no
</select>
<resultMap id="ArBillDetailWithFeesResult" type="ArBillDetail" extends="ArBillDetailResult">
<collection property="fees" ofType="ArBillDetailFee">
<id property="detailFeeId" column="fee_detail_id"/>
<result property="detailId" column="fee_parent_detail_id"/>
<result property="prcIncomeBillFeeId" column="fee_prc_income_bill_fee_id"/>
<result property="feeNo" column="fee_no"/>
<result property="feeType" column="fee_type"/>
<result property="feeName" column="fee_name"/>
<result property="scId" column="fee_sc_id"/>
<result property="quantity" column="fee_quantity"/>
<result property="unit" column="fee_unit"/>
<result property="unitPrice" column="fee_unit_price"/>
<result property="amount" column="fee_amount"/>
<result property="productAmount" column="fee_product_amount"/>
<result property="redlineAmount" column="fee_redline_amount"/>
<result property="currency" column="fee_currency"/>
<result property="calculationFormula" column="fee_calculation_formula"/>
<result property="roundMode" column="fee_round_mode"/>
<result property="remark" column="fee_remark"/>
<result property="createBy" column="fee_create_by"/>
<result property="createTime" column="fee_create_time"/>
</collection>
</resultMap>
<select id="selectByBillIdWithFees" parameterType="long" resultMap="ArBillDetailWithFeesResult">
select d.detail_id, d.ar_bill_id, d.prc_order_bill_id, d.detail_no, d.order_no, d.tracking_no, d.transfer_no, d.receive_time,
d.product_code, d.product_name, d.goods_description, d.country_code, d.country_name, d.dest_postcode,
d.actual_weight, d.receive_weight, d.volume_weight, d.charge_weight, d.freight_rate, d.registration_fee, d.pkg_length, d.pkg_width, d.pkg_height,
d.currency, d.total_amount, d.product_amount, d.redline_amount, d.billing_fail_status, d.remark, d.create_by, d.create_time,
f.detail_fee_id as fee_detail_id, f.detail_id as fee_parent_detail_id,
f.prc_income_bill_fee_id as fee_prc_income_bill_fee_id,
f.fee_no, f.fee_type, f.fee_name, f.sc_id as fee_sc_id, f.quantity as fee_quantity,
f.unit as fee_unit, f.unit_price as fee_unit_price, f.amount as fee_amount,
f.product_amount as fee_product_amount, f.redline_amount as fee_redline_amount,
f.currency as fee_currency, f.calculation_formula as fee_calculation_formula,
f.round_mode as fee_round_mode, f.remark as fee_remark, f.create_by as fee_create_by,
f.create_time as fee_create_time
from ar_bill_detail d
left join ar_bill_detail_fee f on f.detail_id = d.detail_id
where d.ar_bill_id = #{arBillId}
order by d.detail_no, d.detail_id, f.fee_no, f.detail_fee_id
</select>
<select id="selectExportPage" resultMap="ArBillDetailResult">
select detail_id, ar_bill_id, prc_order_bill_id, detail_no, order_no, tracking_no, transfer_no, receive_time,
product_code, product_name, goods_description, country_code, country_name, dest_postcode,
@@ -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>
@@ -0,0 +1,18 @@
-- 应收账单异步作废任务。部署应用前执行;已存在同名表时请先核对结构,勿覆盖历史任务。
CREATE TABLE ar_bill_void_task (
task_id bigint NOT NULL AUTO_INCREMENT,
ar_bill_id bigint NOT NULL,
from_status varchar(32) NOT NULL,
reason varchar(500) NOT NULL,
status varchar(16) NOT NULL COMMENT 'WAITING、SUCCESS、FAILED',
error_message varchar(500) DEFAULT NULL,
request_id varchar(64) NOT NULL,
create_by varchar(64) NOT NULL,
create_user_id bigint NOT NULL,
create_time datetime NOT NULL,
finished_at datetime DEFAULT NULL,
PRIMARY KEY (task_id),
KEY idx_void_task_bill_status (ar_bill_id, status, task_id),
KEY idx_void_task_bill_latest (ar_bill_id, task_id),
KEY idx_void_task_status (status, task_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='应收账单异步作废任务';
+19
View File
@@ -102,6 +102,25 @@ CREATE TABLE `ar_bill` (
KEY `idx_ar_bill_cutoff_date` (`bill_period_end`,`ar_bill_id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='正式应收账单主表';
-- 应收账单异步作废任务(新库初始化;存量库使用 20261112_ar_bill_void_task.sql)
CREATE TABLE `ar_bill_void_task` (
`task_id` bigint NOT NULL AUTO_INCREMENT,
`ar_bill_id` bigint NOT NULL,
`from_status` varchar(32) NOT NULL,
`reason` varchar(500) NOT NULL,
`status` varchar(16) NOT NULL COMMENT 'WAITING、SUCCESS、FAILED',
`error_message` varchar(500) DEFAULT NULL,
`request_id` varchar(64) NOT NULL,
`create_by` varchar(64) NOT NULL,
`create_user_id` bigint NOT NULL,
`create_time` datetime NOT NULL,
`finished_at` datetime DEFAULT NULL,
PRIMARY KEY (`task_id`),
KEY `idx_void_task_bill_status` (`ar_bill_id`, `status`, `task_id`),
KEY `idx_void_task_bill_latest` (`ar_bill_id`, `task_id`),
KEY `idx_void_task_status` (`status`, `task_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='应收账单异步作废任务';
-- ----------------------------
-- 表结构:`ar_bill_detail`
-- ----------------------------
+4
View File
@@ -84,6 +84,10 @@ export function voidArBill(arBillId, data) {
return request({ url: '/ar/bill/' + arBillId + '/void', method: 'put', data })
}
export function getArBillVoidTask(taskId) {
return request({ url: '/ar/bill/void/task/' + taskId, method: 'get' })
}
export function exportArBill(arBillId) {
return request({ url: '/ar/bill/' + arBillId + '/export', method: 'post' })
}
@@ -44,7 +44,7 @@
<span v-if="previewSearched" class="preview-summary">
可选 {{ previewTotal }} 条订单,已选 <b>{{ displaySelectedCount }}</b> 条
<template v-if="previewAmountVisible">,应收合计 <b>{{ money(displaySelectedAmount) }}</b></template>
<el-button link type="primary" :loading="previewAmountLoading" :disabled="previewLoading || !previewTotal" @click="queryPreviewAmount">查询应收合计</el-button>
<el-button v-if="!previewAmountVisible" link type="primary" :loading="previewAmountLoading" :disabled="previewLoading || !previewTotal" @click="queryPreviewAmount">查询应收合计</el-button>
</span>
<span v-else class="preview-summary">请选择客户、币种和账单截止日后查询可生成的计费订单</span>
<el-button v-if="previewSearched && previewTotal > 0" type="warning" plain :icon="Download" :loading="previewExportLoading" :disabled="previewLoading || previewExportLoading" @click="exportPreview">导出查询结果</el-button>
+65 -8
View File
@@ -44,11 +44,17 @@
<template #col-unwrittenAmount="{ row }"><span class="unwritten">{{ money(row.unwrittenAmount) }}</span></template>
<template #col-action="{ row }">
<el-button link type="primary" icon="View" @click="viewBill(row)">详情</el-button>
<el-button v-if="row.status === AR_BILL_STATUS.DRAFT" link type="success" v-hasPermi="['ar:bill:confirm']" @click="confirmBill(row)">确认</el-button>
<el-button v-if="row.status === AR_BILL_STATUS.DRAFT || row.status === AR_BILL_STATUS.CONFIRMED" link type="warning" v-hasPermi="['ar:bill:void']" @click="voidBill(row)">作废</el-button>
<el-button v-if="row.status !== AR_BILL_STATUS.DRAFT && row.status !== AR_BILL_STATUS.VOID" link type="primary" v-hasPermi="['ar:bill:export']" @click="exportBill(row)">导出明细</el-button>
<el-button v-if="canGoWriteoff(row)" link type="primary" v-hasPermi="['customer:bill:writeoff']" @click="goWriteoff(row)">核销</el-button>
<el-button v-if="canGoReverseWriteoff(row)" link type="warning" v-hasPermi="['customer:bill:writeoff:cancel']" @click="goReverseWriteoff(row)">反核销</el-button>
<span v-if="isVoiding(row)" class="voiding-status"><el-icon class="is-loading"><Loading /></el-icon> 作废中</span>
<template v-else>
<el-tooltip v-if="row.voidTaskError" :content="row.voidTaskError" placement="top">
<span class="void-failed-status">作废失败</span>
</el-tooltip>
<el-button v-if="row.status === AR_BILL_STATUS.DRAFT" link type="success" v-hasPermi="['ar:bill:confirm']" @click="confirmBill(row)">确认</el-button>
<el-button v-if="row.status === AR_BILL_STATUS.DRAFT || row.status === AR_BILL_STATUS.CONFIRMED" link type="warning" v-hasPermi="['ar:bill:void']" @click="voidBill(row)">作废</el-button>
<el-button v-if="row.status !== AR_BILL_STATUS.DRAFT && row.status !== AR_BILL_STATUS.VOID" link type="primary" v-hasPermi="['ar:bill:export']" @click="exportBill(row)">导出明细</el-button>
<el-button v-if="canGoWriteoff(row)" link type="primary" v-hasPermi="['customer:bill:writeoff']" @click="goWriteoff(row)">核销</el-button>
<el-button v-if="canGoReverseWriteoff(row)" link type="warning" v-hasPermi="['customer:bill:writeoff:cancel']" @click="goReverseWriteoff(row)">反核销</el-button>
</template>
</template>
</basic-table>
@@ -69,7 +75,7 @@
<script setup name="ArBill">
import { saveAs } from 'file-saver'
import { listArBillCustomerOptions, listArBill, getArBill, confirmArBill, voidArBill, exportArBill, getArBillExport, downloadArBillExport, getArBillExportDownloadInfo, getArBillGenerateTask, listActiveArBillGenerateTasks, cancelArBillGenerateTask, retryArBillGenerateTask } from '@/api/ar/bill'
import { listArBillCustomerOptions, listArBill, getArBill, confirmArBill, voidArBill, getArBillVoidTask, exportArBill, getArBillExport, downloadArBillExport, getArBillExportDownloadInfo, getArBillGenerateTask, listActiveArBillGenerateTasks, cancelArBillGenerateTask, retryArBillGenerateTask } from '@/api/ar/bill'
import BillDetail from './components/BillDetail.vue'
import GenerateBillDialog from './components/GenerateBillDialog.vue'
import { AR_BILL_STATUS, AR_BILL_STATUS_OPTIONS, AR_BILL_STATUS_TYPES } from '@/constants/ar/bill'
@@ -91,6 +97,9 @@ const defaultSearchParams = computed(() => ({
const detailOpen = ref(false)
const detailLoading = ref(false)
const detail = ref(null)
const voidTasks = reactive(new Map())
const voidPollTimers = new Map()
let voidPageClosed = false
const openedBillId = ref()
const generateOpen = ref(false)
// 任务可能在首次轮询前就完成,至少展示一段时间让用户感知到“正在生成”。
@@ -177,7 +186,17 @@ function normalizeBillQuery(params) {
delete query.billDateRange
return query
}
function queryList(params) { return listArBill(normalizeBillQuery(params)) }
function queryList(params) {
return listArBill(normalizeBillQuery(params)).then(res => {
;(res.rows || []).forEach(row => {
if (row.voidTaskId && !voidTasks.has(row.arBillId)) {
voidTasks.set(row.arBillId, row.voidTaskId)
pollVoidTask(row.arBillId, row.voidTaskId)
}
})
return res
})
}
function handleExport() {
const query = normalizeBillQuery(tableRef.value?.getQueryParams() || defaultSearchParams.value)
delete query.pageNum
@@ -322,7 +341,40 @@ function viewBill(row) {
})
}
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 isVoiding(row) { return voidTasks.has(row.arBillId) || !!row.voidTaskId }
function voidBill(row) {
proxy.$prompt(`请输入账单「${row.billNo}」的作废原因`, '作废账单', { inputPattern: /\S+/, inputErrorMessage: '作废原因不能为空' })
.then(({ value }) => voidArBill(row.arBillId, { remark: value }))
.then(res => {
const task = res.data
if (!task?.taskId) return
voidTasks.set(row.arBillId, task.taskId)
pollVoidTask(row.arBillId, task.taskId)
proxy.$modal.msgSuccess('作废任务已提交')
}).catch(() => {})
}
function pollVoidTask(billId, taskId) {
if (voidPollTimers.has(taskId) || voidPageClosed) return
const poll = async () => {
voidPollTimers.delete(taskId)
if (voidPageClosed) return
try {
const res = await getArBillVoidTask(taskId)
if (voidPageClosed) return
const task = res.data
if (task && ['SUCCESS', 'FAILED'].includes(task.status)) {
voidTasks.delete(billId)
tableRef.value?.refresh()
if (detailOpen.value && openedBillId.value === billId) viewBill({ arBillId: billId })
if (task.status === 'SUCCESS') proxy.$modal.msgSuccess('账单已作废')
else proxy.$modal.msgError(`账单作废失败:${task.errorMessage || '请稍后重试'}`)
return
}
} catch (_) { /* 临时网络错误时继续轮询 */ }
if (!voidPageClosed) voidPollTimers.set(taskId, window.setTimeout(poll, 2000))
}
voidPollTimers.set(taskId, window.setTimeout(poll, 1000))
}
function exportBill(row) {
if (!row?.arBillId) return
exportSubmittingCount.value += 1
@@ -415,6 +467,9 @@ watch(() => route.query.customerId, (newId, oldId) => {
// 页面销毁时清理所有轮询定时器,避免切换菜单后仍访问旧页面状态。
onBeforeUnmount(() => {
voidPageClosed = true
voidPollTimers.forEach(timer => window.clearTimeout(timer))
voidPollTimers.clear()
generatePollTimers.forEach(timer => window.clearTimeout(timer))
generatePollTimers.clear()
generateFinishTimers.forEach(timer => window.clearTimeout(timer))
@@ -462,6 +517,8 @@ function statusType(value) { return AR_BILL_STATUS_TYPES[value] || 'info' }
</script>
<style scoped>
.voiding-status { color: var(--el-color-warning); margin-left: 8px; white-space: nowrap; }
.void-failed-status { color: var(--el-color-danger); margin-left: 8px; white-space: nowrap; }
.ar-bill-page :deep(.basic-table > .basic-form) {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));