diff --git a/finance/src/main/java/com/baiyue/finance/mapper/FinanceSettlementStateMapper.java b/finance/src/main/java/com/baiyue/finance/mapper/FinanceSettlementStateMapper.java new file mode 100644 index 000000000..c022c76cc --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/mapper/FinanceSettlementStateMapper.java @@ -0,0 +1,52 @@ +package com.baiyue.finance.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Options; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; +import java.util.List; +import java.util.Map; + +/** 结算状态变更与重新占用来源的事务锁;锁定读取不得复用会话缓存。 */ +@Mapper +public interface FinanceSettlementStateMapper { + /** 锁定待作废或重开的结算,保留客户与来源关联。 */ + @Select("SELECT id,tenant_id,total_amount,paid_amount,outstanding_amount,payment_status,status,version,direction,currency_code,supplier_id,reconciliation_statement_id FROM fin_settlement WHERE id=#{settlementId} FOR UPDATE") + @Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE) + List> lockSettlement(@Param("settlementId") long settlementId); + + /** 直接锁定来源表,不能依赖外连接右侧记录的锁语义。 */ + @Select("SELECT f.id FROM fin_fee_source f WHERE EXISTS (SELECT 1 FROM fin_settlement_line sl WHERE sl.tenant_id=#{customerId} AND sl.settlement_id=#{settlementId} AND sl.fee_source_id=f.id) ORDER BY f.id FOR UPDATE") + @Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE) + List lockSourceRows(@Param("customerId") long customerId, @Param("settlementId") long settlementId); + + /** 按来源顺序锁定原单全部明细,缺失或跨客户来源仍返回明细供服务拒绝。 */ + @Select("SELECT sl.fee_source_id,sl.amount line_amount,sl.currency_code line_currency,f.id,f.customer_id,f.direction,f.currency_code,f.amount,f.review_status,f.status,f.settlement_mode,f.source_snapshot FROM fin_settlement_line sl LEFT JOIN fin_fee_source f ON f.id=sl.fee_source_id WHERE sl.tenant_id=#{customerId} AND sl.settlement_id=#{settlementId} ORDER BY sl.fee_source_id FOR UPDATE") + @Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE) + List> lockSources(@Param("customerId") long customerId, @Param("settlementId") long settlementId); + + /** 使用当前锁定读检查其他有效结算,避免等待来源锁后读取旧快照。 */ + @Select("SELECT s.id FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id AND s.tenant_id=sl.tenant_id WHERE sl.tenant_id=#{customerId} AND sl.fee_source_id=#{sourceId} AND s.id<>#{settlementId} AND s.status<>'VOIDED' FOR UPDATE") + @Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE) + List> lockOtherSourceSettlements(@Param("customerId") long customerId, @Param("sourceId") long sourceId, @Param("settlementId") long settlementId); + + /** 与应收新建共同锁定原对账单,防止检查后被调整或再次生成应收。 */ + @Select("SELECT id,tenant_id,status,currency_code,total_amount,received_amount FROM fin_bill WHERE tenant_id=#{customerId} AND id=#{billId} FOR UPDATE") + @Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE) + List> lockBill(@Param("customerId") long customerId, @Param("billId") long billId); + + /** 读取原账单当前明细,重开不能沿用调账前的行金额。 */ + @Select("SELECT source_id fee_source_id,amount line_amount,currency_code line_currency FROM fin_bill_line WHERE tenant_id=#{customerId} AND bill_id=#{billId} ORDER BY source_id FOR UPDATE") + @Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE) + List> lockBillLines(@Param("customerId") long customerId, @Param("billId") long billId); + + /** 锁定同账单其他有效应收,作废旧单不可绕过唯一占用。 */ + @Select("SELECT id FROM fin_settlement WHERE tenant_id=#{customerId} AND direction='RECEIVABLE' AND reconciliation_statement_id=#{billId} AND id<>#{settlementId} AND status<>'VOIDED' FOR UPDATE") + @Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE) + List> lockOtherBillSettlements(@Param("customerId") long customerId, @Param("billId") long billId, @Param("settlementId") long settlementId); + + /** 客户范围内按原版本推进状态。 */ + @Update("UPDATE fin_settlement SET status=#{status},updated_by=#{operatorId},version=version+1 WHERE id=#{settlementId} AND tenant_id=#{customerId} AND version=#{version}") + int changeStatus(@Param("status") String status, @Param("operatorId") long operatorId, @Param("settlementId") long settlementId, @Param("customerId") long customerId, @Param("version") int version); +} diff --git a/finance/src/main/java/com/baiyue/finance/repository/FinanceSettlementStateRepository.java b/finance/src/main/java/com/baiyue/finance/repository/FinanceSettlementStateRepository.java new file mode 100644 index 000000000..1a5b909d2 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/repository/FinanceSettlementStateRepository.java @@ -0,0 +1,36 @@ +package com.baiyue.finance.repository; + +import com.baiyue.finance.mapper.FinanceSettlementStateMapper; +import org.springframework.stereotype.Repository; +import org.springframework.util.LinkedCaseInsensitiveMap; +import java.util.List; +import java.util.Map; + +/** 封装结算状态及原始费用、账单的事务访问。 */ +@Repository +public class FinanceSettlementStateRepository { + private final FinanceSettlementStateMapper mapper; + /** 注入结算状态持久化入口。 */ + public FinanceSettlementStateRepository(FinanceSettlementStateMapper mapper) { this.mapper = mapper; } + /** 统一数据库返回的列名大小写。 */ + private List> rows(List> rows) { + return rows.stream().map(row -> { Map value = new LinkedCaseInsensitiveMap<>(); value.putAll(row); return value; }).toList(); + } + /** 锁定目标结算。 */ + public List> lockSettlement(long id) { return rows(mapper.lockSettlement(id)); } + /** 锁定结算所有费用来源。 */ + public List> lockSources(long customerId, long id) { + mapper.lockSourceRows(customerId, id); + return rows(mapper.lockSources(customerId, id)); + } + /** 判断费用是否被其他有效结算占用。 */ + public boolean sourceOccupied(long customerId, long sourceId, long id) { return !mapper.lockOtherSourceSettlements(customerId, sourceId, id).isEmpty(); } + /** 锁定原始对账单。 */ + public List> lockBill(long customerId, long billId) { return rows(mapper.lockBill(customerId, billId)); } + /** 锁定原始对账单的当前明细。 */ + public List> lockBillLines(long customerId, long billId) { return rows(mapper.lockBillLines(customerId, billId)); } + /** 判断原对账单是否已生成其他有效应收。 */ + public boolean billOccupied(long customerId, long billId, long id) { return !mapper.lockOtherBillSettlements(customerId, billId, id).isEmpty(); } + /** 按客户及版本变更结算状态。 */ + public int changeStatus(String status, long operatorId, long id, long customerId, int version) { return mapper.changeStatus(status, operatorId, id, customerId, version); } +} diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java index 4259966d4..243bdfbc4 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java @@ -53,13 +53,15 @@ public class FinanceSettlementService { private final FinanceSupplierValidator supplierValidator; private final FinanceDocumentNumberGenerator documentNumbers; private final FinanceCustomerNames customerNames; + private final com.baiyue.finance.repository.FinanceSettlementStateRepository states; /** 注入统一编号组件,确保所有业务入口共用受事务保护的日流水。 */ @Autowired public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator, FinanceSupplierValidator supplierValidator, FinanceDocumentNumberGenerator documentNumbers, - com.baiyue.finance.repository.FinanceSettlementDetailRepository details, FinanceCustomerNames customerNames) { + com.baiyue.finance.repository.FinanceSettlementDetailRepository details, FinanceCustomerNames customerNames, + com.baiyue.finance.repository.FinanceSettlementStateRepository states) { this.jdbc = jdbc; this.details = details; this.objectMapper = objectMapper; @@ -67,6 +69,7 @@ public class FinanceSettlementService { this.supplierValidator = supplierValidator; this.documentNumbers = documentNumbers; this.customerNames = customerNames; + this.states = states; } /** 创建应收结算单,幂等请求恢复原单,首次建单分配 AR 日流水。 */ @@ -89,11 +92,9 @@ public class FinanceSettlementService { Long replay = reserve(customerId, operatorId, "SETTLEMENT_AR_CREATE", key, "POST", "/api/platform/finance/v1/receivable-settlements", request); if (replay != null) return getSettlement(replay); - Integer occupied = jdbc.queryForObject( - "SELECT COUNT(*) FROM fin_settlement WHERE tenant_id=? AND direction='RECEIVABLE' " + - "AND reconciliation_statement_id=? AND status<>'VOIDED'", Integer.class, - customerId, request.reconciliationStatementId()); - if (occupied != null && occupied > 0) throw error("FIN_409", "对账单已生成有效应收结算单"); + // 新建与重开共用当前锁定读,等待来源锁后不能沿用事务开始时的占用快照。 + if (states.billOccupied(customerId, request.reconciliationStatementId(), 0)) + throw error("FIN_409", "对账单已生成有效应收结算单"); Integer lineCount = jdbc.queryForObject("SELECT COUNT(*) FROM fin_bill_line WHERE tenant_id=? AND bill_id=?", Integer.class, customerId, request.reconciliationStatementId()); if (lineCount == null || lineCount == 0) throw error("FIN_409", "对账单没有可结算费用明细"); @@ -148,10 +149,8 @@ public class FinanceSettlementService { throw error("FIN_409", "费用来源未审核、金额无效或状态不可用"); } if (!CNY.equals(text(source, "currency_code"))) throw error("FIN_400", "费用来源币种必须为CNY"); - Integer used = jdbc.queryForObject( - "SELECT COUNT(*) FROM fin_settlement_line sl JOIN fin_settlement s ON s.id=sl.settlement_id " + - "WHERE sl.fee_source_id=? AND s.status<>'VOIDED'", Integer.class, number(source, "id").longValue()); - if (used != null && used > 0) throw error("FIN_409", "费用来源已被有效结算单占用"); + if (states.sourceOccupied(customerId, number(source, "id").longValue(), 0)) + throw error("FIN_409", "费用来源已被有效结算单占用"); } BigDecimal total = sources.stream().map(row -> decimal(row, "amount")).reduce(BigDecimal.ZERO, BigDecimal::add); if (total.signum() <= 0) throw error("FIN_400", "应付结算金额必须大于0"); @@ -249,13 +248,14 @@ public class FinanceSettlementService { return getSettlement(settlementId); } + /** 作废保留来源关联;重开必须重新校验并锁定当前仍可占用的原始依据。 */ @Transactional public SettlementDetail actionSettlement(long settlementId, long operatorId, String key, SettlementActionRequest request) { validateSettlementAction(request); - Map settlement = one( - "SELECT id,tenant_id,total_amount,paid_amount,outstanding_amount,payment_status,status,version " + - "FROM fin_settlement WHERE id=? FOR UPDATE", "结算单不存在", settlementId); + List> locked = states.lockSettlement(settlementId); + if (locked.isEmpty()) throw error("FIN_404", "结算单不存在"); + Map settlement = locked.get(0); long customerId = number(settlement, "tenant_id").longValue(); Long replay = reserve(customerId, operatorId, "SETTLEMENT_STATE_ACTION", key, "POST", "/api/platform/finance/v1/settlements/" + settlementId + "/actions", request); @@ -274,11 +274,10 @@ public class FinanceSettlementService { if (!"VOIDED".equals(currentStatus) || decimal(settlement, "paid_amount").signum() != 0) { throw error("FIN_409", "只有未发生收付款的已作废结算单可以重开"); } + requireReopenableSources(settlement); targetStatus = "ISSUED"; } - int changed = jdbc.update("UPDATE fin_settlement SET status=?,updated_by=?,version=version+1 " + - "WHERE id=? AND tenant_id=? AND version=?", targetStatus, operatorId, settlementId, customerId, - request.expectedVersion()); + int changed = states.changeStatus(targetStatus, operatorId, settlementId, customerId, request.expectedVersion()); if (changed != 1) throw error("FIN_409", "结算单版本冲突"); audit(customerId, settlementId, request.action(), request.reason(), settlementStateSnapshot(settlement), settlementStateSnapshot(settlement, targetStatus), operatorId); @@ -286,6 +285,77 @@ public class FinanceSettlementService { return getSettlement(settlementId); } + /** 重开沿用原单金额,来源变化必须重新生成结算,不覆盖历史单据。 */ + private void requireReopenableSources(Map settlement) { + long customerId = number(settlement, "tenant_id").longValue(); + long settlementId = number(settlement, "id").longValue(); + if (!CNY.equals(text(settlement, "currency_code")) || decimal(settlement, "total_amount").signum() <= 0) + throw error("FIN_409", "原结算金额或币种已不满足重开条件"); + if ("RECEIVABLE".equals(text(settlement, "direction"))) { + requireReopenableReceivable(settlement); + return; + } + if (!"PAYABLE".equals(text(settlement, "direction"))) throw error("FIN_409", "结算方向不支持重开"); + Long supplierId = nullableNumber(settlement, "supplier_id"); + List> sources = states.lockSources(customerId, settlementId); + if (supplierId == null || sources.isEmpty()) throw error("FIN_409", "原应付来源或供应商不完整"); + BigDecimal total = BigDecimal.ZERO; + List> categories = new ArrayList<>(); + for (Map source : sources) { + JsonNode snapshot = parseTree(text(source, "source_snapshot")); + if (source.get("id") == null || number(source, "customer_id").longValue() != customerId + || !"PAYABLE".equals(text(source, "direction")) || !"AVAILABLE".equals(text(source, "status")) + || !"APPROVED".equals(text(source, "review_status")) || !"ON_ACCOUNT".equals(text(source, "settlement_mode")) + || !CNY.equals(text(source, "currency_code")) || !CNY.equals(text(source, "line_currency")) + || !supplierId.equals(jsonLong(snapshot, "supplierId")) || decimal(source, "amount").signum() <= 0 + || decimal(source, "amount").compareTo(decimal(source, "line_amount")) != 0) + throw error("FIN_409", "原应付费用已调整、作废或不再满足结算条件,请重新生成结算单"); + if (states.sourceOccupied(customerId, number(source, "fee_source_id").longValue(), settlementId)) + throw error("FIN_409", "原应付费用已被其他有效结算单占用"); + total = total.add(decimal(source, "line_amount")); + Map category = new java.util.HashMap<>(); + category.put("supplier_category", jsonText(snapshot, "supplierCategory")); + categories.add(category); + } + if (total.compareTo(decimal(settlement, "total_amount")) != 0) + throw error("FIN_409", "原应付结算与费用金额不一致,请重新生成结算单"); + supplierValidator.requireActive(supplierId, supplierCategoryConstraint(categories)); + } + + /** 应收重开还须核对原账单当前状态及逐行金额,防止调账后恢复旧应收。 */ + private void requireReopenableReceivable(Map settlement) { + long customerId = number(settlement, "tenant_id").longValue(); + long settlementId = number(settlement, "id").longValue(); + Long billId = nullableNumber(settlement, "reconciliation_statement_id"); + if (billId == null) throw error("FIN_409", "原应收缺少对账单关联"); + List> bills = states.lockBill(customerId, billId); + if (bills.isEmpty()) throw error("FIN_409", "原对账单不存在或不属于当前客户"); + Map bill = bills.get(0); + if (!"ISSUED".equals(text(bill, "status")) || !CNY.equals(text(bill, "currency_code")) + || decimal(bill, "received_amount").signum() != 0 + || decimal(bill, "total_amount").compareTo(decimal(settlement, "total_amount")) != 0) + throw error("FIN_409", "原对账单状态或金额已变化,请重新生成应收结算单"); + if (states.billOccupied(customerId, billId, settlementId)) + throw error("FIN_409", "原对账单已生成其他有效应收结算单"); + List> lines = states.lockBillLines(customerId, billId); + List> sources = states.lockSources(customerId, settlementId); + if (lines.isEmpty() || sources.size() != lines.size()) throw error("FIN_409", "原应收明细与对账单不一致"); + BigDecimal total = BigDecimal.ZERO; + for (Map source : sources) { + var matching = lines.stream().filter(line -> number(line, "fee_source_id").longValue() == number(source, "fee_source_id").longValue()).toList(); + if (source.get("id") == null || number(source, "customer_id").longValue() != customerId + || !"RECEIVABLE".equals(text(source, "direction")) || !"BILLED".equals(text(source, "status")) + || !"APPROVED".equals(text(source, "review_status")) || !CNY.equals(text(source, "currency_code")) + || !CNY.equals(text(source, "line_currency")) || matching.size() != 1 + || !CNY.equals(text(matching.get(0), "line_currency")) + || decimal(source, "line_amount").compareTo(decimal(matching.get(0), "line_amount")) != 0) + throw error("FIN_409", "原应收费用或明细金额已变化,请重新生成结算单"); + total = total.add(decimal(source, "line_amount")); + } + if (total.compareTo(decimal(settlement, "total_amount")) != 0) + throw error("FIN_409", "原应收明细合计与结算金额不一致"); + } + private long insertSettlement(long customerId, String no, String direction, String customerName, Long supplierId, String supplierName, Long reconciliationId, String businessPeriod, String currency, BigDecimal total, diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java index c59f4328e..41df1fba1 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java @@ -20,6 +20,8 @@ import com.baiyue.finance.service.FinanceSupplierValidator; import com.baiyue.finance.service.FinanceVoucherValidator; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -222,6 +224,143 @@ class FinanceSettlementBehaviorTest { verify(staleJdbc, never()).update(startsWith("UPDATE fin_settlement SET status="), (Object[]) any()); } + /** 作废应付的费用已被另一张有效单占用时,重开必须拒绝,不能形成双重应付。 */ + @Test + void reopeningCannotReclaimASourceUsedByAnotherSettlement() { + Map row = settlementRow("PAYABLE", "VOIDED", BigDecimal.ZERO, + new BigDecimal("100.00"), 3); + JdbcTemplate jdbc = creationJdbc(row); + when(jdbc.queryForList(startsWith("SELECT id,tenant_id,total_amount"), eq(88L))) + .thenReturn(List.of(row)); + var source = payableSource(10L, 20L); + source.put("fee_source_id", 31L); + source.put("line_amount", new BigDecimal("100.00")); + source.put("line_currency", "CNY"); + org.mockito.Mockito.lenient().when(jdbc.queryForList(startsWith("SELECT sl.fee_source_id"), eq(10L), eq(88L))) + .thenReturn(List.of(source)); + org.mockito.Mockito.lenient().when(jdbc.queryForList(startsWith("SELECT s.id FROM fin_settlement_line"), eq(10L), eq(31L), eq(88L))) + .thenReturn(List.of(Map.of("id", 99L))); + FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class)); + + assertCode(() -> service.actionSettlement(88L, 7L, "reopen-used", + new SettlementActionRequest("REOPEN", "验证来源占用", 3)), "FIN_409"); + verify(jdbc, never()).update(startsWith("UPDATE fin_settlement SET status="), (Object[]) any()); + } + + /** 未变化且无其他占用的应付原单可以恢复,继续保留原金额与修订审计。 */ + @Test + void unchangedPayableCanReopen() { + JdbcTemplate jdbc = reopenJdbc("PAYABLE"); + FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class)); + assertThat(service.actionSettlement(88L, 7L, "reopen-ok", + new SettlementActionRequest("REOPEN", "恢复有效原单", 3)).summary().status()).isEqualTo("ISSUED"); + verify(jdbc).update(startsWith("UPDATE fin_settlement SET status="), eq("ISSUED"), eq(7L), eq(88L), eq(10L), eq(3)); + verify(jdbc).update(startsWith("INSERT INTO fin_audit_log"), eq(10L), eq("FIN_SETTLEMENT"), eq(88L), + eq("REOPEN"), eq("恢复有效原单"), any(), any(), eq(7L), any(), any(), any()); + } + + /** 原应付的失效、金额和归属变化均不能借重开恢复旧快照。 */ + @ParameterizedTest + @ValueSource(strings = {"VOIDED", "SUPERSEDED", "PENDING", "DIRECT_PAID", "CROSS_CUSTOMER", "MISSING", "AMOUNT_CHANGED", "SUPPLIER_CHANGED", "EMPTY"}) + void reopeningRejectsChangedPayableSources(String condition) { + JdbcTemplate jdbc = reopenJdbc("PAYABLE"); + Map source = reopenSource("PAYABLE"); + switch (condition) { + case "VOIDED", "SUPERSEDED" -> source.put("status", condition); + case "PENDING" -> source.put("review_status", "PENDING"); + case "DIRECT_PAID" -> source.put("settlement_mode", "DIRECT_PAID"); + case "CROSS_CUSTOMER" -> source.put("customer_id", 11L); + case "MISSING" -> source.remove("id"); + case "AMOUNT_CHANGED" -> source.put("amount", new BigDecimal("101.00")); + case "SUPPLIER_CHANGED" -> source.put("source_snapshot", "{\"supplierId\":21}"); + default -> { } + } + when(jdbc.queryForList(startsWith("SELECT sl.fee_source_id"), eq(10L), eq(88L))) + .thenReturn("EMPTY".equals(condition) ? List.of() : List.of(source)); + assertCode(() -> service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class)).actionSettlement(88L, 7L, + "reopen-" + condition, new SettlementActionRequest("REOPEN", "核对原始费用", 3)), "FIN_409"); + verify(jdbc, never()).update(startsWith("UPDATE fin_settlement SET status="), (Object[]) any()); + } + + /** 应收原账单与明细未变化且仍已出账时,允许恢复原单。 */ + @Test + void unchangedReceivableCanReopen() { + JdbcTemplate jdbc = reopenJdbc("RECEIVABLE"); + assertThat(service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class)).actionSettlement(88L, 7L, + "reopen-ar", new SettlementActionRequest("REOPEN", "恢复原应收", 3)).summary().status()).isEqualTo("ISSUED"); + } + + /** 其他应收占用、对账单作废或调整、已有收款均拒绝恢复旧应收。 */ + @ParameterizedTest + @ValueSource(strings = {"OCCUPIED", "VOIDED_BILL", "ADJUSTED_TOTAL", "ADJUSTED_LINES", "MISSING_BILL", "RECEIPT", "WRONG_SOURCE", "EMPTY"}) + void reopeningRejectsChangedReceivableBasis(String condition) { + JdbcTemplate jdbc = reopenJdbc("RECEIVABLE"); + Map bill = reopenBill(); + if ("VOIDED_BILL".equals(condition)) bill.put("status", "VOIDED"); + if ("ADJUSTED_TOTAL".equals(condition)) bill.put("total_amount", new BigDecimal("101.00")); + if ("RECEIPT".equals(condition)) bill.put("received_amount", new BigDecimal("0.01")); + when(jdbc.queryForList(startsWith("SELECT id,tenant_id,status,currency_code"), eq(10L), eq(501L))) + .thenReturn("MISSING_BILL".equals(condition) ? List.of() : List.of(bill)); + if ("OCCUPIED".equals(condition)) + when(jdbc.queryForList(startsWith("SELECT id FROM fin_settlement WHERE"), eq(10L), eq(501L), eq(88L))) + .thenReturn(List.of(Map.of("id", 99L))); + if (List.of("ADJUSTED_LINES", "WRONG_SOURCE", "EMPTY").contains(condition)) + when(jdbc.queryForList(startsWith("SELECT source_id fee_source_id"), eq(10L), eq(501L))) + .thenReturn("EMPTY".equals(condition) ? List.of() : List.of(Map.of("fee_source_id", "WRONG_SOURCE".equals(condition) ? 32L : 31L, + "line_amount", new BigDecimal("ADJUSTED_LINES".equals(condition) ? "99.00" : "100.00"), "line_currency", "CNY"))); + assertCode(() -> service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class)).actionSettlement(88L, 7L, + "reopen-ar-" + condition, new SettlementActionRequest("REOPEN", "核对原对账单", 3)), "FIN_409"); + verify(jdbc, never()).update(startsWith("UPDATE fin_settlement SET status="), (Object[]) any()); + } + + /** 重放已成功的重开请求恢复当前单据,不再次修改版本或占用来源。 */ + @Test + void successfulReopenReplayDoesNotReacquireSources() { + JdbcTemplate jdbc = reopenJdbc("PAYABLE"); + FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class)); + var request = new SettlementActionRequest("REOPEN", "恢复原单", 3); + String path = "/api/platform/finance/v1/settlements/88/actions"; + String hash = ReflectionTestUtils.invokeMethod(service, "hash", Map.of("method", "POST", "path", path, "customerId", 10L, "body", request)); + when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_STATE_ACTION"), eq("replay-open"))) + .thenReturn(List.of(Map.of("request_hash", hash, "status", "SUCCEEDED", "result_resource_id", 88L))); + assertThat(service.actionSettlement(88L, 7L, "replay-open", request).summary().status()).isEqualTo("ISSUED"); + verify(jdbc, never()).queryForList(startsWith("SELECT sl.fee_source_id"), (Object[]) any()); + verify(jdbc, never()).update(startsWith("UPDATE fin_settlement SET status="), (Object[]) any()); + } + + /** 为重开场景准备完整且默认有效的原单、来源与对账单。 */ + private static JdbcTemplate reopenJdbc(String direction) { + var settlement = settlementRow(direction, "VOIDED", BigDecimal.ZERO, new BigDecimal("100.00"), 3); + settlement.put("reconciliation_statement_id", 501L); + JdbcTemplate jdbc = creationJdbc(settlementRow(direction, "ISSUED", BigDecimal.ZERO, new BigDecimal("100.00"), 4)); + org.mockito.Mockito.lenient().when(jdbc.queryForList(startsWith("SELECT id,tenant_id,total_amount"), eq(88L))) + .thenReturn(List.of(settlement)); + org.mockito.Mockito.lenient().when(jdbc.queryForList(startsWith("SELECT sl.fee_source_id"), eq(10L), eq(88L))) + .thenReturn(List.of(reopenSource(direction))); + if ("RECEIVABLE".equals(direction)) { + org.mockito.Mockito.lenient().when(jdbc.queryForList(startsWith("SELECT id,tenant_id,status,currency_code"), eq(10L), eq(501L))) + .thenReturn(List.of(reopenBill())); + org.mockito.Mockito.lenient().when(jdbc.queryForList(startsWith("SELECT source_id fee_source_id"), eq(10L), eq(501L))) + .thenReturn(List.of(Map.of("fee_source_id", 31L, "line_amount", new BigDecimal("100.00"), "line_currency", "CNY"))); + } + return jdbc; + } + + /** 提供带原结算金额的真实形状费用记录。 */ + private static Map reopenSource(String direction) { + var row = payableSource(10L, 20L); + row.put("direction", direction); + row.put("status", "RECEIVABLE".equals(direction) ? "BILLED" : "AVAILABLE"); + row.put("fee_source_id", 31L); row.put("line_amount", new BigDecimal("100.00")); row.put("line_currency", "CNY"); + return row; + } + + /** 提供已出账且未发生收款的对账单。 */ + private static Map reopenBill() { + return new java.util.HashMap<>(Map.of("id", 501L, "tenant_id", 10L, "status", "ISSUED", "currency_code", "CNY", + "total_amount", new BigDecimal("100.00"), "received_amount", BigDecimal.ZERO)); + } + @Test void partialPaymentUpdatesOutstandingAndReturnsTypedDetail() { JdbcTemplate jdbc = org.mockito.Mockito.mock(JdbcTemplate.class, invocation -> @@ -276,7 +415,7 @@ class FinanceSettlementBehaviorTest { Map.entry("bill_date", Date.valueOf("2026-08-31")), Map.entry("due_date", Date.valueOf("2026-09-30")), Map.entry("status", "ISSUED"), Map.entry("total_amount", new BigDecimal("300.00"))))); when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AR_CREATE"), eq("ar-key"))).thenReturn(List.of()); - when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_settlement WHERE"), eq(Integer.class), eq(10L), eq(501L))).thenReturn(0); + when(jdbc.queryForList(startsWith("SELECT id FROM fin_settlement WHERE"), eq(10L), eq(501L), eq(0L))).thenReturn(List.of()); when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_bill_line"), eq(Integer.class), eq(10L), eq(501L))).thenReturn(1); FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class)); @@ -358,7 +497,7 @@ class FinanceSettlementBehaviorTest { Map.entry("bill_date", Date.valueOf("2026-08-31")), Map.entry("due_date", Date.valueOf("2026-09-30")), Map.entry("status", "ISSUED"), Map.entry("total_amount", new BigDecimal("300.00"))))); when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AR_CREATE"), eq("ar-duplicate"))).thenReturn(List.of()); - when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_settlement WHERE"), eq(Integer.class), eq(10L), eq(501L))).thenReturn(1); + when(jdbc.queryForList(startsWith("SELECT id FROM fin_settlement WHERE"), eq(10L), eq(501L), eq(0L))).thenReturn(List.of(Map.of("id", 99L))); assertCode(() -> service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class)).createReceivableSettlement(7L, "ar-duplicate", new CreateReceivableSettlementRequest(501L, "CNY")), "FIN_409"); @@ -371,7 +510,7 @@ class FinanceSettlementBehaviorTest { new BigDecimal("100.00"), 0)); when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AP_CREATE"), eq("ap-key"))).thenReturn(List.of()); when(jdbc.queryForList(startsWith("SELECT id,customer_id"), eq(31L))).thenReturn(List.of(payableSource(10L, 20L))); - when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_settlement_line"), eq(Integer.class), eq(31L))).thenReturn(0); + when(jdbc.queryForList(startsWith("SELECT s.id FROM fin_settlement_line"), eq(10L), eq(31L), eq(0L))).thenReturn(List.of()); FinanceSupplierValidator supplierValidator = supplierValidator(); FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class), supplierValidator); @@ -422,8 +561,8 @@ class FinanceSettlementBehaviorTest { if ("ineligible".equals(condition)) source.put("review_status", "PENDING"); when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AP_CREATE"), eq("ap-" + condition))).thenReturn(List.of()); when(jdbc.queryForList(startsWith("SELECT id,customer_id"), eq(31L))).thenReturn(List.of(source)); - org.mockito.Mockito.lenient().when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_settlement_line"), eq(Integer.class), eq(31L))) - .thenReturn("occupied".equals(condition) ? 1 : 0); + org.mockito.Mockito.lenient().when(jdbc.queryForList(startsWith("SELECT s.id FROM fin_settlement_line"), eq(10L), eq(31L), eq(0L))) + .thenReturn("occupied".equals(condition) ? List.of(Map.of("id", 99L)) : List.of()); assertCode(() -> service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class)).createPayableSettlement(7L, "ap-" + condition, new CreatePayableSettlementRequest(10L, 20L, List.of(31L), LocalDate.of(2026, 9, 30), "CNY")), "FIN_409"); @@ -579,7 +718,7 @@ class FinanceSettlementBehaviorTest { Map.entry("bill_date", Date.valueOf("2026-08-31")), Map.entry("due_date", Date.valueOf("2026-09-30")), Map.entry("status", "ISSUED"), Map.entry("total_amount", new BigDecimal("300.00"))))); when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AR_CREATE"), eq("ar-key"))).thenReturn(List.of()); - when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_settlement WHERE"), eq(Integer.class), eq(10L), eq(501L))).thenReturn(1); + when(jdbc.queryForList(startsWith("SELECT id FROM fin_settlement WHERE"), eq(10L), eq(501L), eq(0L))).thenReturn(List.of(Map.of("id", 99L))); FinanceSettlementService service = service(jdbc, org.mockito.Mockito.mock(FinanceFileClient.class)); assertCode(() -> service.createReceivableSettlement(7L, "ar-key", new CreateReceivableSettlementRequest(501L, "CNY")), "FIN_409"); @@ -598,7 +737,7 @@ class FinanceSettlementBehaviorTest { JdbcTemplate occupiedJdbc = org.mockito.Mockito.mock(JdbcTemplate.class); when(occupiedJdbc.queryForList(startsWith("SELECT request_hash"), eq(10L), eq(7L), eq("SETTLEMENT_AP_CREATE"), eq("occupied-key"))).thenReturn(List.of()); when(occupiedJdbc.queryForList(startsWith("SELECT id,customer_id"), eq(31L))).thenReturn(List.of(payableSource(10L, 20L))); - when(occupiedJdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_settlement_line"), eq(Integer.class), eq(31L))).thenReturn(1); + when(occupiedJdbc.queryForList(startsWith("SELECT s.id FROM fin_settlement_line"), eq(10L), eq(31L), eq(0L))).thenReturn(List.of(Map.of("id", 99L))); FinanceSettlementService occupiedService = service(occupiedJdbc, org.mockito.Mockito.mock(FinanceFileClient.class)); assertCode(() -> occupiedService.createPayableSettlement(7L, "occupied-key", request), "FIN_409"); } @@ -633,8 +772,15 @@ class FinanceSettlementBehaviorTest { String sql = String.join(" ", select.value()).replaceAll("#\\{[a-zA-Z]+\\}", "?"); return jdbc.queryForList(sql, invocation.getArguments()); }); + var stateMapper = org.mockito.Mockito.mock(com.baiyue.finance.mapper.FinanceSettlementStateMapper.class, invocation -> { + var select = invocation.getMethod().getAnnotation(org.apache.ibatis.annotations.Select.class); + if (select != null) return jdbc.queryForList(String.join(" ", select.value()).replaceAll("#\\{[a-zA-Z]+\\}", "?"), invocation.getArguments()); + var update = invocation.getMethod().getAnnotation(org.apache.ibatis.annotations.Update.class); + if (update != null) return jdbc.update(String.join(" ", update.value()).replaceAll("#\\{[a-zA-Z]+\\}", "?"), invocation.getArguments()); + return RETURNS_DEFAULTS.answer(invocation); + }); return new FinanceSettlementService(jdbc, new ObjectMapper().findAndRegisterModules(), - new FinanceVoucherValidator(fileClient, "internal-token"), supplierValidator, documentNumbers, new com.baiyue.finance.repository.FinanceSettlementDetailRepository(mapper), FinanceCustomerNamesTestSupport.names()); + new FinanceVoucherValidator(fileClient, "internal-token"), supplierValidator, documentNumbers, new com.baiyue.finance.repository.FinanceSettlementDetailRepository(mapper), FinanceCustomerNamesTestSupport.names(), new com.baiyue.finance.repository.FinanceSettlementStateRepository(stateMapper)); } private static FinanceSupplierValidator supplierValidator() { diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSettlementStateMapperTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSettlementStateMapperTest.java new file mode 100644 index 000000000..5a86e7b6f --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FinanceSettlementStateMapperTest.java @@ -0,0 +1,123 @@ +package com.baiyue.finance; + +import com.baiyue.finance.mapper.FinanceSettlementStateMapper; +import com.baiyue.finance.repository.FinanceSettlementStateRepository; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.session.Configuration; +import org.apache.ibatis.session.SqlSessionFactory; +import org.apache.ibatis.session.SqlSessionFactoryBuilder; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class FinanceSettlementStateMapperTest { + /** 真正执行隔离查询,既保留损坏明细供业务拒绝,也不把其他客户占用算入当前客户。 */ + @Test + void originalRowsAndOccupancyStayCustomerScoped() { + var fixture = fixture(); + fixture.jdbc.update("INSERT INTO fin_fee_source VALUES (32,11,'PAYABLE','CNY',100,'APPROVED','AVAILABLE','ON_ACCOUNT','{}')"); + fixture.jdbc.update("INSERT INTO fin_settlement_line VALUES (3,10,88,32,100,'CNY'),(4,11,88,31,100,'CNY')"); + try (var session = fixture.factory.openSession()) { + var repository = new FinanceSettlementStateRepository(session.getMapper(FinanceSettlementStateMapper.class)); + assertThat(repository.lockSources(10, 88)).hasSize(2); + assertThat(repository.lockSources(10, 88).get(1).get("customer_id")).isEqualTo(11L); + assertThat(repository.sourceOccupied(10, 31, 88)).isFalse(); + assertThat(repository.sourceOccupied(11, 31, 88)).isFalse(); + assertThat(repository.lockBill(10, 501)).hasSize(1); + assertThat(repository.lockBill(11, 501)).isEmpty(); + assertThat(repository.lockBillLines(10, 501)).hasSize(1); + assertThat(repository.lockBillLines(11, 501)).isEmpty(); + } + } + + /** 同一会话重复校验必须读取最新状态,状态写入继续校验客户与版本。 */ + @Test + void stateUpdatesCannotReuseCachedOccupancyOrBypassVersion() throws Exception { + var fixture = fixture(); + try (var session = fixture.factory.openSession()) { + var repository = new FinanceSettlementStateRepository(session.getMapper(FinanceSettlementStateMapper.class)); + assertThat(repository.sourceOccupied(10, 31, 99)).isFalse(); + assertThat(repository.changeStatus("ISSUED", 7, 88, 11, 3)).isZero(); + assertThat(repository.changeStatus("ISSUED", 7, 88, 10, 2)).isZero(); + assertThat(repository.changeStatus("ISSUED", 7, 88, 10, 3)).isEqualTo(1); + assertThat(repository.sourceOccupied(10, 31, 99)).isTrue(); + assertThat(repository.lockSettlement(88).get(0).get("version")).isEqualTo(4); + assertThat(repository.lockSources(10, 88).get(0).get("status")).isEqualTo("AVAILABLE"); + try (var statement = session.getConnection().createStatement()) { + statement.executeUpdate("UPDATE fin_fee_source SET status='SUPERSEDED' WHERE id=31"); + } + assertThat(repository.lockSources(10, 88).get(0).get("status")).isEqualTo("SUPERSEDED"); + } + } + + /** 同账单唯一应收采用当前读取,排除自身、作废单及其他客户。 */ + @Test + void receivableOccupancyReflectsCurrentActiveSettlement() { + var fixture = fixture(); + fixture.jdbc.update("UPDATE fin_settlement SET direction='RECEIVABLE',reconciliation_statement_id=501,status='ISSUED' WHERE id=88"); + try (var session = fixture.factory.openSession()) { + var repository = new FinanceSettlementStateRepository(session.getMapper(FinanceSettlementStateMapper.class)); + assertThat(repository.billOccupied(10, 501, 99)).isTrue(); + assertThat(repository.billOccupied(10, 501, 88)).isFalse(); + assertThat(repository.billOccupied(11, 501, 99)).isFalse(); + repository.changeStatus("VOIDED", 7, 88, 10, 3); + assertThat(repository.billOccupied(10, 501, 99)).isFalse(); + } + } + + /** 两个事务争用同一来源时,后到事务须等待并读取先到事务提交后的占用。 */ + @Test + void sourceLockSerializesCompetingReopens() throws Exception { + var fixture = fixture(); + var executor = Executors.newSingleThreadExecutor(); + try (var first = fixture.factory.openSession()) { + var repository = new FinanceSettlementStateRepository(first.getMapper(FinanceSettlementStateMapper.class)); + repository.lockSources(10, 88); + var started = new CountDownLatch(1); + var second = executor.submit(() -> { + try (var session = fixture.factory.openSession()) { + var other = new FinanceSettlementStateRepository(session.getMapper(FinanceSettlementStateMapper.class)); + started.countDown(); + other.lockSources(10, 99); + return other.sourceOccupied(10, 31, 99); + } + }); + assertThat(started.await(2, TimeUnit.SECONDS)).isTrue(); + assertThatThrownBy(() -> second.get(150, TimeUnit.MILLISECONDS)).isInstanceOf(TimeoutException.class); + repository.changeStatus("ISSUED", 7, 88, 10, 3); + first.commit(); + assertThat(second.get(3, TimeUnit.SECONDS)).isTrue(); + } finally { + executor.shutdownNow(); + } + } + + /** 使用独立内存库执行真实 MyBatis SQL,不启动本地业务服务。 */ + private static Fixture fixture() { + var dataSource = new DriverManagerDataSource("jdbc:h2:mem:reopen-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=4000", "sa", ""); + var jdbc = new JdbcTemplate(dataSource); + jdbc.execute("CREATE TABLE fin_settlement(id BIGINT PRIMARY KEY,tenant_id BIGINT,total_amount DECIMAL(18,2),paid_amount DECIMAL(18,2),outstanding_amount DECIMAL(18,2),payment_status VARCHAR(32),status VARCHAR(32),version INT,direction VARCHAR(32),currency_code VARCHAR(3),supplier_id BIGINT,reconciliation_statement_id BIGINT,updated_by BIGINT)"); + jdbc.execute("CREATE TABLE fin_settlement_line(id BIGINT PRIMARY KEY,tenant_id BIGINT,settlement_id BIGINT,fee_source_id BIGINT,amount DECIMAL(18,2),currency_code VARCHAR(3))"); + jdbc.execute("CREATE TABLE fin_fee_source(id BIGINT PRIMARY KEY,customer_id BIGINT,direction VARCHAR(32),currency_code VARCHAR(3),amount DECIMAL(18,2),review_status VARCHAR(32),status VARCHAR(32),settlement_mode VARCHAR(32),source_snapshot VARCHAR(500))"); + jdbc.execute("CREATE TABLE fin_bill(id BIGINT PRIMARY KEY,tenant_id BIGINT,status VARCHAR(32),currency_code VARCHAR(3),total_amount DECIMAL(18,2),received_amount DECIMAL(18,2))"); + jdbc.execute("CREATE TABLE fin_bill_line(id BIGINT PRIMARY KEY,tenant_id BIGINT,bill_id BIGINT,source_id BIGINT,amount DECIMAL(18,2),currency_code VARCHAR(3))"); + jdbc.update("INSERT INTO fin_settlement VALUES (88,10,100,0,100,'UNPAID','VOIDED',3,'PAYABLE','CNY',20,NULL,7),(99,10,100,0,100,'UNPAID','VOIDED',3,'PAYABLE','CNY',20,NULL,7)"); + jdbc.update("INSERT INTO fin_settlement_line VALUES (1,10,88,31,100,'CNY'),(2,10,99,31,100,'CNY')"); + jdbc.update("INSERT INTO fin_fee_source VALUES (31,10,'PAYABLE','CNY',100,'APPROVED','AVAILABLE','ON_ACCOUNT','{}')"); + jdbc.update("INSERT INTO fin_bill VALUES (501,10,'ISSUED','CNY',100,0)"); + jdbc.update("INSERT INTO fin_bill_line VALUES (1,10,501,31,100,'CNY')"); + var configuration = new Configuration(new Environment("test", new JdbcTransactionFactory(), dataSource)); + configuration.addMapper(FinanceSettlementStateMapper.class); + return new Fixture(jdbc, new SqlSessionFactoryBuilder().build(configuration)); + } + + private record Fixture(JdbcTemplate jdbc, SqlSessionFactory factory) { } +}