This commit is contained in:
2026-08-20 09:43:31 +08:00
parent f25e270f22
commit 7cf4a8d562
25 changed files with 804 additions and 41 deletions
@@ -2,6 +2,8 @@ package com.baiyue.firstleg.controller;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.service.FinanceFeeSourceSnapshotService;
import com.baiyue.firstleg.service.FinanceFeeSourceCommandService;
import com.baiyue.firstleg.dto.FinanceFeeSourceCommandDtos.VoidRequest;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import org.springframework.test.util.ReflectionTestUtils;
@@ -15,6 +17,7 @@ import static org.mockito.Mockito.verify;
class InternalFinanceFeeSourceControllerTest {
private final FinanceFeeSourceSnapshotService service = mock(FinanceFeeSourceSnapshotService.class);
private final FinanceFeeSourceCommandService commands = mock(FinanceFeeSourceCommandService.class);
private final InternalFinanceFeeSourceController controller = controller();
@Test
@@ -34,6 +37,16 @@ class InternalFinanceFeeSourceControllerTest {
verify(service).container(41L, 51L, 3);
}
@Test
void routesAuthorizedVoidCommandWithIdempotencyAndHash() {
VoidRequest request = new VoidRequest("INBOUND_FEE", 31L, 2, 9L,
21L, null, 71L, "finance", "测试作废");
controller.voidSource("finance-token", "void-key", "a".repeat(64), request);
verify(commands).voidSource("void-key", "a".repeat(64), request);
}
@Test
void exposesFrozenInternalPaths() {
var mapping = InternalFinanceFeeSourceController.class
@@ -52,11 +65,14 @@ class InternalFinanceFeeSourceControllerTest {
.contains("/internal/firstleg/v1/containers/{containerId}/fees/{feeId}/source-snapshot:")
.contains("{name: sourceVersion, in: query, required: true")
.contains("supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true")
.contains("为空时仅要求供应商ACTIVE");
.contains("为空时仅要求供应商ACTIVE")
.contains("/internal/firstleg/v1/fee-sources/commands/void:")
.contains("FIRSTLEG_FEE_VOIDED Outbox")
.contains("FinanceFeeSourceVoidCommand:");
}
private InternalFinanceFeeSourceController controller() {
InternalFinanceFeeSourceController value = new InternalFinanceFeeSourceController(service);
InternalFinanceFeeSourceController value = new InternalFinanceFeeSourceController(service, commands);
ReflectionTestUtils.setField(value, "expectedToken", "finance-token");
return value;
}
@@ -37,6 +37,9 @@ class FinanceFeeSourceMigrationContractTest {
"source_version", "supplier_id", "supplier_name", "supplier_category",
"CREATE TABLE IF NOT EXISTS fl_container_fee_source_snapshot",
"uk_container_fee_source_version",
"CREATE TABLE IF NOT EXISTS fl_finance_fee_source_command",
"uk_finance_fee_command_idem",
"request_hash CHAR(64) NOT NULL",
"firstleg_finance_add_column('fl_container_fee_detail', 'supplier_id'", "RECEIVABLE", "PAYABLE",
"firstleg_finance_add_column('fl_inbound_fee_detail', 'supplier_id'",
"Firstleg Finance迁移失败:入库费用供应商快照字段不完整",
@@ -0,0 +1,159 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.FinanceFeeSourceCommandDtos.VoidRequest;
import com.baiyue.firstleg.entity.ContainerFeeDetailEntity;
import com.baiyue.firstleg.entity.FinanceFeeSourceCommandEntity;
import com.baiyue.firstleg.entity.InboundFeeDetailEntity;
import com.baiyue.firstleg.repository.ContainerFeeDetailRepository;
import com.baiyue.firstleg.repository.FbaPricingRepository;
import com.baiyue.firstleg.repository.FeeItemRepository;
import com.baiyue.firstleg.repository.FinanceFeeSourceCommandRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.dao.DuplicateKeyException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
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.anyInt;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class FinanceFeeSourceCommandServiceTest {
@Mock FinanceFeeSourceCommandRepository commands;
@Mock FeeItemRepository inboundFees;
@Mock FbaPricingRepository pricing;
@Mock ContainerFeeDetailRepository containerFees;
@Mock ContainerFeeDetailService containerFeeService;
@Mock FinanceFeeSourceOutboxService outbox;
private ObjectMapper objectMapper;
private FinanceFeeSourceCommandService service;
@BeforeEach
void setUp() {
objectMapper = new ObjectMapper().findAndRegisterModules();
service = new FinanceFeeSourceCommandService(commands, inboundFees, pricing,
containerFees, containerFeeService, outbox, objectMapper);
doAnswer(invocation -> {
FinanceFeeSourceCommandEntity value = invocation.getArgument(0);
value.setId(901L);
return null;
}).when(commands).insert(any());
lenient().when(commands.complete(eq(901L), eq("VOIDED"), any())).thenReturn(1);
}
@Test
void voidsManualInboundBusinessFactAndPublishesEvent() throws Exception {
VoidRequest request = inboundRequest(700L, 2);
InboundFeeDetailEntity detail = inboundDetail("MANUAL", 777L, 700L, 2, 9L);
when(inboundFees.findSourceVersion(100L, 700L, 2)).thenReturn(detail);
when(inboundFees.voidApproved(9L, 100L, 777L, 2, 71L)).thenReturn(1);
var result = service.voidSource("void-key", hash(request), request);
assertThat(result.status()).isEqualTo("VOIDED");
assertThat(result.replayed()).isFalse();
verify(outbox).publishInbound(org.mockito.ArgumentMatchers.eq(FinanceFeeSourceOutboxService.VOIDED),
org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq(100L),
org.mockito.ArgumentMatchers.eq(700L), org.mockito.ArgumentMatchers.eq(2), any());
verify(pricing, never()).voidSentFee(anyLong(), anyLong(), anyLong(), anyInt(), any());
}
@Test
void voidsFbaPricingAndMaterializedInboundDetailAtomically() throws Exception {
VoidRequest request = inboundRequest(700L, 3);
when(inboundFees.findSourceVersion(100L, 700L, 3))
.thenReturn(inboundDetail("FBA_PRICING", 778L, 700L, 3, 9L));
when(pricing.voidSentFee(9L, 100L, 700L, 3, any())).thenReturn(1);
when(pricing.markInboundFeeVoided(9L, 100L)).thenReturn(1);
when(inboundFees.voidApproved(9L, 100L, 778L, 3, 71L)).thenReturn(1);
service.voidSource("void-fba", hash(request), request);
verify(pricing).voidSentFee(9L, 100L, 700L, 3, any());
verify(pricing).markInboundFeeVoided(9L, 100L);
verify(outbox).publishInbound(org.mockito.ArgumentMatchers.eq(FinanceFeeSourceOutboxService.VOIDED),
org.mockito.ArgumentMatchers.eq(9L), org.mockito.ArgumentMatchers.eq(100L),
org.mockito.ArgumentMatchers.eq(700L), org.mockito.ArgumentMatchers.eq(3), any());
}
@Test
void routesContainerVoidThroughExistingBusinessService() throws Exception {
VoidRequest request = new VoidRequest("CONTAINER_FEE", 800L, 4, 9L,
null, 200L, 71L, "finance", "柜次费用作废");
ContainerFeeDetailEntity detail = new ContainerFeeDetailEntity();
detail.setId(800L); detail.setTenantId(9L); detail.setLoadingPlanId(200L);
detail.setSourceVersion(4); detail.setSourceStatus("ACTIVE");
when(containerFees.find(200L, 800L)).thenReturn(detail);
service.voidSource("void-container", hash(request), request);
verify(containerFeeService).voidApproved(9L, 200L, 800L, 4, 71L);
}
@Test
void rejectsCrossCustomerSourceBeforeBusinessMutation() throws Exception {
VoidRequest request = inboundRequest(700L, 2);
when(inboundFees.findSourceVersion(100L, 700L, 2))
.thenReturn(inboundDetail("MANUAL", 777L, 700L, 2, 10L));
assertThatThrownBy(() -> service.voidSource("void-cross", hash(request), request))
.isInstanceOf(BizException.class).hasMessageContaining("指定客户");
verify(inboundFees, never()).voidApproved(anyLong(), anyLong(), anyLong(), anyInt(), anyLong());
verify(outbox, never()).publishInbound(any(), anyLong(), anyLong(), anyLong(), anyInt(), any());
}
@Test
void replaysCompletedCommandWithoutRepeatingBusinessMutation() throws Exception {
VoidRequest request = inboundRequest(700L, 2);
String requestHash = hash(request);
doThrow(new DuplicateKeyException("duplicate")).when(commands).insert(any());
FinanceFeeSourceCommandEntity existing = new FinanceFeeSourceCommandEntity();
existing.setRequestHash(requestHash); existing.setStatus("SUCCEEDED");
existing.setResultStatus("VOIDED");
when(commands.find(9L, 71L, "VOID", "void-replay")).thenReturn(existing);
var result = service.voidSource("void-replay", requestHash, request);
assertThat(result.replayed()).isTrue();
verify(inboundFees, never()).findSourceVersion(anyLong(), anyLong(), anyInt());
}
private VoidRequest inboundRequest(long feeId, int version) {
return new VoidRequest("INBOUND_FEE", feeId, version, 9L,
100L, null, 71L, "finance", "入库费用作废");
}
private static InboundFeeDetailEntity inboundDetail(String sourceType, long id,
long sourceFeeId, int version,
long tenantId) {
InboundFeeDetailEntity value = new InboundFeeDetailEntity();
value.setId(id); value.setTenantId(tenantId); value.setInboundId(100L);
value.setSourceType(sourceType); value.setSourceFeeId(sourceFeeId);
value.setSourceFeeVersion(version); value.setReviewStatus("APPROVED");
value.setPublicationStatus("PUBLISHED");
return value;
}
private String hash(VoidRequest request) throws Exception {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(objectMapper.writeValueAsString(request).getBytes(StandardCharsets.UTF_8)));
}
}