修复费用记录、装柜完成及海外履约资料
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user