Files
baiyue-firstleg/firstleg/src/test/java/com/baiyue/firstleg/service/TruckAppointmentServiceTest.java
T
2026-08-18 09:09:28 +08:00

374 lines
21 KiB
Java

package com.baiyue.firstleg.service;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.firstleg.domain.TruckAppointmentStatus;
import com.baiyue.firstleg.dto.TruckAppointmentDtos.ActionRequest;
import com.baiyue.firstleg.dto.TruckAppointmentDtos.SaveRequest;
import com.baiyue.firstleg.entity.TruckAppointmentEntity;
import com.baiyue.firstleg.entity.TruckAppointmentHistoryEntity;
import com.baiyue.firstleg.entity.TruckAppointmentOperationEntity;
import com.baiyue.firstleg.entity.LoadingPlanEntity;
import com.baiyue.firstleg.entity.ContainerBookingEntity;
import com.baiyue.firstleg.repository.TruckAppointmentRepository;
import com.baiyue.firstleg.repository.SmartLoadingRepository;
import com.baiyue.firstleg.repository.ContainerRepository;
import com.baiyue.firstleg.oceanbooking.repository.OceanBookingRepository;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity;
import com.baiyue.firstleg.oceanbooking.entity.OceanBookingContainerAllocationEntity;
import com.baiyue.firstleg.client.IamCustomerSnapshotClient;
import com.baiyue.firstleg.client.WarehouseSupplierClient;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.mockito.Spy;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;
import java.lang.reflect.Constructor;
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.*;
import static org.mockito.Mockito.*;
class TruckAppointmentServiceTest {
@Mock TruckAppointmentRepository repository;
@Mock OceanBookingRepository oceanBookings;
@Mock SmartLoadingRepository loadingPlans;
@Mock ContainerRepository containers;
@Mock WarehouseSupplierSnapshotService suppliers;
@Mock IamCustomerSnapshotService customers;
@Spy ObjectMapper objectMapper = JsonMapper.builder().findAndAddModules().build();
@InjectMocks TruckAppointmentService service;
@BeforeEach void setUp() { MockitoAnnotations.openMocks(this);
when(customers.active(anyLong(), any())).thenAnswer(invocation ->
new IamCustomerSnapshotClient.Customer(invocation.getArgument(0), "C-7",
"Canonical Customer", "ACTIVE", null, null));
when(suppliers.trucking(any(), any())).thenReturn(
new WarehouseSupplierClient.Snapshot(8L, "Canonical Carrier", "TRUCKING", "ACTIVE"));
}
@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(containers.find(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);
verifyNoInteractions(repository);
}
@Test void createWithoutBookingContainerOrPlanWritesIndependentDraft() {
SaveRequest request = mock(SaveRequest.class);
when(request.expectedVersion()).thenReturn(0);
when(request.customerId()).thenReturn(7L);
when(request.carrierId()).thenReturn(8L);
when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0));
when(request.oceanBookingId()).thenReturn(null);
when(request.containerBookingId()).thenReturn(null);
when(request.loadingPlanId()).thenReturn(null);
when(request.warehouseId()).thenReturn(null);
service.create(7L, "admin", request);
ArgumentCaptor<TruckAppointmentEntity> entity = ArgumentCaptor.forClass(TruckAppointmentEntity.class);
verify(repository).insert(entity.capture());
assertThat(entity.getValue().getOceanBookingId()).isNull();
assertThat(entity.getValue().getContainerBookingId()).isNull();
assertThat(entity.getValue().getLoadingPlanId()).isNull();
assertThat(entity.getValue().getCustomerName()).isEqualTo("Canonical Customer");
assertThat(entity.getValue().getCarrierName()).isEqualTo("Canonical Carrier");
verifyNoInteractions(oceanBookings, containers, loadingPlans);
}
@Test void createAcceptsConfirmedBookingWithoutContainerOrPlan() {
SaveRequest request = mock(SaveRequest.class);
when(request.expectedVersion()).thenReturn(0);
when(request.oceanBookingId()).thenReturn(99L);
when(request.customerId()).thenReturn(7L);
when(request.carrierId()).thenReturn(8L);
when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0));
when(request.containerBookingId()).thenReturn(null);
when(request.loadingPlanId()).thenReturn(null);
when(request.warehouseId()).thenReturn(null);
OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("CONFIRMED"); booking.setCustomerId(7L); booking.setWarehouseId(18L);
when(oceanBookings.find(99L)).thenReturn(booking);
service.create(7L, "admin", request);
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");
assertThat(entity.getValue().getOceanBookingId()).isEqualTo(99L);
assertThat(entity.getValue().getContainerBookingId()).isNull();
assertThat(entity.getValue().getLoadingPlanId()).isNull();
assertThat(entity.getValue().getWarehouseId()).isEqualTo(18L);
verify(repository).history(any(TruckAppointmentHistoryEntity.class));
}
@Test void createRejectsBookingFromAnotherCustomer() {
SaveRequest request = validRequest();
when(request.oceanBookingId()).thenReturn(99L);
OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(99L); booking.setCustomerId(17L);
when(oceanBookings.find(99L)).thenReturn(booking);
assertThatThrownBy(() -> service.create(7L, "admin", request))
.isInstanceOf(BizException.class).hasMessageContaining("customerId").hasMessageContaining("订舱");
verify(repository, never()).insert(any());
}
@Test void createWithBookingAndContainerRequiresTheirAllocation() {
SaveRequest request = validRequest();
when(request.oceanBookingId()).thenReturn(99L);
when(request.containerBookingId()).thenReturn(88L);
OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(99L); booking.setCustomerId(7L); booking.setWarehouseId(18L);
ContainerBookingEntity container = new ContainerBookingEntity();
container.setId(88L); container.setCustomerId(7L); container.setWarehouseId(18L);
when(oceanBookings.find(99L)).thenReturn(booking);
when(containers.find(88L)).thenReturn(container);
when(oceanBookings.allocations(99L)).thenReturn(List.of());
assertThatThrownBy(() -> service.create(7L, "admin", request))
.isInstanceOf(BizException.class).hasMessageContaining("未分配至该订舱");
verify(repository, never()).insert(any());
}
@Test void createRejectsLoadingPlanWithoutContainerAssociation() {
SaveRequest request = validRequest();
when(request.loadingPlanId()).thenReturn(77L);
assertThatThrownBy(() -> service.create(7L, "admin", request))
.isInstanceOf(BizException.class).hasMessageContaining("loadingPlanId")
.hasMessageContaining("containerBookingId");
verify(repository, never()).insert(any());
}
@Test void createRejectsLoadingPlanFromAnotherContainer() {
SaveRequest request = mock(SaveRequest.class);
when(request.expectedVersion()).thenReturn(0);
when(request.oceanBookingId()).thenReturn(99L);
when(request.containerBookingId()).thenReturn(88L);
when(request.loadingPlanId()).thenReturn(77L);
when(request.customerId()).thenReturn(7L);
when(request.carrierId()).thenReturn(8L);
when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0));
when(request.warehouseId()).thenReturn(null);
OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("CONFIRMED"); booking.setCustomerId(7L);
when(oceanBookings.find(99L)).thenReturn(booking);
ContainerBookingEntity container = new ContainerBookingEntity(); container.setId(88L);
when(containers.find(88L)).thenReturn(container);
var allocation = new OceanBookingContainerAllocationEntity();
allocation.setContainerBookingId(88L);
when(oceanBookings.allocations(99L)).thenReturn(java.util.List.of(allocation));
LoadingPlanEntity plan = new LoadingPlanEntity(); plan.setId(78L); plan.setContainerBookingId(88L);
when(loadingPlans.planByBooking(88L)).thenReturn(plan);
assertThatThrownBy(() -> service.create(7L, "admin", request))
.isInstanceOf(BizException.class).hasMessageContaining("loadingPlanId").hasMessageContaining("当前装柜计划");
verify(repository, never()).insert(any());
}
@Test void createRejectsUnknownLoadingPlan() {
SaveRequest request = mock(SaveRequest.class);
when(request.expectedVersion()).thenReturn(0);
when(request.oceanBookingId()).thenReturn(99L);
when(request.containerBookingId()).thenReturn(88L);
when(request.loadingPlanId()).thenReturn(77L);
when(request.customerId()).thenReturn(7L);
when(request.carrierId()).thenReturn(8L);
when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0));
when(request.warehouseId()).thenReturn(null);
OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("CONFIRMED"); booking.setCustomerId(7L);
when(oceanBookings.find(99L)).thenReturn(booking);
ContainerBookingEntity container = new ContainerBookingEntity(); container.setId(88L);
when(containers.find(88L)).thenReturn(container);
var allocation = new OceanBookingContainerAllocationEntity();
allocation.setContainerBookingId(88L);
when(oceanBookings.allocations(99L)).thenReturn(java.util.List.of(allocation));
when(loadingPlans.planByBooking(88L)).thenReturn(null);
assertThatThrownBy(() -> service.create(7L, "admin", request))
.isInstanceOf(BizException.class).hasMessageContaining("loadingPlanId").hasMessageContaining("当前装柜计划");
verify(repository, never()).insert(any());
}
@Test void actionAdvancesMainLifecycleAndWritesIdempotentOperation() {
TruckAppointmentEntity before = entity(TruckAppointmentStatus.DRAFT, 0);
TruckAppointmentEntity after = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1);
when(repository.find(1L)).thenReturn(before, after);
when(repository.claimOperation(anyLong(), eq(1L), eq("SUBMIT"), eq("idem-1"), anyString())).thenReturn(true);
when(repository.transition(1L, "DRAFT", "PENDING_DISPATCH", 0)).thenReturn(1);
when(repository.completeOperation(eq(1L), eq("SUBMIT"), eq("idem-1"), anyString())).thenReturn(1);
var result = service.action(1L, "SUBMIT", new ActionRequest(0, "提交"), "idem-1", null, 7L, "admin");
assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.PENDING_DISPATCH);
assertThat(result.getVersion()).isEqualTo(1);
assertThat(result.getRevision()).isEqualTo(1);
verify(repository).claimOperation(anyLong(), eq(1L), eq("SUBMIT"), eq("idem-1"), anyString());
verify(repository).completeOperation(eq(1L), eq("SUBMIT"), eq("idem-1"), anyString());
verify(repository).history(any(TruckAppointmentHistoryEntity.class));
}
@Test void actionReplaysCompletedRequestWithoutApplyingTransitionAgain() throws Exception {
TruckAppointmentEntity completed = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1);
TruckAppointmentOperationEntity operation = new TruckAppointmentOperationEntity();
operation.setAppointmentId(1L); operation.setAction("SUBMIT"); operation.setIdempotencyKey("idem-1");
String hash = requestHash("SUBMIT", 1L, new ActionRequest(0, "提交"));
operation.setRequestHash(hash); operation.setResponseSnapshot(objectMapper.writeValueAsString(completed));
when(repository.claimOperation(anyLong(), eq(1L), eq("SUBMIT"), eq("idem-1"), eq(hash))).thenReturn(false);
when(repository.operation("idem-1")).thenReturn(operation);
var replay = service.action(1L, "SUBMIT", new ActionRequest(0, "提交"), "idem-1", hash, 7L, "admin");
assertThat(replay.getStatus()).isEqualTo(TruckAppointmentStatus.PENDING_DISPATCH);
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt());
verify(repository, never()).history(any());
}
@Test void actionRejectsMissingKeyAndMismatchedClientHash() {
var request = new ActionRequest(0, "提交");
assertThatThrownBy(() -> service.action(1L, "SUBMIT", request, null, null, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("Idempotency-Key");
assertThatThrownBy(() -> service.action(1L, "SUBMIT", request, "idem-1", "wrong", 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("X-Request-Hash");
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt());
}
@Test void rejectsRescheduleThroughGenericActionAndCompletedReschedule() {
TruckAppointmentEntity e = entity(TruckAppointmentStatus.DRAFT, 0);
when(repository.find(1L)).thenReturn(e);
when(repository.claimOperation(anyLong(), eq(1L), eq("RESCHEDULE"), eq("idem-action"), anyString())).thenReturn(true);
assertThatThrownBy(() -> service.action(1L, "RESCHEDULE", new ActionRequest(0, null), "idem-action", null, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("专用接口");
e.setStatus(TruckAppointmentStatus.COMPLETED);
when(repository.claimOperation(anyLong(), eq(1L), eq("RESCHEDULE"), eq("idem-reschedule"), anyString())).thenReturn(true);
assertThatThrownBy(() -> service.reschedule(1L, LocalDateTime.now(), new ActionRequest(0, "改约"), "idem-reschedule", null, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("不可改约");
verify(repository, never()).reschedule(anyLong(), any(), anyString(), anyInt());
}
@Test void rescheduleKeepsFromStatusAndIncrementsOptimisticFields() {
TruckAppointmentEntity before = entity(TruckAppointmentStatus.DISPATCHED, 2);
TruckAppointmentEntity after = entity(TruckAppointmentStatus.RESCHEDULED, 3);
when(repository.find(1L)).thenReturn(before, after);
when(repository.claimOperation(anyLong(), eq(1L), eq("RESCHEDULE"), eq("idem-reschedule"), anyString())).thenReturn(true);
when(repository.reschedule(eq(1L), any(), eq("改约"), eq(2))).thenReturn(1);
when(repository.completeOperation(eq(1L), eq("RESCHEDULE"), eq("idem-reschedule"), anyString())).thenReturn(1);
var at = LocalDateTime.of(2026, 8, 12, 10, 0);
var result = service.reschedule(1L, at, new ActionRequest(2, "改约"), "idem-reschedule", null, 7L, "admin");
assertThat(result.getStatus()).isEqualTo(TruckAppointmentStatus.RESCHEDULED);
assertThat(result.getVersion()).isEqualTo(3);
ArgumentCaptor<TruckAppointmentHistoryEntity> history = ArgumentCaptor.forClass(TruckAppointmentHistoryEntity.class);
verify(repository).history(history.capture());
assertThat(history.getValue().getFromStatus()).isEqualTo("DISPATCHED");
assertThat(history.getValue().getToStatus()).isEqualTo("RESCHEDULED");
}
@Test void pdaCompletionDoesNotRequireTruckAppointment() {
when(repository.activeForUpdate(99L)).thenReturn(List.of());
service.completeLoadingFromPda(99L, 20L, 7L, "admin");
verify(repository, never()).completeLoadingFromPda(anyLong(), anyString(), anyInt());
verify(repository, never()).history(any());
}
@Test void pdaCompletionLocksAndCompletesTheOnlyActiveTruckWithHistory() {
TruckAppointmentEntity before = entity(TruckAppointmentStatus.LOADING, 4);
TruckAppointmentEntity after = entity(TruckAppointmentStatus.COMPLETED, 5);
when(repository.activeForUpdate(99L)).thenReturn(List.of(before));
when(repository.completeLoadingFromPda(1L, "LOADING", 4)).thenReturn(1);
when(repository.find(1L)).thenReturn(after);
service.completeLoadingFromPda(99L, 20L, 7L, "admin");
verify(repository).completeLoadingFromPda(1L, "LOADING", 4);
ArgumentCaptor<TruckAppointmentHistoryEntity> history = ArgumentCaptor.forClass(TruckAppointmentHistoryEntity.class);
verify(repository).history(history.capture());
assertThat(history.getValue().getFromStatus()).isEqualTo("LOADING");
assertThat(history.getValue().getToStatus()).isEqualTo("COMPLETED");
assertThat(history.getValue().getAction()).isEqualTo("PDA_COMPLETE_LOADING");
assertThat(history.getValue().getOperatorId()).isEqualTo(7L);
assertThat(history.getValue().getReason()).contains("20");
}
@Test void pdaCompletionRejectsMultipleActiveTruckAppointmentsWithoutMutation() {
when(repository.activeForUpdate(99L)).thenReturn(List.of(
entity(TruckAppointmentStatus.LOADING, 1), entity(TruckAppointmentStatus.DEPARTED, 2)));
assertThatThrownBy(() -> service.completeLoadingFromPda(99L, 20L, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("多条有效拖车预约");
verify(repository, never()).completeLoadingFromPda(anyLong(), anyString(), anyInt());
verify(repository, never()).history(any());
}
@Test void pdaCompletionRejectsConcurrentTruckModificationWithoutHistory() {
TruckAppointmentEntity before = entity(TruckAppointmentStatus.RESCHEDULED, 4);
when(repository.activeForUpdate(99L)).thenReturn(List.of(before));
when(repository.completeLoadingFromPda(1L, "RESCHEDULED", 4)).thenReturn(0);
assertThatThrownBy(() -> service.completeLoadingFromPda(99L, 20L, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("并发修改");
verify(repository, never()).history(any());
}
private String requestHash(String action, long id, Object request) throws Exception {
String canonical = action + '|' + id + '|' + objectMapper.writeValueAsString(request);
return java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256")
.digest(canonical.getBytes(java.nio.charset.StandardCharsets.UTF_8)));
}
private static TruckAppointmentEntity entity(TruckAppointmentStatus status, int version) {
TruckAppointmentEntity e = new TruckAppointmentEntity(); e.setId(1L); e.setStatus(status); e.setVersion(version); e.setRevision(version); return e;
}
private static SaveRequest request(int expectedVersion) {
try {
Constructor<?> c = SaveRequest.class.getDeclaredConstructors()[0];
Object[] args = new Object[c.getParameterCount()]; args[args.length - 1] = expectedVersion;
return (SaveRequest) c.newInstance(args);
} catch (ReflectiveOperationException ex) { throw new AssertionError(ex); }
}
private static SaveRequest validRequest() {
SaveRequest request = mock(SaveRequest.class);
when(request.expectedVersion()).thenReturn(0);
when(request.customerId()).thenReturn(7L);
when(request.carrierId()).thenReturn(8L);
when(request.plannedLoadingAt()).thenReturn(LocalDateTime.of(2026, 8, 17, 10, 0));
when(request.oceanBookingId()).thenReturn(null);
when(request.containerBookingId()).thenReturn(null);
when(request.loadingPlanId()).thenReturn(null);
when(request.warehouseId()).thenReturn(null);
return request;
}
}