This commit is contained in:
2026-08-14 14:26:56 +08:00
parent 06c264db0a
commit 261a8bf2df
65 changed files with 1040 additions and 670 deletions
@@ -25,6 +25,7 @@ class ContainerControllerContractTest {
assertPermission("updateTransport", "platform:firstleg:container:edit");
assertPermission("uploadClearance", "platform:firstleg:customs:manage");
assertPermission("overview", "platform:firstleg:container:list");
assertPermission("downloadPdaPhoto", "platform:firstleg:container:list");
assertPermission("markException", "platform:firstleg:container:edit");
assertPermission("cancelException", "platform:firstleg:container:edit");
assertPermission("logisticsDocument", "platform:firstleg:container:list");
@@ -38,7 +39,8 @@ class ContainerControllerContractTest {
assertThat(openapi).contains("/api/platform/v1/containers/{id}/overview:",
"/documents/{documentType}/{fileId}/download:", "ExceptionRequest",
"enum: [DELIVERY_ORDER, PICKUP_ORDER]", "Idempotency-Key", "photoCounts:",
"downloadPath:", "version: {type: integer, minimum: 0}")
"/pda/photos/{fileId}/download:", "downloadPath:", "workflowStatus:",
"AWAITING_REVIEW", "version: {type: integer, minimum: 0}")
.doesNotContain("?containerNo", "containerNo in: query");
}
@@ -36,7 +36,7 @@ class FbaPricingContractTest {
}
@Test
@DisplayName("契约声明二期边界计费、已发布费用调整和幂等头")
@DisplayName("契约声明二期边界计费、人工费用调整和幂等头")
void documentsPricingLifecycle() throws Exception {
String openapi = new ClassPathResource("openapi/fba-pricing-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
@@ -53,6 +53,10 @@ class FbaPricingContractTest {
.contains("不再以 carrierId、业务 customerId 或客户计费规则作为前置")
.contains("该动作涉及后续仓储流程,本轮二期验收不调用")
.contains("不回退 WAIT_SPLIT 状态")
.contains("expectedFeeVersion")
.contains("chargeableQuantity")
.contains("unitPrice")
.doesNotContain("required: [subtotalAmount, additionalAmount, reason]")
.doesNotContain("应收")
.doesNotContain("应付")
.doesNotContain("待客户确认");
@@ -1,10 +1,10 @@
package com.baiyue.firstleg.controller;
import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.ExceptionReleaseRequest;
import com.baiyue.firstleg.dto.MeasurementApprovalResponse;
import com.baiyue.firstleg.dto.MeasurementReviewRequest;
import com.baiyue.firstleg.service.AutomaticInboundCompletionService;
import com.baiyue.firstleg.service.InitialInboundPricingService;
import com.baiyue.firstleg.service.MeasurementReviewService;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@@ -46,50 +46,39 @@ class InboundMeasurementReviewContractTest {
.contains("/api/platform/v1/inbounds/{inboundId}/measurements/reject:")
.contains("firstleg:inbound:exception-release")
.contains("Idempotency-Key")
.contains("审核事务提交后服务端自动执行计费")
.contains("接口不会伪造 WAIT_SPLIT")
.contains("成功 data 为 FeeSnapshotResponse")
.contains("nextStatus=WAIT_SPLIT")
.contains("初算异常只返回非阻断提示")
.contains("data.processStatus 固定为 WAIT_SPLIT")
.contains("feeStatus=UNPRICED 且主流程仍成功")
.contains("name: inboundId")
.contains("schema: {type: string, pattern:")
.doesNotContain("待客户确认");
}
@Test
@DisplayName("审核事务完成后才启动自动编排并返回真实费用快照")
void shouldCompleteApprovedReviewAfterReviewTransactionReturns() {
@DisplayName("审核先提交待分柜,再执行不阻塞的初算")
void shouldApproveBeforeBestEffortInitialPricing() {
MeasurementReviewService review = mock(MeasurementReviewService.class);
AutomaticInboundCompletionService completion = mock(AutomaticInboundCompletionService.class);
InitialInboundPricingService initialPricing = mock(InitialInboundPricingService.class);
PlatformInboundMeasurementReviewController controller = new PlatformInboundMeasurementReviewController();
ReflectionTestUtils.setField(controller, "service", review);
ReflectionTestUtils.setField(controller, "automaticCompletion", completion);
ReflectionTestUtils.setField(controller, "initialPricing", initialPricing);
AuthenticatedUser user = new AuthenticatedUser(9L, 0L, 0L, null,
"客服", "PLATFORM", "WEB", Set.of("firstleg:inbound:measurement-review"));
MeasurementReviewRequest request = new MeasurementReviewRequest(30L, 4, "通过");
var fee = mock(com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse.class);
when(completion.completeApproved(10001L, 88L, 9L, "客服", 30L, 5)).thenReturn(fee);
var result = new MeasurementApprovalResponse(88L, 30L, "WAIT_SPLIT", "UNPRICED",
false, "待人工维护费用", null);
when(initialPricing.tryCalculate(10001L, 9L, 88L, 30L, 5)).thenReturn(result);
var first = controller.approve(user, 10001L, 88L, "approve-1", request);
var replay = controller.approve(user, 10001L, 88L, "approve-1", request);
assertThat(first.data()).isSameAs(fee);
assertThat(replay.data()).isSameAs(fee);
var ordered = inOrder(review, completion);
assertThat(first.data()).isSameAs(result);
assertThat(replay.data()).isSameAs(result);
var ordered = inOrder(review, initialPricing);
ordered.verify(review).approve(10001L, 9L, "客服", 88L, "approve-1", request);
ordered.verify(completion).completeApproved(10001L, 88L, 9L, "客服", 30L, 5);
ordered.verify(initialPricing).tryCalculate(10001L, 9L, 88L, 30L, 5);
ordered.verify(review).approve(10001L, 9L, "客服", 88L, "approve-1", request);
ordered.verify(completion).completeApproved(10001L, 88L, 9L, "客服", 30L, 5);
}
@Test
void shouldAuditAutomaticInboundCompletion() throws Exception {
Method method = AutomaticInboundCompletionService.class.getDeclaredMethod("completeApproved",
long.class, long.class, long.class, String.class, long.class, int.class);
BusinessAudit audit = method.getAnnotation(BusinessAudit.class);
assertThat(audit.event()).isEqualTo("INBOUND_AUTO_COMPLETE");
assertThat(audit.resourceType()).isEqualTo("INBOUND");
assertThat(audit.resourceId()).isEqualTo("#inboundId");
assertThat(audit.captureBefore()).isFalse();
ordered.verify(initialPricing).tryCalculate(10001L, 9L, 88L, 30L, 5);
}
private void assertAuthority(String name, String expression, Class<?>... types) throws Exception {
@@ -23,6 +23,12 @@ class PriceBookContractTest {
@DisplayName("固定十五个渠道且海派使用区域重量模型")
void keepsStableChannelCatalog() {
assertThat(PriceBookChannelCatalog.CHANNELS).hasSize(15);
assertThat(PriceBookChannelCatalog.CHANNELS)
.extracting(value -> value.channelCode() + ":" + value.inboundCodePrefix())
.contains("MATSON_CLX_TRUCK:ZBYHK", "OA_LA_FULL_TRUCK:PCKK", "OA_CHICAGO_RAIL:CPHT");
assertThat(PriceBookChannelCatalog.CHANNELS)
.filteredOn(value -> "OA_NEW_YORK_TRUCK".equals(value.channelCode()))
.singleElement().extracting(value -> value.overseasInboundCodePrefix()).isEqualTo("NPHWK");
assertThat(PriceBookChannelCatalog.CHANNELS.stream()
.map(PriceBookChannelCatalog.ChannelDefinition::channelCode))
.containsExactly(
@@ -19,6 +19,7 @@ class TruckAppointmentControllerContractTest {
@Test void exposesDedicatedEndpointAndPermissions() {String p=PlatformTruckAppointmentController.class.getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class).value()[0];assertThat(p).isEqualTo("/api/platform/v1/truck-appointments"); for(String n:new String[]{"page","detail","history","attachments","attach","upload","deleteAttachment","create","edit","action","reschedule"}) assertThat(java.util.Arrays.stream(PlatformTruckAppointmentController.class.getDeclaredMethods()).anyMatch(m->m.getName().equals(n))).isTrue();}
@Test void schemaHasAggregateTablesStatesAndActualEventTimes() throws Exception {String s=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("fl_truck_appointment","fl_truck_appointment_history","fl_truck_appointment_attachment","fl_truck_appointment_operation","PENDING_DISPATCH","PICKUP_IN_PROGRESS","ARRIVED_WAREHOUSE","RESCHEDULED","actual_arrived_warehouse_at","actual_loading_at","actual_departed_at","CALL phase3_truck_add_column");}
@Test void openApiDocumentsLifecycleIdempotencyAndActualTimes() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("/api/platform/v1/truck-appointments/{id}/{action}:","/api/platform/v1/truck-appointments/{id}/reschedule:","name: Idempotency-Key","required: true","actual event timestamps");}
@Test void openApiFreezesAttachmentRequestAndOwnershipContract() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("AttachmentRequest:","required: [fileId]","maxLength: 64","TRUCK_APPOINTMENT_ATTACHMENT","Deletes the appointment reference only","DetailStableReferences:","oceanBookingNo:","shippingCompanyName:","bookingAgentName:","cabinetNo:");}
@Test void methodsHavePlatformPermissionsAndAuthenticatedScope() {
Map<String,String> expected=Map.ofEntries(
Map.entry("page","platform:firstleg:truck-appointment:list"), Map.entry("detail","platform:firstleg:truck-appointment:list"),
@@ -11,10 +11,11 @@ import static org.assertj.core.api.Assertions.assertThat;
class InboundOrderStatusSqlContractTest {
@Test
void advancesThroughPricingBeforeOpeningWaitSplit() {
void opensWaitSplitImmediatelyAfterMeasurementApproval() {
assertThat(sql("approveMeasurement"))
.contains("process_status = 'WAIT_PRICING'")
.doesNotContain("inbounded_at =");
.contains("process_status = 'WAIT_SPLIT'")
.contains("inbounded_at = CURRENT_TIMESTAMP(3)")
.doesNotContain("process_status = 'WAIT_PRICING'");
assertThat(sql("completeInbound"))
.contains("process_status = 'WAIT_SPLIT'")
.contains("inbounded_at = CURRENT_TIMESTAMP(3)")
@@ -1,92 +0,0 @@
package com.baiyue.firstleg.service;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.repository.InboundWorkflowRepository;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@SpringJUnitConfig(AutomaticInboundCompletionService.class)
class AutomaticInboundCompletionServiceTest {
@MockBean private FbaPricingService pricing;
@MockBean private InboundWorkflowRepository workflow;
@MockBean private InboundCompletionOrchestrator completion;
@Resource private AutomaticInboundCompletionService service;
@Test
void shouldHandleCase01() {
FeeSnapshotResponse fee = fee(501L);
when(pricing.latest(10001L, 88L)).thenReturn(fee);
when(workflow.findOwned(10001L, 88L)).thenReturn(
order("WAIT_PRICING", "UNPRICED", 4),
order("FEE_CONFIRMING", "CALCULATED", 5),
order("FEE_CONFIRMING", "CONFIRMED", 6),
order("INBOUNDING", "CONFIRMED", 7),
order("WAIT_SPLIT", "SENT", 8));
FeeSnapshotResponse result = service.completeApproved(10001L, 88L, 9L, "客服", 30L, 4);
assertThat(result).isSameAs(fee);
verify(pricing).calculate(10001L, 9L, 88L, null, "auto-88-30-4-calculate");
verify(pricing).confirm(10001L, 9L, 88L, "auto-88-30-4-confirm");
verify(completion).beginSendFeeAndInbound(10001L, 9L, "客服", 88L, 501L, 6,
"auto-88-30-4-begin");
verify(completion).completeSendFeeAndInbound(10001L, 9L, "客服", 88L, 501L, 7,
"auto-88-30-4-complete");
}
@Test
void shouldHandleCase02() {
FeeSnapshotResponse fee = fee(501L);
when(workflow.findOwned(10001L, 88L)).thenReturn(order("WAIT_SPLIT", "SENT", 8));
when(pricing.latest(10001L, 88L)).thenReturn(fee);
assertThat(service.completeApproved(10001L, 88L, 9L, "客服", 30L, 4)).isSameAs(fee);
verify(pricing, never()).calculate(anyLong(), anyLong(), anyLong(),
org.mockito.ArgumentMatchers.any(), anyString());
verify(pricing, never()).confirm(anyLong(), anyLong(), anyLong(), anyString());
verify(completion, never()).beginSendFeeAndInbound(anyLong(), anyLong(), anyString(),
anyLong(), anyLong(), org.mockito.ArgumentMatchers.anyInt(), anyString());
}
@Test
void shouldHandleCase03() {
FeeSnapshotResponse fee = fee(501L);
when(pricing.latest(10001L, 88L)).thenReturn(fee);
when(workflow.findOwned(10001L, 88L)).thenReturn(
order("INBOUNDING", "CONFIRMED", 7), order("WAIT_SPLIT", "SENT", 8));
service.completeApproved(10001L, 88L, 9L, "客服", 30L, 4);
verify(completion).completeSendFeeAndInbound(10001L, 9L, "客服", 88L, 501L, 7,
"auto-88-30-4-complete");
verify(pricing, never()).calculate(anyLong(), anyLong(), anyLong(),
org.mockito.ArgumentMatchers.any(), anyString());
verify(pricing, never()).confirm(anyLong(), anyLong(), anyLong(), anyString());
}
private static InboundOrderEntity order(String process, String feeStatus, int version) {
InboundOrderEntity order = new InboundOrderEntity();
order.setProcessStatus(process);
order.setFeeStatus(feeStatus);
order.setVersion(version);
return order;
}
private static FeeSnapshotResponse fee(long feeId) {
FeeSnapshotResponse fee = mock(FeeSnapshotResponse.class);
when(fee.feeId()).thenReturn(feeId);
return fee;
}
}
@@ -5,6 +5,10 @@ import com.baiyue.firstleg.dto.ContainerOverviewDtos.ExceptionRequest;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.mapper.ContainerOverviewMapper;
import com.baiyue.firstleg.repository.ContainerRepository;
import com.baiyue.firstleg.client.LoadingEvidenceFileClient;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.test.util.ReflectionTestUtils;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
@@ -13,6 +17,7 @@ import org.mockito.MockitoAnnotations;
import java.time.LocalDateTime;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@@ -20,9 +25,67 @@ import static org.mockito.Mockito.*;
class ContainerOverviewServiceTest {
@Mock ContainerRepository containers;
@Mock ContainerOverviewMapper mapper;
@Mock LoadingEvidenceFileClient evidenceFiles;
@InjectMocks ContainerOverviewService service;
@BeforeEach void setUp() { MockitoAnnotations.openMocks(this); }
@BeforeEach void setUp() {
MockitoAnnotations.openMocks(this);
ReflectionTestUtils.setField(service, "internalToken", "internal-token");
}
@Test void overviewExposesAwaitingReviewWorkflowAndFirstlegPhotoDownloadPath() {
ContainerBookingEntity booking = new ContainerBookingEntity();
booking.setId(41L); booking.setVersion(3); booking.setPlanId(52L);
when(containers.find(41L)).thenReturn(booking);
when(mapper.pickTask(41L)).thenReturn(new ContainerOverviewMapper.PickTaskRow(
61L, 52L, 41L, "LOADING", 7));
when(mapper.itemCount(61L)).thenReturn(2);
when(mapper.completedItemCount(61L)).thenReturn(2);
when(mapper.unresolvedItemCount(61L)).thenReturn(0);
when(mapper.photos(52L)).thenReturn(java.util.List.of(new ContainerOverviewMapper.PhotoRow(
71L, 81L, 91L, "LOADING_DOOR_OPEN", 101L, "door.jpg", "ACTIVE", LocalDateTime.now())));
when(mapper.documents(41L)).thenReturn(java.util.List.of());
when(mapper.exceptions(41L)).thenReturn(java.util.List.of());
when(mapper.transport(41L)).thenReturn(java.util.List.of());
when(mapper.pickTimeline(61L)).thenReturn(java.util.List.of());
when(mapper.pickExceptionReasons(61L)).thenReturn(java.util.List.of());
var overview = service.overview(41L);
assertThat(overview.pda().taskStatus()).isEqualTo("LOADING");
assertThat(overview.pda().workflowStatus()).isEqualTo("AWAITING_REVIEW");
assertThat(overview.pda().photos().get(0).downloadPath())
.isEqualTo("/api/platform/v1/containers/41/pda/photos/101/download");
}
@Test void downloadsOnlyActivePhotoOwnedByContainerLoadingPlan() {
ContainerBookingEntity booking = new ContainerBookingEntity(); booking.setId(41L);
when(containers.find(41L)).thenReturn(booking);
when(mapper.pickTask(41L)).thenReturn(new ContainerOverviewMapper.PickTaskRow(
61L, 52L, 41L, "LOADING", 7));
when(mapper.photos(52L)).thenReturn(java.util.List.of(new ContainerOverviewMapper.PhotoRow(
71L, 81L, 91L, "LOADING_DOOR_OPEN", 101L, "door.jpg", "ACTIVE", LocalDateTime.now())));
when(evidenceFiles.download("internal-token", 81L, 91L, 101L)).thenReturn(ResponseEntity.ok()
.contentType(MediaType.IMAGE_JPEG).body(new byte[]{1, 2, 3}));
var download = service.downloadPhoto(41L, 101L);
assertThat(download.fileName()).isEqualTo("door.jpg");
assertThat(download.contentType()).isEqualTo(MediaType.IMAGE_JPEG);
assertThat(download.content()).containsExactly(1, 2, 3);
}
@Test void rejectsPhotoFromAnotherContainerBeforeCallingFileService() {
ContainerBookingEntity booking = new ContainerBookingEntity(); booking.setId(41L);
when(containers.find(41L)).thenReturn(booking);
when(mapper.pickTask(41L)).thenReturn(new ContainerOverviewMapper.PickTaskRow(
61L, 52L, 41L, "LOADING", 7));
when(mapper.photos(52L)).thenReturn(java.util.List.of());
assertThatThrownBy(() -> service.downloadPhoto(41L, 999L))
.isInstanceOf(BizException.class).hasMessageContaining("不属于该货柜");
verifyNoInteractions(evidenceFiles);
}
@Test void markLocksContainerAndPersistsIdempotentImmutableHistory() {
ContainerBookingEntity booking = new ContainerBookingEntity(); booking.setId(41L); booking.setVersion(3);
@@ -31,6 +31,7 @@ import java.util.concurrent.atomic.AtomicReference;
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;
@@ -302,7 +303,9 @@ class FbaPricingServiceTest {
doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(901L); return null; })
.when(repository).insertIdempotency(any());
when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2);
when(repository.supersedeSent(TENANT, 501L)).thenReturn(1);
when(repository.supersedeCurrent(TENANT, 501L, "SENT")).thenReturn(1);
when(repository.markInboundAdjusted(eq(TENANT), eq(INBOUND), eq("SENT"), eq("KG"), any()))
.thenReturn(1);
doAnswer(invocation -> {
InboundFeeEntity fee = invocation.getArgument(0);
fee.setId(502L);
@@ -314,18 +317,80 @@ class FbaPricingServiceTest {
FeeSnapshotResponse response = service.adjustPublished(TENANT, OPERATOR, INBOUND,
new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest(
new BigDecimal("100.00"), new BigDecimal("20.00"), "客服修正"), "adjust-1");
1, new BigDecimal("12.5"), new BigDecimal("8.00"),
new BigDecimal("20.00"), "客服修正"), "adjust-1");
assertThat(response.feeVersion()).isEqualTo(2);
assertThat(response.feeStatus()).isEqualTo("SENT");
assertThat(response.totalAmount()).isEqualByComparingTo("120.00");
assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT");
assertThat(inbound.getProcessStatus()).isEqualTo("WAIT_SPLIT");
verify(repository).supersedeSent(TENANT, 501L);
assertThat(inserted.get().getChargeableQuantity()).isEqualByComparingTo("12.5000");
verify(repository).supersedeCurrent(TENANT, 501L, "SENT");
verify(financeOutbox).publishInbound(eq(FinanceFeeSourceOutboxService.ADJUSTED),
eq(TENANT), eq(INBOUND), eq(502L), eq(2), eq(NOW));
}
@Test
@DisplayName("初算费用调整后保持初算状态且不发布财务来源")
void adjustsCalculatedFeeWithoutPublishingIt() {
InboundPricingEntity inbound = inbound("10", "1");
inbound.setProcessStatus("WAIT_SPLIT");
inbound.setFeeStatus("CALCULATED");
InboundFeeEntity current = savedFee("CALCULATED");
AtomicReference<InboundFeeEntity> inserted = new AtomicReference<>();
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(current);
when(repository.findIdempotency(TENANT, OPERATOR, "FEE_ADJUST", "adjust-calculated")).thenReturn(null);
doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(902L); return null; })
.when(repository).insertIdempotency(any());
when(repository.nextFeeVersion(TENANT, INBOUND)).thenReturn(2);
when(repository.supersedeCurrent(TENANT, 501L, "CALCULATED")).thenReturn(1);
when(repository.markInboundAdjusted(eq(TENANT), eq(INBOUND), eq("CALCULATED"), eq("KG"), any()))
.thenReturn(1);
doAnswer(invocation -> {
InboundFeeEntity fee = invocation.getArgument(0);
fee.setId(503L);
inserted.set(fee);
return null;
}).when(repository).insertFee(any());
when(repository.completeIdempotency(any(), eq(503L), eq(NOW))).thenReturn(1);
when(repository.findFee(TENANT, 503L)).thenAnswer(invocation -> inserted.get());
FeeSnapshotResponse response = service.adjustPublished(TENANT, OPERATOR, INBOUND,
new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest(
1, new BigDecimal("12.5"), new BigDecimal("8.00"),
new BigDecimal("20.00"), "客服修正"), "adjust-calculated");
assertThat(response.feeStatus()).isEqualTo("CALCULATED");
assertThat(response.totalAmount()).isEqualByComparingTo("120.00");
assertThat(response.nextProcessStatus()).isEqualTo("WAIT_SPLIT");
verify(repository).supersedeCurrent(TENANT, 501L, "CALCULATED");
verify(financeOutbox, never()).publishInbound(any(), anyLong(), anyLong(), anyLong(), anyInt(), any());
}
@Test
@DisplayName("费用版本已变化时拒绝覆盖当前版本")
void rejectsAdjustmentWithStaleFeeVersion() {
InboundPricingEntity inbound = inbound("10", "1");
inbound.setProcessStatus("WAIT_SPLIT");
InboundFeeEntity current = savedFee("CALCULATED");
current.setFeeVersion(2);
when(repository.findInbound(TENANT, INBOUND)).thenReturn(inbound);
when(repository.findLatestFee(TENANT, INBOUND)).thenReturn(current);
assertThatThrownBy(() -> service.adjustPublished(TENANT, OPERATOR, INBOUND,
new com.baiyue.firstleg.dto.FbaPricingDtos.AdjustFeeRequest(
1, new BigDecimal("12.5"), new BigDecimal("8.00"),
BigDecimal.ZERO, "客服修正"), "stale-adjust"))
.isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode())
.isEqualTo(ResultCode.CONFLICT);
verify(repository, never()).insertIdempotency(any());
verify(repository, never()).supersedeCurrent(anyLong(), anyLong(), any());
}
private void readyRepository() {
when(repository.findIdempotency(anyLong(), anyLong(), any(), any())).thenReturn(null);
doAnswer(invocation -> { ((InboundPricingIdempotencyEntity) invocation.getArgument(0)).setId(900L); return null; })
@@ -1,150 +0,0 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.entity.InboundFeeEntity;
import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.repository.InboundWorkflowRepository;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
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;
@DisplayName("发送费用并入库状态编排")
@SpringJUnitConfig(InboundCompletionOrchestrator.class)
class InboundCompletionOrchestratorTest {
@MockBean private FinanceFeeSourceOutboxService financeOutbox;
@MockBean
private InboundWorkflowRepository repository;
@MockBean
private InboundIdempotencyService idempotency;
@MockBean
private InboundFeeDetailService feeDetails;
@Resource
private InboundCompletionOrchestrator service;
@Test
@DisplayName("审核通过且费用和库位引用已准备时进入INBOUNDING")
void shouldBeginInboundAfterAllGuardsPass() {
Fixture fixture = fixture("APPROVED", false);
when(fixture.repository.beginInbound(10001L, 10L, 9L, 4)).thenReturn(1);
fixture.service.beginSendFeeAndInbound(10001L, 9L, "操作员", 10L, 30L, 4, "send-1");
verify(fixture.repository).beginInbound(10001L, 10L, 9L, 4);
verify(fixture.repository).insertHistory(any());
}
@Test
@DisplayName("测量驳回且无异常放行记录时禁止发送费用")
void shouldBlockRejectedMeasurementWithoutRelease() {
Fixture fixture = fixture("REJECTED", true);
when(fixture.repository.hasExceptionRelease(10001L, 10L)).thenReturn(false);
assertThatThrownBy(() -> fixture.service.beginSendFeeAndInbound(
10001L, 9L, "操作员", 10L, 30L, 4, "send-1"))
.isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode())
.isEqualTo(ResultCode.CONFLICT);
verify(fixture.repository, never()).beginInbound(anyLong(), anyLong(), anyLong(), anyInt());
}
@Test
@DisplayName("异常放行标记必须与费用快照一致")
void shouldRequireFeeSnapshotReleaseFlag() {
Fixture fixture = fixture("REJECTED", false);
when(fixture.repository.hasExceptionRelease(10001L, 10L)).thenReturn(true);
assertThatThrownBy(() -> fixture.service.beginSendFeeAndInbound(
10001L, 9L, "操作员", 10L, 30L, 4, "send-1"))
.isInstanceOf(BizException.class)
.hasMessageContaining("异常放行标记");
}
@Test
@DisplayName("完成阶段原子发送费用、完成入库并写待投递事件")
void shouldCompleteInboundAndWriteOutbox() {
Fixture fixture = fixture("APPROVED", false);
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");
verify(fixture.repository).completeInbound(10001L, 10L, 9L, 5);
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(financeOutbox).publishInbound(eq(FinanceFeeSourceOutboxService.PUBLISHED),
eq(10001L), eq(10L), eq(30L), eq(1),
org.mockito.ArgumentMatchers.any(java.time.LocalDateTime.class));
}
@Test
@DisplayName("条件更新失败时报告并发冲突且不发送费用")
void shouldRejectConcurrentCompletion() {
Fixture fixture = fixture("APPROVED", false);
fixture.order.setProcessStatus("INBOUNDING");
when(fixture.repository.completeInbound(10001L, 10L, 9L, 5)).thenReturn(0);
assertThatThrownBy(() -> fixture.service.completeSendFeeAndInbound(
10001L, 9L, "操作员", 10L, 30L, 5, "complete-1"))
.isInstanceOf(BizException.class)
.extracting(error -> ((BizException) error).getResultCode())
.isEqualTo(ResultCode.CONFLICT);
verify(fixture.repository, never()).markFeeSent(anyLong(), anyLong(), anyLong(), anyLong());
verify(financeOutbox, never()).publishInbound(any(), anyLong(), anyLong(), anyLong(),
org.mockito.ArgumentMatchers.anyInt(),
org.mockito.ArgumentMatchers.any(java.time.LocalDateTime.class));
}
@Test
@DisplayName("只有仓库预占已补偿后才通过显式方法回退")
void shouldRollbackOnlyInboundingState() {
Fixture fixture = fixture("APPROVED", false);
when(fixture.repository.rollbackInbound(10001L, 10L, 9L, 5)).thenReturn(1);
fixture.service.rollbackSendFeeAndInbound(
10001L, 9L, "操作员", 10L, 5, "仓库确认失败且已释放预占", "rollback-1");
verify(fixture.repository).rollbackInbound(10001L, 10L, 9L, 5);
verify(fixture.repository).insertHistory(any());
}
private Fixture fixture(String reviewStatus, boolean feeRelease) {
when(idempotency.begin(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString()))
.thenReturn(new InboundIdempotencyService.Context(10001L, 1L, false));
InboundOrderEntity order = new InboundOrderEntity();
order.setProcessStatus("FEE_CONFIRMING");
order.setFeeStatus("CONFIRMED");
order.setMeasureReviewStatus(reviewStatus);
InboundFeeEntity fee = new InboundFeeEntity();
fee.setExceptionRelease(feeRelease);
when(repository.findOwned(10001L, 10L)).thenReturn(order);
when(repository.findConfirmedFee(10001L, 10L, 30L)).thenReturn(fee);
when(repository.countPreparedAllocations(10001L, 10L)).thenReturn(1L);
return new Fixture(service, repository, order);
}
private record Fixture(InboundCompletionOrchestrator service,
InboundWorkflowRepository repository,
InboundOrderEntity order) {
}
}
@@ -0,0 +1,46 @@
package com.baiyue.firstleg.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.dto.FbaPricingDtos.FeeSnapshotResponse;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class InitialInboundPricingServiceTest {
private final FbaPricingService pricing = mock(FbaPricingService.class);
private final InitialInboundPricingService service = new InitialInboundPricingService();
InitialInboundPricingServiceTest() {
ReflectionTestUtils.setField(service, "pricing", pricing);
}
@Test
void returnsCalculatedDraftWithoutChangingWaitSplit() {
FeeSnapshotResponse fee = mock(FeeSnapshotResponse.class);
when(fee.feeStatus()).thenReturn("CALCULATED");
when(pricing.calculate(10001L, 9L, 88L, null, "initial-88-30-5")).thenReturn(fee);
var result = service.tryCalculate(10001L, 9L, 88L, 30L, 5);
assertThat(result.processStatus()).isEqualTo("WAIT_SPLIT");
assertThat(result.feeStatus()).isEqualTo("CALCULATED");
assertThat(result.initialPricingSucceeded()).isTrue();
assertThat(result.initialFee()).isSameAs(fee);
}
@Test
void keepsWaitSplitWhenNoPriceMatches() {
when(pricing.calculate(10001L, 9L, 88L, null, "initial-88-30-5"))
.thenThrow(new BizException("未匹配有效价格"));
var result = service.tryCalculate(10001L, 9L, 88L, 30L, 5);
assertThat(result.processStatus()).isEqualTo("WAIT_SPLIT");
assertThat(result.feeStatus()).isEqualTo("UNPRICED");
assertThat(result.initialPricingSucceeded()).isFalse();
assertThat(result.initialPricingMessage()).contains("未匹配有效价格");
}
}
@@ -78,7 +78,7 @@ class MeasurementReviewServiceTest {
ArgumentCaptor.forClass(InboundStatusHistoryEntity.class);
verify(fixture.repository).insertHistory(history.capture());
assertThat(history.getValue().getFromStatus()).isEqualTo("MEASURE_PENDING");
assertThat(history.getValue().getToStatus()).isEqualTo("WAIT_PRICING");
assertThat(history.getValue().getToStatus()).isEqualTo("WAIT_SPLIT");
}
@Test
@@ -16,6 +16,7 @@ import com.baiyue.firstleg.pricing.RegionWeightTierEstimator;
import com.baiyue.firstleg.repository.PriceBookQueryRepository;
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelRow;
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelTaxModeRow;
import com.baiyue.firstleg.repository.PriceBookQueryRows.ChannelPrefixRow;
import com.baiyue.firstleg.repository.PriceBookQueryRows.RateRow;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
@@ -41,6 +42,32 @@ class MerchantPriceBookServiceTest {
@MockBean private PriceBookWarehouseClient warehouses;
@Resource private MerchantPriceBookService service;
@Test
void shouldResolveNormalAndOverseasInboundCodePrefixesFromChannelCatalog() {
when(repository.activeVersionId()).thenReturn(7L);
when(repository.channels(7L, 10L)).thenReturn(List.of(
channel(7L, "MATSON_CLX_TRUCK", PriceModel.FBA_WAREHOUSE_RATE)));
when(repository.channelPrefix("MATSON_CLX_TRUCK"))
.thenReturn(new ChannelPrefixRow("MATSON_CLX_TRUCK", "ZBYHK", "ZBYHWK"));
assertThat(service.requireAvailableChannel(10001L, 10L, "matson_clx_truck", "FBA")
.inboundCodePrefix()).isEqualTo("ZBYHK");
assertThat(service.requireAvailableChannel(10001L, 10L, "MATSON_CLX_TRUCK", "OVERSEAS")
.inboundCodePrefix()).isEqualTo("ZBYHWK");
}
@Test
void shouldFallBackToNormalPrefixWhenChannelHasNoOverseasVariant() {
when(repository.activeVersionId()).thenReturn(7L);
when(repository.channels(7L, 10L)).thenReturn(List.of(
channel(7L, "OA_HOUSTON_TRUCK", PriceModel.FBA_WAREHOUSE_RATE)));
when(repository.channelPrefix("OA_HOUSTON_TRUCK"))
.thenReturn(new ChannelPrefixRow("OA_HOUSTON_TRUCK", "HPHK", null));
assertThat(service.requireAvailableChannel(10001L, 10L, "OA_HOUSTON_TRUCK", "OVERSEAS")
.inboundCodePrefix()).isEqualTo("HPHK");
}
@Test
void shouldReturnEmptyChannelsWithoutActiveVersion() {
when(repository.activeVersionId()).thenReturn(null);
@@ -41,6 +41,28 @@ class TruckAppointmentServiceTest {
@BeforeEach void setUp() { MockitoAnnotations.openMocks(this); }
@Test void detailResolvesStableNamesAndIdentifiersFromLinkedResources() {
TruckAppointmentEntity appointment = new TruckAppointmentEntity();
appointment.setId(51L); appointment.setOceanBookingId(61L); appointment.setContainerBookingId(71L);
when(repository.find(51L)).thenReturn(appointment);
OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(61L); booking.setBookingNo("OB61");
booking.setWarehouseName("Shenzhen Transit"); booking.setShippingCompanyId(81L);
booking.setShippingCompanyName("Carrier Line"); booking.setBookingAgentId(91L); booking.setBookingAgentName("Agent Co");
when(oceanBookings.find(61L)).thenReturn(booking);
ContainerBookingEntity container = new ContainerBookingEntity(); container.setId(71L);
container.setCabinetNo("CAB-71"); container.setContainerNo("MSCU1234567");
when(oceanBookings.containerBooking(71L)).thenReturn(container);
TruckAppointmentEntity detail = service.detail(51L);
assertThat(detail.getOceanBookingNo()).isEqualTo("OB61");
assertThat(detail.getWarehouseName()).isEqualTo("Shenzhen Transit");
assertThat(detail.getShippingCompanyName()).isEqualTo("Carrier Line");
assertThat(detail.getBookingAgentName()).isEqualTo("Agent Co");
assertThat(detail.getCabinetNo()).isEqualTo("CAB-71");
assertThat(detail.getContainerNo()).isEqualTo("MSCU1234567");
}
@Test void createRequiresZeroVersionAndInitialisesDraft() {
SaveRequest invalid = request(1);
assertThatThrownBy(() -> service.create(7L, "admin", invalid)).isInstanceOf(BizException.class);
@@ -63,10 +85,13 @@ class TruckAppointmentServiceTest {
when(request.oceanBookingId()).thenReturn(99L);
when(request.customerId()).thenReturn(7L);
when(request.loadingPlanId()).thenReturn(null);
OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("RELEASED"); booking.setCustomerId(7L);
OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("RELEASED"); booking.setCustomerId(7L); booking.setCustomerName("Canonical Customer");
when(oceanBookings.find(99L)).thenReturn(booking);
service.create(7L, "admin", request);
verify(repository).insert(any(TruckAppointmentEntity.class));
ArgumentCaptor<TruckAppointmentEntity> entity = ArgumentCaptor.forClass(TruckAppointmentEntity.class);
verify(repository).insert(entity.capture());
assertThat(entity.getValue().getCustomerId()).isEqualTo(7L);
assertThat(entity.getValue().getCustomerName()).isEqualTo("Canonical Customer");
verify(repository).history(any(TruckAppointmentHistoryEntity.class));
}
@@ -76,7 +101,7 @@ class TruckAppointmentServiceTest {
when(request.oceanBookingId()).thenReturn(99L);
when(request.containerBookingId()).thenReturn(88L);
when(request.loadingPlanId()).thenReturn(77L);
OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("RELEASED");
OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("RELEASED"); booking.setCustomerId(7L); booking.setCustomerName("Canonical Customer");
when(oceanBookings.find(99L)).thenReturn(booking);
ContainerBookingEntity container = new ContainerBookingEntity(); container.setId(88L);
when(oceanBookings.containerBooking(88L)).thenReturn(container);
@@ -97,7 +122,7 @@ class TruckAppointmentServiceTest {
when(request.oceanBookingId()).thenReturn(99L);
when(request.containerBookingId()).thenReturn(88L);
when(request.loadingPlanId()).thenReturn(77L);
OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("RELEASED");
OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("RELEASED"); booking.setCustomerId(7L); booking.setCustomerName("Canonical Customer");
when(oceanBookings.find(99L)).thenReturn(booking);
ContainerBookingEntity container = new ContainerBookingEntity(); container.setId(88L);
when(oceanBookings.containerBooking(88L)).thenReturn(container);