修复费用记录、装柜完成及海外履约资料

This commit is contained in:
2026-10-21 23:20:09 +08:00
parent 5356a8c898
commit 2c3435adaf
62 changed files with 1622 additions and 298 deletions
@@ -17,6 +17,7 @@ class FinanceContractTest {
.getAnnotation(org.springframework.scheduling.annotation.EnableScheduling.class));
}
/** 固定财务访问域与账单关联键,查询迁入 Mapper 后仍核对真实来源记录。 */
@Test
void controllersUseFrozenFinancePaths() throws Exception {
assertEquals("/api/platform/finance/v1", PlatformFinanceController.class.getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class).value()[0]);
@@ -40,7 +41,8 @@ class FinanceContractTest {
assertTrue(controller.contains("costSnapshotClient.loadingPlan") && controller.contains("createCost(request.customerId(), user.userId(), internal, volumes, key)"));
assertTrue(service.contains("validateVoucherFiles(tenantId, request.voucherFileIds())"));
String operations = Files.readString(Path.of("src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java"));
assertTrue(operations.contains("bl.source_id=f.id"));
String feeReads = Files.readString(Path.of("src/main/java/com/baiyue/finance/mapper/FinanceFeeSourceReadMapper.java"));
assertTrue(feeReads.contains("bl.source_id=f.id"));
assertTrue(operations.contains("Boolean.class, id, rs.getInt(\"source_fee_version\")"));
assertFalse(operations.contains("bl.source_id=f.source_fee_id"));
String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"));
@@ -0,0 +1,141 @@
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 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)");
jdbc.execute("CREATE TABLE fin_bill_line(source_id BIGINT,source_version INT)");
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);
jdbc.update("INSERT INTO fin_bill_line VALUES(2,2)");
filters.put("status", "BILLED"); filters.put("statementGenerated", true);
assertThat(repository.count(filters)).isEqualTo(1);
assertThat(repository.list(filters)).extracting(row -> row.feeSourceId()).containsExactly(2L);
// 财务内部费用原地改金额,其历史只取真实审计,不能伪造旧时间的同步金额。
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();
}
}
@@ -9,10 +9,13 @@ import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceDetail;
import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope;
import com.baiyue.finance.service.FinanceEventException;
import com.baiyue.finance.service.FinancePhase1OperationService;
import com.baiyue.finance.repository.FinanceFeeSourceReadRepository;
import com.baiyue.finance.service.FinanceCustomerAccountService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -40,6 +43,9 @@ import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.times;
@ExtendWith(MockitoExtension.class)
class FinancePhase1OperationBehaviorTest {
@@ -81,18 +87,20 @@ class FinancePhase1OperationBehaviorTest {
assertThat(response.page().total()).isEqualTo(1);
}
/** 无效客户在进入持久化查询前拒绝。 */
@Test
void ledgerRejectsInvalidCustomerBeforeAnyQuery() {
FinancePhase1OperationService service = new FinancePhase1OperationService(mock(JdbcTemplate.class), objectMapper());
FinancePhase1OperationService service = new FinancePhase1OperationService(mock(JdbcTemplate.class), objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
assertThatThrownBy(() -> service.listDerivedCustomerAccountLedger(0, 1, 20, null, null, null))
.isInstanceOf(FinanceEventException.class)
.extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-REQ-400");
}
/** 保留客户账户汇总的固定分页与金额口径。 */
@Test
void accountListReturnsFixedPagedDto() {
AccountJdbc jdbc = new AccountJdbc();
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
PageEnvelope<FinancePhase1Dtos.AccountSummary> result =
service.listDerivedCustomerAccounts(1, 20, "客户9", "ACTIVE");
@@ -112,10 +120,11 @@ class FinancePhase1OperationBehaviorTest {
assertThat(jdbc.accountListSql).doesNotContain("CAST(c.customer_id AS CHAR)");
}
/** 所有派生流水查询保留目标客户范围。 */
@Test
void ledgerQueriesEveryDerivedSourceWithinCustomerScope() {
AccountJdbc jdbc = new AccountJdbc();
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
PageEnvelope<FinancePhase1Dtos.LedgerEntry> result =
service.listDerivedCustomerAccountLedger(9L, 1, 20, null, null, null);
@@ -128,58 +137,24 @@ class FinancePhase1OperationBehaviorTest {
.contains("fin_settlement_payment", "transaction_type='PAYMENT'", "s.direction='PAYABLE'"));
}
/** 所有筛选在服务端进入同一版本投影,不在分页后按费用名称去重。 */
@Test
void feeSourceListUsesFormalFieldsAndAllActiveFilters() throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_fee_source"), eq(Long.class), any(Object[].class)))
.thenReturn(1L);
when(jdbc.query(startsWith("SELECT f.*"), any(RowMapper.class), any(Object[].class))).thenAnswer(invocation -> {
@SuppressWarnings("unchecked") RowMapper<Object> mapper = invocation.getArgument(1);
ResultSet rs = mock(ResultSet.class);
when(rs.getLong("id")).thenReturn(88L);
when(rs.getLong("source_fee_id")).thenReturn(9001L);
when(rs.getInt("source_fee_version")).thenReturn(3);
when(rs.getLong("customer_id")).thenReturn(9L);
when(rs.getLong("order_id")).thenReturn(19L);
when(rs.getLong("inbound_id")).thenReturn(29L);
when(rs.getLong("container_id")).thenReturn(39L);
when(rs.getString("source_system")).thenReturn("FIRSTLEG");
when(rs.getString("source_type")).thenReturn("INBOUND_FEE");
when(rs.getString("source_no")).thenReturn("IN-9");
when(rs.getString("direction")).thenReturn("RECEIVABLE");
when(rs.getString("fee_item_name")).thenReturn("入库操作费");
when(rs.getString("currency_code")).thenReturn("CNY");
when(rs.getString("review_status")).thenReturn("APPROVED");
when(rs.getString("effective_status")).thenReturn("AVAILABLE");
when(rs.getString("source_snapshot")).thenReturn("{\"customerName\":\"客户9\",\"supplierId\":\"79\",\"supplierName\":\"供应商A\",\"inboundCode\":\"IN-9\",\"containerNo\":\"CONT-9\",\"businessDate\":\"2026-08-11\",\"chargeableQuantity\":\"10\"}");
when(rs.getBigDecimal("amount")).thenReturn(new BigDecimal("120.00"));
when(rs.getBoolean("statement_generated")).thenReturn(false);
return List.of(mapper.mapRow(rs, 0));
});
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
void feeSourceListUsesFormalFieldsAndAllActiveFilters() {
var reads = mock(FinanceFeeSourceReadRepository.class);
var filters = java.util.Map.<String, Object>of("query", "current");
when(reads.filters(9L, "RECEIVABLE", "AVAILABLE", "APPROVED", false,
LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "IN-9", 2, 50)).thenReturn(filters);
when(reads.list(filters)).thenReturn(List.of());
when(reads.count(filters)).thenReturn(3L);
var service = new FinancePhase1OperationService(mock(JdbcTemplate.class), objectMapper(), null, "", reads);
var result = service.listFinanceFeeSources(2, 50, 9L, "receivable", "available", "approved",
false, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "IN-9");
assertThat(result.page()).satisfies(meta -> {
assertThat(meta.page()).isEqualTo(2);
assertThat(meta.pageSize()).isEqualTo(50);
assertThat(meta.total()).isEqualTo(1);
});
assertThat(result.data()).singleElement().satisfies(source -> {
assertThat(source.sourceSystem()).isEqualTo("FIRSTLEG");
assertThat(source.sourceBusinessId()).isEqualTo("IN-9");
assertThat(source.inboundCode()).isEqualTo("IN-9");
assertThat(source.containerNo()).isEqualTo("CONT-9");
assertThat(source.supplierId()).isEqualTo(79L);
assertThat(source.supplierName()).isEqualTo("供应商A");
assertThat(source.reviewStatus()).isEqualTo("APPROVED");
assertThat(source.businessDate()).isEqualTo(LocalDate.of(2026, 8, 11));
assertThat(source.statementGenerated()).isFalse();
});
verify(jdbc).query(startsWith("SELECT f.*"), any(RowMapper.class), any(Object[].class));
assertThat(result.page().total()).isEqualTo(3);
assertThat(result.page().page()).isEqualTo(2);
verify(reads).list(filters);
}
/** 流水返回业务单号和操作者快照。 */
@Test
void ledgerReturnsStableBusinessTypeDocumentAndOperatorNameSnapshot() throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
@@ -197,7 +172,7 @@ class FinancePhase1OperationBehaviorTest {
});
when(jdbc.query(startsWith("SELECT id,receipt_no"), any(RowMapper.class), eq(9L))).thenReturn(List.of());
when(jdbc.query(startsWith("SELECT id,cost_no"), any(RowMapper.class), eq(9L))).thenReturn(List.of());
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
PageEnvelope<FinancePhase1Dtos.LedgerEntry> result =
service.listDerivedCustomerAccountLedger(9L, 1, 20, null, null, null);
@@ -211,18 +186,20 @@ class FinancePhase1OperationBehaviorTest {
});
}
/** 保留费用审核状态读取。 */
@Test
void approvedFeeCannotBeEditedAsDraft() throws Exception {
JdbcTemplate jdbc = feeSourceJdbc("REJECTED", false, 2);
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
assertThat(service.getFinanceFeeSource(88L).reviewStatus()).isEqualTo("REJECTED");
}
/** 费用详情保持原客户与来源版本。 */
@Test
void feeSourceDetailUsesFixedDtoAndRealCustomerScope() throws Exception {
FinancePhase1OperationService service = new FinancePhase1OperationService(
feeSourceJdbc("APPROVED", true, 2), objectMapper());
feeSourceJdbc("APPROVED", true, 2), objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
FeeSourceDetail detail = service.getFinanceFeeSource(88L);
@@ -232,10 +209,11 @@ class FinancePhase1OperationBehaviorTest {
assertThat(detail.reviewStatus()).isEqualTo("APPROVED");
}
/** 业务来源费用必须回到所属系统调整。 */
@Test
void firstlegFeeEditAndAdjustAreBlockedInsteadOfMutatingFinanceCopy() throws Exception {
JdbcTemplate jdbc = feeSourceJdbc("REJECTED", false, 1);
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
var request = new FinancePhase1Dtos.FeeSourceEditRequest(
new BigDecimal("120.00"), "更正金额", 1);
@@ -247,6 +225,7 @@ class FinancePhase1OperationBehaviorTest {
(Object[]) any());
}
/** 头程作废等待权威来源事件,不能直接改财务副本。 */
@Test
void voidingNyexx2608180003DelegatesToFirstlegAndWaitsForEvent() throws Exception {
JdbcTemplate jdbc = feeSourceJdbc("APPROVED", false, 2);
@@ -257,7 +236,7 @@ class FinancePhase1OperationBehaviorTest {
.thenReturn(ApiResponse.success(new FirstlegFeeSourceClient.CommandResult(
"INBOUND_FEE", 700L, 3, "VOIDED", false)));
FinancePhase1OperationService service = new FinancePhase1OperationService(
jdbc, objectMapper(), firstleg, "internal-token");
jdbc, objectMapper(), firstleg, "internal-token", mock(FinanceFeeSourceReadRepository.class));
FeeSourceDetail result = service.voidFinanceFeeSource(88L, 71L, "finance",
"void-key", new FinancePhase1Dtos.FeeSourceActionRequest("测试作废", 2));
@@ -278,6 +257,7 @@ class FinancePhase1OperationBehaviorTest {
(Object[]) any());
}
/** 审核以当前操作版本更新并追加同一记录的审计。 */
@Test
void reviewUsesExpectedVersionAndReturnsUpdatedDetail() throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
@@ -289,7 +269,6 @@ class FinancePhase1OperationBehaviorTest {
return List.of(mapper.mapRow(resultSet(review, false, version), 0));
});
when(jdbc.queryForObject(startsWith("SELECT COUNT(*)>0"), eq(Boolean.class), eq(88L), eq(3))).thenReturn(false);
when(jdbc.query(startsWith("SELECT created_at,action"), any(RowMapper.class), eq(9L), eq(88L))).thenReturn(List.of());
when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L),
eq("FEE_SOURCE_REVIEW"), eq("review-key"))).thenReturn(List.of());
when(jdbc.update(startsWith("INSERT INTO fin_idempotency_record"), eq(9L), eq(71L),
@@ -301,7 +280,7 @@ class FinancePhase1OperationBehaviorTest {
eq(71L), eq("操作人 71"), any(), any())).thenReturn(1);
when(jdbc.update(startsWith("UPDATE fin_idempotency_record SET status='SUCCEEDED'"),
eq(88L), eq(9L), eq(71L), eq("FEE_SOURCE_REVIEW"), eq("review-key"))).thenReturn(1);
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper());
FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class));
FeeSourceDetail result = service.reviewFinanceFeeSource(88L, 71L, "review-key",
new FinancePhase1Dtos.ReviewRequest("APPROVE", "核对无误", 0));
@@ -311,6 +290,51 @@ class FinancePhase1OperationBehaviorTest {
verify(jdbc).update(startsWith("UPDATE fin_fee_source SET review_status="), eq("APPROVED"), eq("核对无误"), eq(88L), eq(0));
}
/** 财务内部应收应付调整原费用记录,重复提交不能新增费用或重复记审计。 */
@ParameterizedTest
@ValueSource(strings = {"RECEIVABLE", "PAYABLE"})
void manualAdjustmentUpdatesOriginalFeeOnceAndChecksVersionAndFrozenState(String direction) throws Exception {
var jdbc = mock(JdbcTemplate.class);
var service = spy(new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class)));
var beforeJson = objectMapper().createObjectNode().put("feeSourceId", 88).put("sourceSystem", "FINANCE")
.put("sourceType", "MANUAL").put("sourceFeeId", 700).put("sourceVersion", 1).put("customerId", 9)
.put("direction", direction).put("amount", 100).put("currency", "CNY").put("operationVersion", 2)
.put("reviewStatus", "APPROVED").put("sourceStatus", "AVAILABLE").put("statementGenerated", false);
var before = objectMapper().treeToValue(beforeJson, FeeSourceDetail.class);
var after = objectMapper().treeToValue(beforeJson.deepCopy().put("amount", 120)
.put("operationVersion", 3).put("reviewStatus", "DRAFT"), FeeSourceDetail.class);
doReturn(before, after).when(service).getFinanceFeeSource(88L);
lenient().when(jdbc.update(startsWith("UPDATE fin_fee_source SET amount="), eq(new BigDecimal("120.00")), eq(88L), eq(2), eq("APPROVED")))
.thenReturn(1, 0);
var request = new FinancePhase1Dtos.FeeSourceEditRequest(new BigDecimal("120.00"), "调整金额", 2);
var result = service.editFinanceFeeSource(88, 71, "adjust-once", request, true);
assertThat(result.feeSourceId()).isEqualTo(88);
assertThat(result.sourceFeeId()).isEqualTo(700);
assertThat(result.direction()).isEqualTo(direction);
assertThat(result.amount()).isEqualByComparingTo("120");
assertThat(result.operationVersion()).isEqualTo(3);
var hash = org.mockito.ArgumentCaptor.forClass(String.class);
verify(jdbc).update(startsWith("INSERT INTO fin_idempotency_record"), eq(9L), eq(71L),
eq("FEE_SOURCE_ADJUST"), eq("adjust-once"), hash.capture());
lenient().when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("FEE_SOURCE_ADJUST"), eq("adjust-once")))
.thenReturn(List.of(java.util.Map.of("request_hash", hash.getValue(), "status", "SUCCEEDED", "result_resource_id", 88L)));
assertThat(service.editFinanceFeeSource(88, 71, "adjust-once", request, true)).isEqualTo(after);
verify(jdbc, times(1)).update(startsWith("UPDATE fin_fee_source SET amount="), eq(new BigDecimal("120.00")), eq(88L), eq(2), eq("APPROVED"));
verify(jdbc, times(1)).update(startsWith("INSERT INTO fin_audit_log"), eq(9L), eq("FIN_FEE_SOURCE"), eq(88L),
eq("FEE_SOURCE_ADJUST"), eq("调整金额"), anyString(), anyString(), eq(71L), anyString(), any(), any());
verify(jdbc, never()).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class));
assertThatThrownBy(() -> service.editFinanceFeeSource(88, 71, "adjust-once",
new FinancePhase1Dtos.FeeSourceEditRequest(new BigDecimal("121.00"), "调整金额", 2), true))
.isInstanceOf(FinanceEventException.class).hasMessageContaining("Idempotency-Key已用于其他请求");
assertThatThrownBy(() -> service.editFinanceFeeSource(88, 71, "stale-version", request, true))
.isInstanceOf(FinanceEventException.class).hasMessageContaining("状态或版本");
doReturn(objectMapper().treeToValue(beforeJson.deepCopy().put("statementGenerated", true), FeeSourceDetail.class))
.when(service).getFinanceFeeSource(88L);
assertThatThrownBy(() -> service.editFinanceFeeSource(88, 71, "frozen", request, true))
.isInstanceOf(FinanceEventException.class).hasMessageContaining("已生成单据");
}
/** 准备指定来源版本的费用查询,不再模拟已迁入 Mapper 的审计查询。 */
private static JdbcTemplate feeSourceJdbc(String reviewStatus, boolean included, int version) throws Exception {
JdbcTemplate jdbc = mock(JdbcTemplate.class);
when(jdbc.query(startsWith("SELECT * FROM fin_fee_source"), any(RowMapper.class), (Object[]) any())).thenAnswer(invocation -> {
@@ -318,7 +342,6 @@ class FinancePhase1OperationBehaviorTest {
return List.of(mapper.mapRow(resultSet(reviewStatus, included, version), 0));
});
when(jdbc.queryForObject(startsWith("SELECT COUNT(*)>0"), eq(Boolean.class), eq(88L), eq(3))).thenReturn(false);
when(jdbc.query(startsWith("SELECT created_at,action"), any(RowMapper.class), eq(9L), eq(88L))).thenReturn(List.of());
lenient().when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L),
eq("FEE_SOURCE_SETTLEMENT"), eq("include-key"))).thenReturn(List.of());
return jdbc;