fix(finance): revalidate source ownership before reopening settlements
This commit is contained in:
@@ -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