fix(finance): revalidate source ownership before reopening settlements
This commit is contained in:
@@ -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<Map<String, Object>> 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<Long> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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);
|
||||
}
|
||||
+36
@@ -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<Map<String, Object>> rows(List<Map<String, Object>> rows) {
|
||||
return rows.stream().map(row -> { Map<String, Object> value = new LinkedCaseInsensitiveMap<>(); value.putAll(row); return value; }).toList();
|
||||
}
|
||||
/** 锁定目标结算。 */
|
||||
public List<Map<String, Object>> lockSettlement(long id) { return rows(mapper.lockSettlement(id)); }
|
||||
/** 锁定结算所有费用来源。 */
|
||||
public List<Map<String, Object>> 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<Map<String, Object>> lockBill(long customerId, long billId) { return rows(mapper.lockBill(customerId, billId)); }
|
||||
/** 锁定原始对账单的当前明细。 */
|
||||
public List<Map<String, Object>> 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); }
|
||||
}
|
||||
@@ -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<String, Object> 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<Map<String, Object>> locked = states.lockSettlement(settlementId);
|
||||
if (locked.isEmpty()) throw error("FIN_404", "结算单不存在");
|
||||
Map<String, Object> 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<String, Object> 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<Map<String, Object>> sources = states.lockSources(customerId, settlementId);
|
||||
if (supplierId == null || sources.isEmpty()) throw error("FIN_409", "原应付来源或供应商不完整");
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
List<Map<String, Object>> categories = new ArrayList<>();
|
||||
for (Map<String, Object> 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<String, Object> 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<String, Object> 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<Map<String, Object>> bills = states.lockBill(customerId, billId);
|
||||
if (bills.isEmpty()) throw error("FIN_409", "原对账单不存在或不属于当前客户");
|
||||
Map<String, Object> 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<Map<String, Object>> lines = states.lockBillLines(customerId, billId);
|
||||
List<Map<String, Object>> sources = states.lockSources(customerId, settlementId);
|
||||
if (lines.isEmpty() || sources.size() != lines.size()) throw error("FIN_409", "原应收明细与对账单不一致");
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
for (Map<String, Object> 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,
|
||||
|
||||
@@ -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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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() {
|
||||
|
||||
@@ -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) { }
|
||||
}
|
||||
Reference in New Issue
Block a user