180 lines
15 KiB
Java
180 lines
15 KiB
Java
package com.baiyue.finance;
|
|
|
|
import com.baiyue.finance.mapper.FinanceFeeSourceReadMapper;
|
|
import com.baiyue.finance.repository.FinanceFeeSourceReadRepository;
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
import org.apache.ibatis.mapping.Environment;
|
|
import org.apache.ibatis.session.Configuration;
|
|
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
|
|
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
|
|
import org.junit.jupiter.params.ParameterizedTest;
|
|
import org.junit.jupiter.params.provider.ValueSource;
|
|
import org.springframework.jdbc.core.JdbcTemplate;
|
|
import org.springframework.jdbc.datasource.DriverManagerDataSource;
|
|
|
|
import java.math.BigDecimal;
|
|
import java.time.LocalDate;
|
|
import java.util.UUID;
|
|
import java.util.List;
|
|
|
|
import static org.assertj.core.api.Assertions.assertThat;
|
|
|
|
/** 隔离数据库执行正式 Mapper,验证版本归并与历史不会改变金额和客户归属。 */
|
|
public class FinanceFeeSourceReadMapperTest {
|
|
/** 应收和应付均以来源身份替换当前展示,同名费用、其他来源类型和系统保持独立。 */
|
|
@ParameterizedTest
|
|
@ValueSource(strings = {"RECEIVABLE", "PAYABLE"})
|
|
void currentProjectionAndHistoryKeepStableFeeIdentity(String direction) {
|
|
var data = new DriverManagerDataSource("jdbc:h2:mem:fee-current-" + UUID.randomUUID() + ";MODE=MySQL", "sa", "");
|
|
var jdbc = new JdbcTemplate(data);
|
|
// 保持一个会话存活;Mapper 使用同一隔离数据库,绝不访问共享测试环境。
|
|
try (var connection = data.getConnection()) {
|
|
jdbc.execute("CREATE ALIAS JSON_EXTRACT FOR 'com.baiyue.finance.FinanceFeeSourceReadMapperTest.jsonExtract'");
|
|
jdbc.execute("CREATE ALIAS JSON_UNQUOTE FOR 'com.baiyue.finance.FinanceFeeSourceReadMapperTest.jsonUnquote'");
|
|
jdbc.execute("CREATE TABLE fin_fee_source(id BIGINT PRIMARY KEY,source_system VARCHAR(32),source_type VARCHAR(32),source_fee_id BIGINT,source_fee_version INT,customer_id BIGINT,direction VARCHAR(16),source_no VARCHAR(64),fee_item_name VARCHAR(100),amount DECIMAL(20,6),currency_code VARCHAR(3),review_status VARCHAR(16),status VARCHAR(16),source_snapshot VARCHAR(2000),order_id BIGINT,inbound_id BIGINT,container_id BIGINT,created_at TIMESTAMP,settlement_mode VARCHAR(32),paid_at TIMESTAMP,operation_version INT DEFAULT 0,review_reason VARCHAR(128),source_hash VARCHAR(64))");
|
|
jdbc.execute("CREATE TABLE fin_bill_line(source_id BIGINT,source_version INT,tenant_id BIGINT,bill_id BIGINT)");
|
|
jdbc.execute("CREATE TABLE fin_bill(id BIGINT PRIMARY KEY,tenant_id BIGINT,status VARCHAR(24))");
|
|
jdbc.execute("CREATE TABLE fin_settlement(id BIGINT PRIMARY KEY,tenant_id BIGINT,direction VARCHAR(16),status VARCHAR(24),payment_status VARCHAR(24))");
|
|
jdbc.execute("CREATE TABLE fin_settlement_line(settlement_id BIGINT,tenant_id BIGINT,fee_source_id BIGINT)");
|
|
jdbc.execute("CREATE TABLE fin_audit_log(id BIGINT PRIMARY KEY,tenant_id BIGINT,resource_type VARCHAR(32),resource_id BIGINT,action VARCHAR(64),operator_name_snapshot VARCHAR(64),reason VARCHAR(100),before_data VARCHAR(1000),after_data VARCHAR(1000),created_at TIMESTAMP)");
|
|
insert(jdbc, 1, "FIRSTLEG", "INBOUND_FEE", 700, 1, 9, direction, "SUPERSEDED", "100.00");
|
|
insert(jdbc, 2, "FIRSTLEG", "INBOUND_FEE", 700, 2, 9, direction, "AVAILABLE", "120.00");
|
|
insert(jdbc, 3, "FIRSTLEG", "INBOUND_FEE", 701, 1, 9, direction, "AVAILABLE", "80.00");
|
|
insert(jdbc, 4, "FIRSTLEG", "INBOUND_FEE", 702, 1, 9, direction, "AVAILABLE", "50.00");
|
|
insert(jdbc, 5, "FIRSTLEG", "INBOUND_FEE", 702, 2, 9, direction, "VOIDED", "70.00");
|
|
insert(jdbc, 6, "LASTLEG", "INBOUND_FEE", 700, 1, 9, direction, "AVAILABLE", "33.00");
|
|
insert(jdbc, 7, "FIRSTLEG", "CONTAINER_FEE", 700, 1, 9, direction, "AVAILABLE", "44.00");
|
|
insert(jdbc, 8, "FIRSTLEG", "INBOUND_FEE", 703, 1, 10, direction, "AVAILABLE", "55.00");
|
|
jdbc.update("INSERT INTO fin_audit_log VALUES(1,9,'FIN_FEE_SOURCE',1,'FEE_SOURCE_REVIEW_APPROVE','审核员','原费用审核',NULL,NULL,'2026-10-21 10:01:30')");
|
|
jdbc.update("INSERT INTO fin_audit_log VALUES(2,9,'FIN_FEE_SOURCE',2,'FEE_SOURCE_ADJUST','财务员','更正费用','{\"amount\":100}','{\"amount\":120,\"currency\":\"CNY\"}','2026-10-21 10:03:00')");
|
|
jdbc.update("INSERT INTO fin_audit_log VALUES(3,9,'FIN_FEE_SOURCE',3,'FEE_SOURCE_ADJUST','其他费用','不能合并',NULL,NULL,'2026-10-21 10:04:00')");
|
|
jdbc.update("INSERT INTO fin_audit_log VALUES(4,10,'FIN_FEE_SOURCE',1,'FEE_SOURCE_ADJUST','其他客户','不能越界',NULL,NULL,'2026-10-21 10:04:00')");
|
|
|
|
var configuration = new Configuration(new Environment("test", new JdbcTransactionFactory(), data));
|
|
configuration.addMapper(FinanceFeeSourceReadMapper.class);
|
|
try (var session = new SqlSessionFactoryBuilder().build(configuration).openSession()) {
|
|
var repository = new FinanceFeeSourceReadRepository(session.getMapper(FinanceFeeSourceReadMapper.class), new ObjectMapper());
|
|
var filters = repository.filters(9L, direction, null, "APPROVED", false,
|
|
LocalDate.of(2026, 10, 1), LocalDate.of(2026, 10, 31), "操作费", 1, 20);
|
|
assertThat(repository.count(filters)).isEqualTo(5);
|
|
var rows = repository.list(filters);
|
|
assertThat(rows).extracting(row -> row.feeSourceId()).containsExactly(7L, 6L, 5L, 3L, 2L);
|
|
assertThat(rows.stream().map(row -> row.amount()).reduce(BigDecimal.ZERO, BigDecimal::add))
|
|
.isEqualByComparingTo("347.00");
|
|
assertThat(rows).allSatisfy(row -> {
|
|
assertThat(row.direction()).isEqualTo(direction);
|
|
assertThat(row.customerId()).isEqualTo(9);
|
|
assertThat(row.supplierId()).isEqualTo(79);
|
|
assertThat(row.inboundCode()).isEqualTo("IN-9");
|
|
assertThat(row.businessDate()).isEqualTo(LocalDate.of(2026, 10, 21));
|
|
assertThat(row.originalAmount()).isEqualByComparingTo("200");
|
|
assertThat(row.unitPrice()).isEqualByComparingTo("20");
|
|
});
|
|
filters.put("limit", 2);
|
|
filters.put("offset", 2);
|
|
assertThat(repository.count(filters)).isEqualTo(5);
|
|
assertThat(repository.list(filters)).extracting(row -> row.feeSourceId()).containsExactly(5L, 3L);
|
|
filters.put("limit", 20); filters.put("offset", 0); filters.put("status", "AVAILABLE");
|
|
assertThat(repository.count(filters)).isEqualTo(4);
|
|
assertThat(repository.list(filters)).extracting(row -> row.feeSourceId()).doesNotContain(4L, 5L);
|
|
filters.put("status", "SUPERSEDED");
|
|
assertThat(repository.list(filters)).extracting(row -> row.feeSourceId()).containsExactly(1L);
|
|
|
|
var logs = repository.logs(9, 2);
|
|
assertThat(logs).hasSize(4);
|
|
assertThat(logs).extracting(log -> log.action()).containsExactly(
|
|
"FEE_SOURCE_ADJUST", "FEE_SOURCE_VERSION_ADJUST", "FEE_SOURCE_REVIEW_APPROVE", "FEE_SOURCE_PUBLISHED");
|
|
assertThat(logs.get(0).remark()).isEqualTo("更正费用;金额 100 → 120 CNY");
|
|
assertThat(logs.get(1).remark()).contains("来源版本 1 → 2", "100.000000 → 120.000000 CNY");
|
|
assertThat(logs).noneSatisfy(log -> assertThat(log.operatorName()).startsWith("其他"));
|
|
assertThat(repository.logs(9, 2)).isEqualTo(logs);
|
|
assertThat(repository.logs(9, 1)).hasSize(2);
|
|
assertThat(repository.logs(10, 2)).isEmpty();
|
|
// 同毫秒必须先展示新版本;审计仍按 ID 倒序,不能反转金额差分或突破旧版本锚点。
|
|
jdbc.update("UPDATE fin_fee_source SET created_at='2026-10-21 10:03:00.123' WHERE id IN (1,2)");
|
|
jdbc.update("UPDATE fin_audit_log SET created_at='2026-10-21 10:03:00.123' WHERE id IN (1,2)");
|
|
jdbc.update("INSERT INTO fin_audit_log VALUES(6,9,'FIN_FEE_SOURCE',2,'FEE_SOURCE_REVIEW_APPROVE','审核员','同毫秒后审计',NULL,NULL,'2026-10-21 10:03:00.123')");
|
|
session.clearCache();
|
|
var sameTimeLogs = repository.logs(9, 2);
|
|
assertThat(sameTimeLogs).extracting(log -> log.action()).containsExactly(
|
|
"FEE_SOURCE_VERSION_ADJUST", "FEE_SOURCE_PUBLISHED", "FEE_SOURCE_REVIEW_APPROVE",
|
|
"FEE_SOURCE_ADJUST", "FEE_SOURCE_REVIEW_APPROVE");
|
|
assertThat(sameTimeLogs.get(0).remark()).contains("来源版本 1 → 2", "100.000000 → 120.000000 CNY");
|
|
assertThat(sameTimeLogs.get(1).remark()).contains("来源版本 1;金额 100.000000 CNY");
|
|
assertThat(sameTimeLogs.subList(2, 5)).extracting(log -> log.remark()).containsExactly(
|
|
"同毫秒后审计", "更正费用;金额 100 → 120 CNY", "原费用审核");
|
|
assertThat(repository.logs(9, 2)).isEqualTo(sameTimeLogs);
|
|
assertThat(repository.logs(9, 1)).extracting(log -> log.action()).containsExactly(
|
|
"FEE_SOURCE_PUBLISHED", "FEE_SOURCE_REVIEW_APPROVE");
|
|
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_fee_source", Long.class)).isEqualTo(8);
|
|
if ("RECEIVABLE".equals(direction)) {
|
|
jdbc.update("INSERT INTO fin_bill VALUES(91,9,'ISSUED')");
|
|
jdbc.update("INSERT INTO fin_bill_line VALUES(2,2,9,91)");
|
|
filters.put("status", "BILLED"); filters.put("statementGenerated", true);
|
|
session.clearCache();
|
|
assertThat(repository.count(filters)).isEqualTo(1);
|
|
assertThat(repository.list(filters)).extracting(row -> row.feeSourceId()).containsExactly(2L);
|
|
assertThat(repository.detail(2).sourceStatus()).isEqualTo("BILLED");
|
|
jdbc.update("UPDATE fin_bill SET status='VOIDED' WHERE id=91");
|
|
session.clearCache();
|
|
assertThat(repository.detail(2).statementGenerated()).isFalse();
|
|
assertThat(repository.detail(2).sourceStatus()).isEqualTo("AVAILABLE");
|
|
} else {
|
|
jdbc.update("INSERT INTO fin_settlement VALUES(91,9,'PAYABLE','ISSUED','UNPAID')");
|
|
jdbc.update("INSERT INTO fin_settlement_line VALUES(91,9,2)");
|
|
for (String payment : List.of("UNPAID", "PARTIALLY_PAID", "PAID")) {
|
|
jdbc.update("UPDATE fin_settlement SET payment_status=? WHERE id=91", payment);
|
|
String expected = "UNPAID".equals(payment) ? "PAYABLE_BILLED" : payment;
|
|
filters.put("status", expected);
|
|
assertThat(repository.count(filters)).isEqualTo(1);
|
|
assertThat(repository.list(filters)).singleElement().satisfies(row -> {
|
|
assertThat(row.feeSourceId()).isEqualTo(2);
|
|
assertThat(row.sourceStatus()).isEqualTo(expected);
|
|
assertThat(row.statementGenerated()).isFalse();
|
|
assertThat(row.amount()).isEqualByComparingTo("120");
|
|
});
|
|
assertThat(repository.detail(2).sourceStatus()).isEqualTo(expected);
|
|
}
|
|
jdbc.update("UPDATE fin_settlement SET status='VOIDED' WHERE id=91");
|
|
assertThat(repository.detail(2).sourceStatus()).isEqualTo("AVAILABLE");
|
|
jdbc.update("UPDATE fin_settlement SET status='PAID',tenant_id=10 WHERE id=91");
|
|
jdbc.update("UPDATE fin_settlement_line SET tenant_id=10 WHERE settlement_id=91");
|
|
assertThat(repository.detail(2).sourceStatus()).isEqualTo("AVAILABLE");
|
|
jdbc.update("UPDATE fin_fee_source SET settlement_mode='DIRECT_PAID',paid_at='2026-10-21 10:00:00' WHERE id=2");
|
|
assertThat(repository.detail(2).sourceStatus()).isEqualTo("PAID");
|
|
jdbc.update("UPDATE fin_fee_source SET status='VOIDED' WHERE id=2");
|
|
assertThat(repository.detail(2).sourceStatus()).isEqualTo("VOIDED");
|
|
}
|
|
|
|
// 财务内部费用原地改金额,其历史只取真实审计,不能伪造旧时间的同步金额。
|
|
insert(jdbc, 9, "FINANCE", "MANUAL", 700, 1, 9, direction, "AVAILABLE", "120.00");
|
|
jdbc.update("INSERT INTO fin_audit_log VALUES(5,9,'FIN_FEE_SOURCE',9,'FEE_SOURCE_ADJUST','财务员','更正人工费用','{\"amount\":100}','{\"amount\":120,\"currency\":\"CNY\"}','2026-10-21 10:05:00')");
|
|
assertThat(repository.logs(9, 9)).singleElement().satisfies(log -> {
|
|
assertThat(log.action()).isEqualTo("FEE_SOURCE_ADJUST");
|
|
assertThat(log.remark()).isEqualTo("更正人工费用;金额 100 → 120 CNY");
|
|
});
|
|
}
|
|
} catch (java.sql.SQLException failure) { throw new AssertionError(failure); }
|
|
}
|
|
|
|
/** 准备具有相同名称、不同稳定来源的隔离费用快照。 */
|
|
private static void insert(JdbcTemplate jdbc, long id, String system, String type, long fee, int version,
|
|
long customer, String direction, String status, String amount) {
|
|
jdbc.update("INSERT INTO fin_fee_source(id,source_system,source_type,source_fee_id,source_fee_version,customer_id,direction,source_no,fee_item_name,amount,currency_code,review_status,status,source_snapshot,created_at) VALUES(?,?,?,?,?,?,?,'IN-9','操作费',?,'CNY','APPROVED',?,?,?)",
|
|
id, system, type, fee, version, customer, direction, new BigDecimal(amount), status,
|
|
"{\"businessDate\":\"2026-10-21\",\"supplierId\":79,\"inboundCode\":\"IN-9\",\"chargeableQuantity\":10,\"originalAmount\":200}",
|
|
java.sql.Timestamp.valueOf("2026-10-21 10:0" + version + ":00"));
|
|
}
|
|
|
|
/** H2 仅补足 MySQL JSON 读取函数,实际筛选、关联和分页仍执行正式 SQL。 */
|
|
public static String jsonExtract(String value, String path) throws Exception {
|
|
var node = new ObjectMapper().readTree(value).path(path.substring(2));
|
|
return node.isMissingNode() || node.isNull() ? null : node.toString();
|
|
}
|
|
|
|
/** 对测试 JSON 标量解引号,保持 MySQL 空值语义。 */
|
|
public static String jsonUnquote(String value) throws Exception {
|
|
return value == null ? null : new ObjectMapper().readTree(value).asText();
|
|
}
|
|
}
|