This commit is contained in:
2026-08-11 18:06:26 +08:00
parent a02dd3a422
commit 192964419f
101 changed files with 8892 additions and 1952 deletions
@@ -0,0 +1,25 @@
package com.baiyue.firstleg.client;
import org.junit.jupiter.api.Test;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.RequestHeader;
import java.util.Arrays;
import static org.assertj.core.api.Assertions.assertThat;
class FinanceFeeSourceEventClientContractTest {
@Test
void usesFrozenFinanceInboxPathAndIdempotencyHeaders() {
FeignClient client = FinanceFeeSourceEventClient.class.getAnnotation(FeignClient.class);
assertThat(client.name()).isEqualTo("finance-service");
assertThat(client.path()).isEqualTo("/internal/finance/v1/fee-source-events");
var method = FinanceFeeSourceEventClient.class.getDeclaredMethods()[0];
assertThat(Arrays.stream(method.getParameterAnnotations())
.flatMap(Arrays::stream)
.filter(RequestHeader.class::isInstance)
.map(RequestHeader.class::cast)
.map(RequestHeader::value))
.contains("X-Internal-Service-Token", "Idempotency-Key");
}
}
@@ -0,0 +1,54 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.service.FinanceFeeSourceSnapshotService;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
class InternalFinanceFeeSourceControllerTest {
private final FinanceFeeSourceSnapshotService service = mock(FinanceFeeSourceSnapshotService.class);
private final InternalFinanceFeeSourceController controller = controller();
@Test
void rejectsMissingOrWrongInternalTokenBeforeSnapshotLookup() {
assertThatThrownBy(() -> controller.inbound(null, 21L, 31L))
.isInstanceOf(BizException.class).hasMessageContaining("令牌无效");
assertThatThrownBy(() -> controller.container("wrong", 41L, 51L))
.isInstanceOf(BizException.class).hasMessageContaining("令牌无效");
}
@Test
void routesAuthorizedSnapshotLookup() {
controller.inbound("finance-token", 21L, 31L);
controller.container("finance-token", 41L, 51L);
verify(service).inbound(21L, 31L);
verify(service).container(41L, 51L);
}
@Test
void exposesFrozenInternalPaths() {
var mapping = InternalFinanceFeeSourceController.class
.getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class);
assertThat(mapping.value()).containsExactly("/internal/firstleg/v1");
assertThat(methodPath("inbound")).isEqualTo("/inbounds/{inboundId}/fees/{feeId}/source-snapshot");
assertThat(methodPath("container")).isEqualTo("/containers/{containerId}/fees/{feeId}/source-snapshot");
}
private InternalFinanceFeeSourceController controller() {
InternalFinanceFeeSourceController value = new InternalFinanceFeeSourceController(service);
ReflectionTestUtils.setField(value, "expectedToken", "finance-token");
return value;
}
private static String methodPath(String name) {
return java.util.Arrays.stream(InternalFinanceFeeSourceController.class.getDeclaredMethods())
.filter(method -> method.getName().equals(name)).findFirst().orElseThrow()
.getAnnotation(org.springframework.web.bind.annotation.GetMapping.class).value()[0];
}
}
@@ -8,6 +8,8 @@ import org.apache.ibatis.annotations.Select;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
@@ -18,6 +20,9 @@ class PdaConvergenceSqlContractTest {
.getContentAsString(StandardCharsets.UTF_8);
assertThat(sql)
.contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_assignment")
.contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_item_assignment")
.contains("assignment_type VARCHAR(16) NOT NULL COMMENT '分配类型:CLAIMED作业员领取、ASSIGNED管理员分配'")
.contains("CONSTRAINT fk_loading_pick_item_assignment_item")
.contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_review")
.contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_operation")
.contains("UNIQUE KEY uk_loading_pick_operation_id")
@@ -30,6 +35,11 @@ class PdaConvergenceSqlContractTest {
.contains("platform:firstleg:pda:assign")
.contains("platform:firstleg:pda:work")
.contains("platform:firstleg:pda:review");
String unified = Files.readString(Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql"),
StandardCharsets.UTF_8);
assertThat(createBlock(unified, "fl_loading_pick_item_assignment"))
.isEqualTo(createBlock(sql, "fl_loading_pick_item_assignment"));
}
@Test
@@ -51,4 +61,13 @@ class PdaConvergenceSqlContractTest {
.contains("ON DUPLICATE KEY UPDATE")
.contains("柜次PDA自动汇总");
}
private static String createBlock(String sql, String table) {
String marker = "CREATE TABLE IF NOT EXISTS " + table + " (";
int start = sql.indexOf(marker);
int end = sql.indexOf(";", start);
assertThat(start).as("missing table %s", table).isGreaterThanOrEqualTo(0);
assertThat(end).as("missing table terminator %s", table).isGreaterThan(start);
return sql.substring(start, end + 1).replace("\r\n", "\n");
}
}
@@ -0,0 +1,22 @@
package com.baiyue.firstleg.repository;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
class FinanceFeeSourceMigrationContractTest {
@Test
void migrationDefinesIdempotentOutboxEnvelopeAndContainerFeeModel() throws Exception {
String sql = new ClassPathResource("db/finance-fee-source-migration.sql")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(sql).contains("information_schema.columns", "event_key", "payload_hash",
"uk_inbound_outbox_event_key", "next_retry_at", "locked_at", "locked_by",
"idx_inbound_outbox_finance_relay", "CREATE TABLE IF NOT EXISTS fl_container_fee_detail",
"settlement_included", "source_version", "RECEIVABLE", "PAYABLE",
"chk_container_fee_currency", "currency = 'CNY'", "firstleg_finance_assert_schema");
}
}
@@ -5,11 +5,21 @@ import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static org.assertj.core.api.Assertions.assertThat;
class Phase3FinalUnifiedSqlContractTest {
private static final Path UNIFIED = Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql");
private static final Pattern CREATE_TABLE = Pattern.compile(
"(?i)CREATE\\s+TABLE\\s+IF\\s+NOT\\s+EXISTS\\s+(?:`[^`]+`\\.)?`?([a-zA-Z0-9_]+)`?\\s*\\(");
private static final Pattern COLUMN = Pattern.compile(
"(?i)^\\s*`?([a-zA-Z0-9_]+)`?\\s+(bigint|binary|bit|blob|boolean|char|date|datetime|decimal|double|enum|float|int|integer|json|longblob|longtext|mediumblob|mediumint|mediumtext|numeric|real|set|smallint|text|time|timestamp|tinyblob|tinyint|tinytext|varbinary|varchar|year)\\b");
private static final Pattern CHINESE_COMMENT = Pattern.compile(
"(?i)\\bCOMMENT\\s*=?\\s*'(?:(?:''|\\\\'|[^'])*[\\u3400-\\u9fff](?:''|\\\\'|[^'])*)'");
@Test
void unifiedEntryOwnsPhase3OutboundCustomsPortAndEvidenceHardening() throws Exception {
@@ -27,6 +37,10 @@ class Phase3FinalUnifiedSqlContractTest {
.contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_assignment")
.contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_review")
.contains("CREATE TABLE IF NOT EXISTS fl_loading_pick_operation")
.contains("CREATE TABLE IF NOT EXISTS fl_inbound_split_detail")
.contains("trg_inbound_split_detail_before_insert")
.contains("trg_inbound_split_detail_before_update")
.contains("trg_inbound_split_detail_before_delete")
.contains("CHECK (action_type IN ('CLAIM', 'ASSIGN', 'ITEM_CLAIM', 'ITEM_ASSIGN', 'REVIEW', 'BATCH'))")
.contains("phase3_loading_evidence_add_column")
.contains("DROP INDEX uk_loading_evidence_type")
@@ -74,6 +88,86 @@ class Phase3FinalUnifiedSqlContractTest {
"iam/src/main/resources/db/phase3-warehouse-roles.sql");
}
@Test
void financeFeeSourceMigrationsAreContiguousAndSynchronised() throws Exception {
String unified = normalise(Files.readString(resolveUnified()));
assertBlock(unified, "-- 来源:firstleg/src/main/resources/db/finance-fee-source-migration.sql",
"-- 来源:finance/src/main/resources/db/fee-source-event-migration.sql",
"firstleg/src/main/resources/db/finance-fee-source-migration.sql");
assertBlock(unified, "-- 来源:finance/src/main/resources/db/fee-source-event-migration.sql",
"-- END FINANCE FEE SOURCE MODULE MIGRATIONS",
"finance/src/main/resources/db/fee-source-event-migration.sql");
assertThat(unified)
.contains("column_name IN ('source_type','source_no','direction','container_id','fee_item_code',")
.contains("column_name IN ('payload_hash','result_resource_id','error_code')")
.contains("column_name IN ('event_key', 'event_version', 'aggregate_sequence', 'payload_hash',")
.contains("CREATE TABLE IF NOT EXISTS fl_container_fee_detail");
}
@Test
void commentUpgradeIsIdempotentAndGuardsNineSchemaStructure() throws Exception {
String sql = normalise(Files.readString(resolveUnified()));
assertThat(sql)
.contains("CREATE PROCEDURE queue_table_comment")
.contains("CREATE PROCEDURE queue_column_comment")
.contains("CREATE PROCEDURE apply_one_table_comments")
.contains("CREATE PROCEDURE apply_queued_comments")
.contains("NOT (BINARY v_current_comment=BINARY p_column_comment)")
.contains("SET v_signature_before=SHA2")
.contains("BINARY target.signature_before")
.contains("字段注释升级遇到未知数据类型")
.contains("字段注释升级遇到未知EXTRA定义")
.contains("字段注释升级遇到未知生成列结构")
.contains("MySQL DDL 会隐式提交")
.contains("不使用 GROUP_CONCAT")
.contains("DECLARE table_cursor CURSOR FOR")
.contains("CALL apply_one_table_comments(v_schema_name,v_table_name)")
.contains("CALL apply_queued_comments();")
.contains("PREPARE table_comment_batch_stmt FROM @table_comment_batch_sql")
.contains("九个业务数据库未完整创建")
.contains("存在缺失中文注释的业务表")
.contains("存在缺失中文注释的业务字段")
.doesNotContain("CREATE PROCEDURE upgrade_column_comment");
assertThat(count(sql, "CALL queue_table_comment(")).isEqualTo(150);
assertThat(count(sql, "CALL queue_column_comment(")).isEqualTo(2456);
assertThat(sql.indexOf("CALL apply_queued_comments();",
sql.indexOf("-- END GENERATED NINE-SCHEMA COMMENT UPGRADE CALLS")))
.isGreaterThan(sql.indexOf("-- END GENERATED NINE-SCHEMA COMMENT UPGRADE CALLS"));
}
@Test
void unifiedCreateDefinitionsHaveChineseTableAndColumnComments() throws Exception {
String sql = normalise(Files.readString(resolveUnified()));
List<String> issues = new ArrayList<>();
Matcher tableMatcher = CREATE_TABLE.matcher(sql);
int tableCount = 0;
int columnCount = 0;
while (tableMatcher.find()) {
tableCount++;
String table = tableMatcher.group(1);
int open = tableMatcher.end() - 1;
int close = matchingParen(sql, open);
int semicolon = sql.indexOf(';', close);
assertThat(semicolon).as("missing semicolon for %s", table).isGreaterThan(close);
if (!CHINESE_COMMENT.matcher(sql.substring(close + 1, semicolon)).find()) {
issues.add(table + ": table comment");
}
for (String definition : splitDefinitions(sql.substring(open + 1, close))) {
Matcher columnMatcher = COLUMN.matcher(definition);
if (!columnMatcher.find()) continue;
columnCount++;
if (!CHINESE_COMMENT.matcher(definition).find()) {
issues.add(table + "." + columnMatcher.group(1));
}
}
}
assertThat(tableCount).isGreaterThan(100);
assertThat(columnCount).isGreaterThan(2000);
assertThat(issues).isEmpty();
}
@Test
void passIsUniqueAndOnlyTheFinalExecutableStatement() throws Exception {
String sql = normalise(Files.readString(resolveUnified()));
@@ -153,4 +247,51 @@ class Phase3FinalUnifiedSqlContractTest {
private static int count(String value, String token) {
return value.split(java.util.regex.Pattern.quote(token), -1).length - 1;
}
private static int matchingParen(String sql, int open) {
int depth = 0;
char quote = 0;
for (int index = open; index < sql.length(); index++) {
char current = sql.charAt(index);
if (quote != 0) {
if (current == '\\') index++;
else if (current == quote) {
if (index + 1 < sql.length() && sql.charAt(index + 1) == quote) index++;
else quote = 0;
}
continue;
}
if (current == '\'' || current == '"' || current == '`') quote = current;
else if (current == '(') depth++;
else if (current == ')' && --depth == 0) return index;
}
throw new AssertionError("unclosed CREATE TABLE at " + open);
}
private static List<String> splitDefinitions(String body) {
List<String> definitions = new ArrayList<>();
int start = 0;
int depth = 0;
char quote = 0;
for (int index = 0; index < body.length(); index++) {
char current = body.charAt(index);
if (quote != 0) {
if (current == '\\') index++;
else if (current == quote) {
if (index + 1 < body.length() && body.charAt(index + 1) == quote) index++;
else quote = 0;
}
continue;
}
if (current == '\'' || current == '"' || current == '`') quote = current;
else if (current == '(') depth++;
else if (current == ')') depth--;
else if (current == ',' && depth == 0) {
definitions.add(body.substring(start, index));
start = index + 1;
}
}
definitions.add(body.substring(start));
return definitions;
}
}
@@ -0,0 +1,114 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos;
import com.baiyue.firstleg.entity.ContainerFeeDetailEntity;
import com.baiyue.firstleg.entity.FeeItemEntity;
import com.baiyue.firstleg.entity.LoadingPlanEntity;
import com.baiyue.firstleg.entity.LoadingPlanItemEntity;
import com.baiyue.firstleg.repository.ContainerFeeDetailRepository;
import com.baiyue.firstleg.repository.FeeItemRepository;
import com.baiyue.firstleg.repository.SmartLoadingRepository;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class ContainerFeeDetailServiceTest {
private final ContainerFeeDetailRepository repository = mock(ContainerFeeDetailRepository.class);
private final SmartLoadingRepository loading = mock(SmartLoadingRepository.class);
private final FeeItemRepository feeItems = mock(FeeItemRepository.class);
private final FinanceFeeSourceOutboxService outbox = mock(FinanceFeeSourceOutboxService.class);
private final ContainerFeeDetailService service =
new ContainerFeeDetailService(repository, loading, feeItems, outbox);
@Test
void rejectsContainerThatDoesNotContainCustomerOrders() {
LoadingPlanEntity plan = new LoadingPlanEntity();
plan.setId(41L);
when(loading.plan(41L)).thenReturn(plan);
LoadingPlanItemEntity other = new LoadingPlanItemEntity();
other.setAccountId(20002L);
when(loading.items(41L)).thenReturn(List.of(other));
assertThatThrownBy(() -> service.create(10001L, 41L, 9L, request(true)))
.isInstanceOf(BizException.class).hasMessageContaining("不属于该客户");
verify(repository, never()).insert(any());
}
@Test
void approvedSettlementFeePublishesContainerSource() {
ownedPlan();
ContainerFeeDetailEntity approved = new ContainerFeeDetailEntity();
approved.setId(51L);
approved.setTenantId(10001L);
approved.setLoadingPlanId(41L);
approved.setSourceVersion(1);
approved.setSettlementIncluded(true);
when(repository.find(10001L, 41L, 51L)).thenReturn(approved);
when(repository.review(10001L, 41L, 51L, "APPROVED", 9L, "admin", null)).thenReturn(1);
service.approve(10001L, 41L, 51L, 9L, "admin");
verify(outbox).publishContainer(eq(FinanceFeeSourceOutboxService.PUBLISHED),
eq(10001L), eq(41L), eq(51L), eq(1), any(LocalDateTime.class));
}
@Test
void adjustmentIncrementsSourceVersionAndReturnsToReview() {
ownedPlan();
FeeItemEntity item = item();
when(feeItems.findItem(11L)).thenReturn(item);
ContainerFeeDetailEntity current = new ContainerFeeDetailEntity();
current.setId(51L);
current.setTenantId(10001L);
current.setLoadingPlanId(41L);
current.setSourceVersion(1);
when(repository.find(10001L, 41L, 51L)).thenReturn(current);
when(repository.adjust(any(), eq(1))).thenReturn(1);
service.adjust(10001L, 41L, 51L, 1, 9L, request(true));
ArgumentCaptor<ContainerFeeDetailEntity> captor = ArgumentCaptor.forClass(ContainerFeeDetailEntity.class);
verify(repository).adjust(captor.capture(), eq(1));
assertThat(captor.getValue().getSourceVersion()).isEqualTo(2);
assertThat(captor.getValue().getReviewStatus()).isEqualTo("PENDING");
}
private void ownedPlan() {
LoadingPlanEntity plan = new LoadingPlanEntity();
plan.setId(41L);
when(loading.plan(41L)).thenReturn(plan);
LoadingPlanItemEntity item = new LoadingPlanItemEntity();
item.setAccountId(10001L);
when(loading.items(41L)).thenReturn(List.of(item));
}
private static FeeItemEntity item() {
FeeItemEntity value = new FeeItemEntity();
value.setId(11L);
value.setFeeCode("TRUCKING");
value.setFeeTypeName("拖车费");
value.setWarehousePayable(true);
value.setStatus("ACTIVE");
return value;
}
private static ContainerFeeDetailDtos.SaveRequest request(boolean settlementIncluded) {
return new ContainerFeeDetailDtos.SaveRequest(11L, "PAYABLE",
LocalDate.of(2026, 8, 11), new BigDecimal("300.00"), "CNY",
settlementIncluded, "整柜拖车费");
}
}
@@ -42,6 +42,7 @@ import static org.mockito.Mockito.when;
@DisplayName("FBA费用状态机")
@SpringJUnitConfig(classes = {FbaPricingService.class, FbaPricingServiceTest.TestConfig.class})
class FbaPricingServiceTest {
@MockBean private FinanceFeeSourceOutboxService financeOutbox;
private static final long TENANT = 10001L;
private static final long OPERATOR = 20001L;
private static final long INBOUND = 30001L;
@@ -224,7 +225,9 @@ class FbaPricingServiceTest {
assertThat(first.nextProcessStatus()).isEqualTo("WAIT_PALLET");
assertThat(repeated.nextProcessStatus()).isEqualTo("WAIT_PALLET");
verify(repository, times(1)).insertOutbox(any());
verify(financeOutbox, times(1)).publishInbound(
eq(FinanceFeeSourceOutboxService.PUBLISHED), eq(TENANT), eq(INBOUND),
eq(501L), eq(1), eq(NOW));
verify(repository, times(1)).sendFee(TENANT, 501L, OPERATOR, NOW);
verify(feeDetails, times(2)).publishFreight(TENANT, INBOUND, 501L, OPERATOR, null, NOW);
}
@@ -250,7 +253,8 @@ class FbaPricingServiceTest {
assertThat(response.nextProcessStatus()).isEqualTo("INBOUNDED");
verify(repository).markInboundFeeSent(TENANT, INBOUND, "INBOUNDED");
verify(repository).insertOutbox(any());
verify(financeOutbox).publishInbound(eq(FinanceFeeSourceOutboxService.PUBLISHED),
eq(TENANT), eq(INBOUND), eq(501L), eq(1), eq(NOW));
}
@Test
@@ -300,7 +304,8 @@ class FbaPricingServiceTest {
assertThat(response.nextProcessStatus()).isEqualTo("INBOUNDED");
assertThat(inbound.getProcessStatus()).isEqualTo("INBOUNDED");
verify(repository).supersedeSent(TENANT, 501L);
verify(repository).insertOutbox(any());
verify(financeOutbox).publishInbound(eq(FinanceFeeSourceOutboxService.ADJUSTED),
eq(TENANT), eq(INBOUND), eq(502L), eq(2), eq(NOW));
}
private void readyRepository() {
@@ -27,6 +27,7 @@ import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
@@ -40,6 +41,8 @@ class FeeManagementServiceTest {
private FeeItemRepository repository;
@MockBean
private FbaPricingRepository pricingRepository;
@MockBean
private FinanceFeeSourceOutboxService financeOutbox;
@Resource
private FeeItemService feeItems;
@Resource
@@ -268,6 +271,54 @@ class FeeManagementServiceTest {
verify(repository).review(10001L, 21L, 31L, "APPROVED", 9L, "admin", null);
}
@Test
@DisplayName("审核通过且参与结算的人工费用写入Finance事件")
void publishesApprovedSettlementFee() {
InboundFeeDetailEntity approved = new InboundFeeDetailEntity();
approved.setId(31L);
approved.setTenantId(10001L);
approved.setInboundId(21L);
approved.setFeeItemId(11L);
approved.setSourceType("MANUAL");
approved.setSourceFeeVersion(1);
approved.setSettlementIncluded(true);
when(repository.findDetail(10001L, 21L, 31L)).thenReturn(approved);
when(repository.review(10001L, 21L, 31L, "APPROVED", 9L, "admin", null)).thenReturn(1);
details.approve(10001L, 21L, 31L, 9L, "admin");
verify(financeOutbox).publishInbound(eq(FinanceFeeSourceOutboxService.PUBLISHED),
eq(10001L), eq(21L), eq(31L), eq(1), any(LocalDateTime.class));
}
@Test
@DisplayName("作废已入池人工费用引用当前版本并发布VOIDED")
void voidsPublishedSettlementFee() {
InboundFeeDetailEntity current = new InboundFeeDetailEntity();
current.setId(31L);
current.setTenantId(10001L);
current.setInboundId(21L);
current.setFeeItemId(11L);
current.setSourceType("MANUAL");
current.setSourceFeeVersion(1);
current.setSettlementIncluded(true);
InboundFeeDetailEntity voided = new InboundFeeDetailEntity();
voided.setId(31L);
voided.setTenantId(10001L);
voided.setInboundId(21L);
voided.setFeeItemId(11L);
voided.setSourceType("MANUAL");
voided.setSourceFeeVersion(1);
voided.setSettlementIncluded(true);
when(repository.findDetail(10001L, 21L, 31L)).thenReturn(current, voided);
when(repository.voidApproved(10001L, 21L, 31L, 1, 9L)).thenReturn(1);
details.voidApproved(10001L, 21L, 31L, 1, 9L);
verify(financeOutbox).publishInbound(eq(FinanceFeeSourceOutboxService.VOIDED),
eq(10001L), eq(21L), eq(31L), eq(1), any(LocalDateTime.class));
}
private static FeeItemDtos.SaveRequest itemRequest() {
return new FeeItemDtos.SaveRequest("仓储成本", "INTERNATIONAL", "仓库租赁费",
false, false, true, true, true, "应付仓库租赁费");
@@ -0,0 +1,135 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.firstleg.client.FinanceFeeSourceEventClient;
import com.baiyue.firstleg.dto.FinanceFeeSourceEvent;
import com.baiyue.firstleg.entity.InboundOutboxEntity;
import com.baiyue.firstleg.mapper.InboundOutboxMapper;
import com.baiyue.firstleg.repository.FinanceFeeSourceOutboxRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class FinanceFeeSourceOutboxRelayTest {
private static final LocalDateTime NOW = LocalDateTime.of(2026, 8, 11, 15, 0);
private final ObjectMapper json = new ObjectMapper().registerModule(new JavaTimeModule());
private final FinanceFeeSourceOutboxRepository repository = mock(FinanceFeeSourceOutboxRepository.class);
private final FinanceFeeSourceEventClient client = mock(FinanceFeeSourceEventClient.class);
private final FinanceFeeSourceOutboxRelay relay = relay();
@Test
void claimsThenPublishesOnlyAfterFinanceAcknowledgesSameEvent() throws Exception {
InboundOutboxEntity row = event();
ready(row);
FinanceFeeSourceEvent envelope = json.readValue(row.getPayloadJson(), FinanceFeeSourceEvent.class);
when(client.receive("finance-token", envelope.idempotencyKey(), envelope)).thenReturn(
ApiResponse.success(new FinanceFeeSourceEventClient.Acknowledgement(
901L)));
when(repository.published(row.getId(), "worker-1", NOW)).thenReturn(true);
relay.relay();
verify(repository).recoverStale(NOW.minusMinutes(5), NOW.plusSeconds(10), 12);
verify(repository).claim(row.getId(), "worker-1", NOW);
verify(client).receive("finance-token", envelope.idempotencyKey(), envelope);
verify(repository).published(row.getId(), "worker-1", NOW);
verify(repository, never()).retry(any(Long.class), any(), any(), any(), eq(false));
}
@Test
void concurrentWorkerThatLosesClaimDoesNotDeliver() {
InboundOutboxEntity row = event();
when(repository.pending(50, NOW)).thenReturn(List.of(row));
when(repository.claim(row.getId(), "worker-1", NOW)).thenReturn(false);
relay.relay();
verify(client, never()).receive(any(), any(), any());
verify(repository, never()).published(any(Long.class), any(), any());
}
@Test
void transientFailureReturnsClaimToPendingWithBackoff() throws Exception {
InboundOutboxEntity row = event();
ready(row);
FinanceFeeSourceEvent envelope = json.readValue(row.getPayloadJson(), FinanceFeeSourceEvent.class);
when(client.receive("finance-token", envelope.idempotencyKey(), envelope))
.thenThrow(new IllegalStateException("finance unavailable"));
relay.relay();
verify(repository).retry(row.getId(), "worker-1", NOW.plusSeconds(10),
"finance unavailable", false);
verify(repository, never()).published(any(Long.class), any(), any());
}
@Test
void invalidEventHashFailsPermanentlyWithoutCallingFinance() {
InboundOutboxEntity row = event();
row.setPayloadHash("0".repeat(64));
ready(row);
relay.relay();
verify(repository).retry(eq(row.getId()), eq("worker-1"), eq(NOW.plusSeconds(10)),
any(), eq(true));
verify(client, never()).receive(any(), any(), any());
}
@Test
void financeIdempotencyConflictFailsPermanently() throws Exception {
InboundOutboxEntity row = event();
ready(row);
FinanceFeeSourceEvent envelope = json.readValue(row.getPayloadJson(), FinanceFeeSourceEvent.class);
when(client.receive("finance-token", envelope.idempotencyKey(), envelope)).thenReturn(
new ApiResponse<>("FIN-EVT-002", "同一事件ID对应不同请求内容", null, "r", "t", Instant.now()));
relay.relay();
verify(repository).retry(eq(row.getId()), eq("worker-1"), eq(NOW.plusSeconds(10)),
any(), eq(true));
verify(repository, never()).published(any(Long.class), any(), any());
}
private void ready(InboundOutboxEntity row) {
when(repository.pending(50, NOW)).thenReturn(List.of(row));
when(repository.claim(row.getId(), "worker-1", NOW)).thenReturn(true);
when(repository.findClaimed(row.getId(), "worker-1")).thenReturn(row);
}
private InboundOutboxEntity event() {
InboundOutboxMapper mapper = mock(InboundOutboxMapper.class);
FinanceFeeSourceOutboxService producer = new FinanceFeeSourceOutboxService(mapper, json);
producer.publishInbound(FinanceFeeSourceOutboxService.PUBLISHED,
10001L, 21L, 31L, 1, NOW);
ArgumentCaptor<InboundOutboxEntity> captor = ArgumentCaptor.forClass(InboundOutboxEntity.class);
verify(mapper).insert(captor.capture());
InboundOutboxEntity row = captor.getValue();
row.setId(71L);
return row;
}
private FinanceFeeSourceOutboxRelay relay() {
Clock clock = Clock.fixed(Instant.parse("2026-08-11T07:00:00Z"), ZoneId.of("Asia/Shanghai"));
FinanceFeeSourceOutboxRelay value =
new FinanceFeeSourceOutboxRelay(repository, client, json, clock, "worker-1");
ReflectionTestUtils.setField(value, "internalToken", "finance-token");
return value;
}
}
@@ -0,0 +1,52 @@
package com.baiyue.firstleg.service;
import com.baiyue.firstleg.entity.InboundOutboxEntity;
import com.baiyue.firstleg.mapper.InboundOutboxMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.dao.DuplicateKeyException;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
class FinanceFeeSourceOutboxServiceTest {
private final InboundOutboxMapper mapper = mock(InboundOutboxMapper.class);
private final ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule());
private final FinanceFeeSourceOutboxService service =
new FinanceFeeSourceOutboxService(mapper, objectMapper);
@Test
void writesContractEnvelopeWithShortPayloadAndStableHash() {
LocalDateTime occurredAt = LocalDateTime.of(2026, 8, 11, 12, 0);
service.publishInbound(FinanceFeeSourceOutboxService.PUBLISHED,
10001L, 21L, 31L, 1, occurredAt);
ArgumentCaptor<InboundOutboxEntity> captor = ArgumentCaptor.forClass(InboundOutboxEntity.class);
verify(mapper).insert(captor.capture());
InboundOutboxEntity event = captor.getValue();
assertThat(event.getEventType()).isEqualTo("FIRSTLEG_FEE_PUBLISHED");
assertThat(event.getEventVersion()).isEqualTo(1);
assertThat(event.getAggregateSequence()).isEqualTo(1);
assertThat(event.getPayloadHash()).matches("[0-9a-f]{64}");
assertThat(event.getPayloadJson()).contains("\"producer\":\"firstleg-service\"",
"\"inboundId\":\"21\"", "\"feeId\":\"31\"", "\"feeVersion\":1")
.doesNotContain("amount", "currency");
}
@Test
void treatsDuplicateEventKeyAsSuccessfulReplay() {
doThrow(new DuplicateKeyException("duplicate event_key")).when(mapper)
.insert((InboundOutboxEntity) org.mockito.ArgumentMatchers.any());
assertThatCode(() -> service.publishContainer(FinanceFeeSourceOutboxService.VOIDED,
10001L, 41L, 51L, 2, LocalDateTime.now())).doesNotThrowAnyException();
}
}
@@ -0,0 +1,71 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.FinanceFeeSourceSnapshot;
import com.baiyue.firstleg.entity.FeeItemEntity;
import com.baiyue.firstleg.entity.InboundFeeDetailEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.repository.ContainerFeeDetailRepository;
import com.baiyue.firstleg.repository.FeeItemRepository;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class FinanceFeeSourceSnapshotServiceTest {
private final FeeItemRepository inboundFees = mock(FeeItemRepository.class);
private final ContainerFeeDetailRepository containerFees = mock(ContainerFeeDetailRepository.class);
private final FinanceFeeSourceSnapshotService service =
new FinanceFeeSourceSnapshotService(inboundFees, containerFees);
@Test
void returnsReviewedSettlementSnapshotWithStableSourceHash() {
InboundFeeDetailEntity detail = new InboundFeeDetailEntity();
detail.setId(31L);
detail.setTenantId(10001L);
detail.setInboundId(21L);
detail.setFeeItemId(11L);
detail.setSourceType("MANUAL");
detail.setSourceFeeVersion(2);
detail.setDirection("RECEIVABLE");
detail.setAmount(new BigDecimal("100.00"));
detail.setCurrency("CNY");
detail.setReviewStatus("APPROVED");
detail.setPublicationStatus("PUBLISHED");
detail.setSettlementIncluded(true);
when(inboundFees.findDetail(21L, 31L)).thenReturn(detail);
InboundOrderEntity inbound = new InboundOrderEntity();
inbound.setId(21L);
inbound.setOrderId(81L);
inbound.setInboundCode("RK-21");
inbound.setCustomerName("客户A");
when(inboundFees.findInbound(10001L, 21L)).thenReturn(inbound);
FeeItemEntity item = new FeeItemEntity();
item.setId(11L);
item.setFeeCode("STORAGE");
when(inboundFees.findItem(11L)).thenReturn(item);
FinanceFeeSourceSnapshot snapshot = service.inbound(21L, 31L);
assertThat(snapshot.customerId()).isEqualTo(10001L);
assertThat(snapshot.orderId()).isEqualTo(81L);
assertThat(snapshot.feeVersion()).isEqualTo(2);
assertThat(snapshot.sourceHash()).matches("[0-9a-f]{64}");
detail.setPublicationStatus("VOIDED");
assertThat(service.inbound(21L, 31L).sourceHash()).isEqualTo(snapshot.sourceHash());
}
@Test
void rejectsFeeThatDoesNotBelongToInboundPath() {
InboundFeeDetailEntity detail = new InboundFeeDetailEntity();
detail.setInboundId(22L);
when(inboundFees.findDetail(21L, 31L)).thenReturn(detail);
assertThatThrownBy(() -> service.inbound(21L, 31L))
.isInstanceOf(BizException.class).hasMessageContaining("不存在");
}
}
@@ -25,6 +25,7 @@ import static org.mockito.Mockito.when;
@DisplayName("发送费用并入库状态编排")
@SpringJUnitConfig(InboundCompletionOrchestrator.class)
class InboundCompletionOrchestratorTest {
@MockBean private FinanceFeeSourceOutboxService financeOutbox;
@MockBean
private InboundWorkflowRepository repository;
@MockBean
@@ -78,6 +79,11 @@ class InboundCompletionOrchestratorTest {
fixture.order.setProcessStatus("INBOUNDING");
when(fixture.repository.completeInbound(10001L, 10L, 9L, 5)).thenReturn(1);
when(fixture.repository.markFeeSent(10001L, 10L, 30L, 9L)).thenReturn(1);
com.baiyue.firstleg.entity.InboundFeeDetailEntity published =
new com.baiyue.firstleg.entity.InboundFeeDetailEntity();
published.setSourceFeeVersion(1);
when(feeDetails.publishFreight(eq(10001L), eq(10L), eq(30L), eq(9L), eq("操作员"),
org.mockito.ArgumentMatchers.any(java.time.LocalDateTime.class))).thenReturn(published);
fixture.service.completeSendFeeAndInbound(
10001L, 9L, "操作员", 10L, 30L, 5, "complete-1");
@@ -86,7 +92,9 @@ class InboundCompletionOrchestratorTest {
verify(fixture.repository).markFeeSent(10001L, 10L, 30L, 9L);
verify(feeDetails).publishFreight(eq(10001L), eq(10L), eq(30L), eq(9L), eq("操作员"),
org.mockito.ArgumentMatchers.any(java.time.LocalDateTime.class));
verify(fixture.repository).insertOutbox(any());
verify(financeOutbox).publishInbound(eq(FinanceFeeSourceOutboxService.PUBLISHED),
eq(10001L), eq(10L), eq(30L), eq(1),
org.mockito.ArgumentMatchers.any(java.time.LocalDateTime.class));
}
@Test
@@ -102,7 +110,9 @@ class InboundCompletionOrchestratorTest {
.extracting(error -> ((BizException) error).getResultCode())
.isEqualTo(ResultCode.CONFLICT);
verify(fixture.repository, never()).markFeeSent(anyLong(), anyLong(), anyLong(), anyLong());
verify(fixture.repository, never()).insertOutbox(any());
verify(financeOutbox, never()).publishInbound(any(), anyLong(), anyLong(), anyLong(),
org.mockito.ArgumentMatchers.anyInt(),
org.mockito.ArgumentMatchers.any(java.time.LocalDateTime.class));
}
@Test