124 lines
8.0 KiB
Java
124 lines
8.0 KiB
Java
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) { }
|
|
}
|