refactor(firstleg): remove customer scope from trucking

This commit is contained in:
2026-09-01 09:51:59 +08:00
parent 936412f351
commit 249b182e7f
17 changed files with 357 additions and 463 deletions
@@ -19,7 +19,7 @@ import static org.assertj.core.api.Assertions.assertThat;
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","bill_of_lading_no VARCHAR(128)","chk_truck_attachment_type","DISPATCH_ORDER","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 schemaHasPlatformAggregateWithoutCustomerColumns() 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","bill_of_lading_no VARCHAR(128)","chk_truck_attachment_type","DISPATCH_ORDER","PENDING_DISPATCH","PICKUP_IN_PROGRESS","ARRIVED_WAREHOUSE","RESCHEDULED","actual_arrived_warehouse_at","actual_loading_at","actual_departed_at","CALL phase3_truck_add_column","visibility VARCHAR(24) NOT NULL DEFAULT 'PLATFORM'","active_container_booking_id BIGINT UNSIGNED GENERATED ALWAYS AS","UNIQUE KEY uk_truck_active_container(active_container_booking_id)").doesNotContain("customer_id", "customer_name", "idx_truck_appointment_customer");}
@Test void existingAppointmentTableReceivesAllLateColumns() throws Exception {
String moduleSql=new ClassPathResource("db/phase3-truck-appointment-schema.sql").getContentAsString(StandardCharsets.UTF_8);
Path direct=Path.of("deploy","sql","头程数据库统一增量-MySQL8.sql");
@@ -30,7 +30,7 @@ class TruckAppointmentControllerContractTest {
}
}
@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 openApiRequiresReleasedBookingAndAllocatedRealContainer() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("required: [oceanBookingId, containerBookingId, customerId, carrierId, plannedLoadingAt, expectedVersion]","RELEASED ocean booking","ACTIVE real-numbered container","OCEAN_BOOKING_DOCUMENT","valid unique ISO 6346 container number","schema: {$ref: '#/components/schemas/DetailEnvelope'}","required: [code, message, data, timestamp]","allOf: [{$ref: '#/components/schemas/DetailStableReferences'}]","nullable: true");}
@Test void openApiRequiresReleasedBookingAndAllocatedRealContainerWithoutCustomerScope() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("required: [oceanBookingId, containerBookingId, carrierId, plannedLoadingAt, expectedVersion]","RELEASED ocean booking","ACTIVE real-numbered container","OCEAN_BOOKING_DOCUMENT","valid unique ISO 6346 container number","schema: {$ref: '#/components/schemas/DetailEnvelope'}","required: [code, message, data, timestamp]","allOf: [{$ref: '#/components/schemas/DetailStableReferences'}]","nullable: true").doesNotContain("customerId", "customerName", "Filter by customer");}
@Test void openApiKeepsOnlyNonBookingTruckAttachments() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("AttachmentRequest:","required: [fileId, attachmentType]","required: [attachmentType, file]","TRUCK_APPOINTMENT_ATTACHMENT","Deletes the appointment reference only","DetailStableReferences:","oceanBookingNo:","shippingCompanyName:","bookingAgentName:","cabinetNo:","billOfLadingNo:").doesNotContain("enum: [BILL_OF_LADING, DISPATCH_ORDER");}
@Test void methodsHavePlatformPermissionsAndAuthenticatedScope() {
Map<String,String> expected=Map.ofEntries(
@@ -13,8 +13,9 @@ class TruckAppointmentMapperContractTest {
@Test void mapsToPhase3TableAndUsesOptimisticGuards() throws Exception {
assertThat(TruckAppointmentEntity.class.getAnnotation(TableName.class).value()).isEqualTo("fl_truck_appointment");
String page = TruckAppointmentMapper.class.getMethod("page", com.baomidou.mybatisplus.extension.plugins.pagination.Page.class, com.baiyue.firstleg.dto.TruckAppointmentDtos.Query.class).getAnnotation(Select.class).value()[0];
assertThat(page).contains("fl_truck_appointment", "appointment_no", "vehicle_no", "status=#{q.status}", "customer_id=#{q.customerId}", "container_booking_id=#{q.containerBookingId}",
assertThat(page).contains("fl_truck_appointment", "appointment_no", "vehicle_no", "status=#{q.status}", "container_booking_id=#{q.containerBookingId}",
"fl_ocean_booking", "fl_container_booking", "record_status='ACTIVE'", "cabinet_no", "container_no", "fl_truck_appointment_attachment", "attachment_count");
assertThat(page).doesNotContain("customer_id", "customerId", "customer_name");
String transition = TruckAppointmentMapper.class.getMethod("transition", long.class, String.class, String.class, int.class).getAnnotation(Update.class).value()[0];
assertThat(transition).contains("status=#{toStatus}", "status=#{fromStatus}", "version=#{version}", "revision=revision+1", "version=version+1");
String dispatch = TruckAppointmentMapper.class.getMethod("dispatch", long.class, String.class, String.class, int.class).getAnnotation(Update.class).value()[0];
@@ -29,8 +30,9 @@ class TruckAppointmentMapperContractTest {
assertThat(reschedule).contains("status='RESCHEDULED'", "version=#{version}", "revision=revision+1");
String edit = TruckAppointmentMapper.class.getMethod("updateEdit", TruckAppointmentEntity.class, int.class).getAnnotation(Update.class).value()[0];
assertThat(edit).contains("ocean_booking_id=#{e.oceanBookingId}", "container_booking_id=#{e.containerBookingId}",
"loading_plan_id=#{e.loadingPlanId}", "customer_id=#{e.customerId}", "carrier_id=#{e.carrierId}",
"loading_plan_id=#{e.loadingPlanId}", "carrier_id=#{e.carrierId}",
"planned_loading_at=#{e.plannedLoadingAt}", "bill_of_lading_no=#{e.billOfLadingNo}", "WHERE id=#{e.id} AND version=#{version}",
"version=version+1", "revision=revision+1");
assertThat(edit).doesNotContain("customer_id", "customer_name", "customerId", "customerName");
}
}
@@ -236,6 +236,57 @@ class ContainerServiceTest {
.isInstanceOf(BizException.class).hasMessageContaining("已存在");
}
@Test
@DisplayName("同一订舱箱型数量允许绑定多个不同真实柜号")
void linksMultipleRealContainersToTheSameReleasedBookingRequirement() {
ContainerBookingEntity first = booking("WAIT_LOADING", 2);
first.setId(11L); first.setContainerNo(null); first.setWarehouseId(101L);
first.setChannelCode("SEA-US"); first.setContainerType("40HC");
ContainerBookingEntity second = booking("WAIT_LOADING", 4);
second.setId(12L); second.setContainerNo(null); second.setWarehouseId(101L);
second.setChannelCode("SEA-US"); second.setContainerType("40HC");
OceanBookingEntity oceanBooking = new OceanBookingEntity();
oceanBooking.setId(21L); oceanBooking.setStatus("RELEASED");
oceanBooking.setWarehouseId(101L); oceanBooking.setChannelCode("SEA-US");
OceanBookingContainerRequirementEntity requirement = new OceanBookingContainerRequirementEntity();
requirement.setId(31L); requirement.setQuantity(2); requirement.setContainerType("40HC");
when(repository.find(11L)).thenReturn(first);
when(repository.find(12L)).thenReturn(second);
when(oceanBookings.find(21L)).thenReturn(oceanBooking);
when(oceanBookings.requirement(21L, 31L)).thenReturn(requirement);
when(oceanBookings.activeLinkedCount(21L, 31L)).thenReturn(0L, 1L);
when(repository.linkReleasedBooking(eq(11L), eq(21L), eq(31L), eq("MSCU6639870"),
same(oceanBooking), eq(2), eq(9L), eq("admin"))).thenReturn(1);
when(repository.linkReleasedBooking(eq(12L), eq(21L), eq(31L), eq("TSTU1503306"),
same(oceanBooking), eq(4), eq(9L), eq("admin"))).thenReturn(1);
service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(21L, 31L, "MSCU6639870", 2));
service.linkOceanBooking(12L, 9L, "admin",
new OceanBookingLinkRequest(21L, 31L, "TSTU1503306", 4));
ArgumentCaptor<OceanBookingContainerAllocationEntity> allocations =
ArgumentCaptor.forClass(OceanBookingContainerAllocationEntity.class);
verify(oceanBookings, org.mockito.Mockito.times(2)).insert(allocations.capture());
assertThat(allocations.getAllValues()).extracting(OceanBookingContainerAllocationEntity::getContainerBookingId)
.containsExactly(11L, 12L);
}
@Test
@DisplayName("已经归属其他订舱的真实柜不能改绑")
void rejectsContainerAlreadyOwnedByAnotherBooking() {
ContainerBookingEntity container = booking("WAIT_LOADING", 2);
container.setContainerNo(null);
container.setOceanBookingId(22L);
when(repository.find(11L)).thenReturn(container);
assertThatThrownBy(() -> service.linkOceanBooking(11L, 9L, "admin",
new OceanBookingLinkRequest(21L, 31L, "MSCU6639870", 2)))
.isInstanceOf(BizException.class).hasMessageContaining("已关联真实订舱柜");
verifyNoInteractions(oceanBookings);
}
@Test
@DisplayName("首次关联拒绝装柜仓库不一致")
void rejectsLinkWhenWarehouseDiffers() {
@@ -3,6 +3,7 @@ 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.AttachmentRequest;
import com.baiyue.firstleg.dto.TruckAppointmentDtos.SaveRequest;
import com.baiyue.firstleg.entity.TruckAppointmentEntity;
import com.baiyue.firstleg.entity.TruckAppointmentAttachmentEntity;
@@ -17,7 +18,6 @@ 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.oceanbooking.service.OceanBookingDocumentService;
import com.baiyue.firstleg.client.IamCustomerSnapshotClient;
import com.baiyue.firstleg.client.TruckAppointmentFileClient;
import com.baiyue.firstleg.client.WarehouseSupplierClient;
import com.baiyue.common.core.api.ApiResponse;
@@ -28,6 +28,7 @@ import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.mockito.Spy;
import org.springframework.dao.DuplicateKeyException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;
@@ -46,7 +47,6 @@ class TruckAppointmentServiceTest {
@Mock SmartLoadingRepository loadingPlans;
@Mock ContainerRepository containers;
@Mock WarehouseSupplierSnapshotService suppliers;
@Mock IamCustomerSnapshotService customers;
@Mock TruckAppointmentFileClient files;
@Mock OceanBookingDocumentService oceanBookingDocuments;
@Mock FirstlegDocumentNumberGenerator documentNumbers;
@@ -56,9 +56,6 @@ class TruckAppointmentServiceTest {
@BeforeEach void setUp() { MockitoAnnotations.openMocks(this);
service.internalToken = "test-internal-token";
lenient().when(documentNumbers.next("TA")).thenReturn("TA-260818-0001");
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", java.util.List.of()));
lenient().when(repository.attachments(anyLong())).thenReturn(List.of());
@@ -102,7 +99,6 @@ class TruckAppointmentServiceTest {
@Test void createRejectsMissingBookingAndContainer() {
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);
@@ -127,11 +123,70 @@ class TruckAppointmentServiceTest {
verifyNoInteractions(files);
}
@Test void createRejectsAnotherActiveAppointmentForTheSameRealContainerWithoutWriting() {
SaveRequest request = validRequest();
when(repository.activeForUpdate(88L)).thenReturn(List.of(entity(TruckAppointmentStatus.DRAFT, 0)));
assertThatThrownBy(() -> service.create(7L, "admin", request))
.isInstanceOf(BizException.class).hasMessageContaining("已有有效拖车预约");
verify(repository, never()).insert(any());
verify(repository, never()).history(any());
}
@Test void concurrentCreateUniqueCollisionBecomesBusinessConflictWithoutHistory() {
SaveRequest request = validRequest();
doThrow(new DuplicateKeyException("Duplicate entry for key 'uk_truck_active_container'"))
.when(repository).insert(any());
assertThatThrownBy(() -> service.create(7L, "admin", request))
.isInstanceOf(BizException.class).hasMessageContaining("已有有效拖车预约");
verify(repository, never()).history(any());
}
@Test void createPreservesMaximumChineseRemarkAndBoundaryValues() {
SaveRequest request = validRequest();
String remark = "报".repeat(1000);
when(request.remark()).thenReturn(remark);
when(request.packageCount()).thenReturn(0);
when(request.palletCount()).thenReturn(0);
when(request.grossWeightKg()).thenReturn(java.math.BigDecimal.ZERO);
when(request.volumeCbm()).thenReturn(java.math.BigDecimal.ZERO);
when(request.transitMinutes()).thenReturn(0);
service.create(7L, "admin", request);
ArgumentCaptor<TruckAppointmentEntity> inserted = ArgumentCaptor.forClass(TruckAppointmentEntity.class);
verify(repository).insert(inserted.capture());
assertThat(inserted.getValue().getRemark()).hasSize(1000).isEqualTo(remark);
assertThat(inserted.getValue().getPackageCount()).isZero();
assertThat(inserted.getValue().getPalletCount()).isZero();
assertThat(inserted.getValue().getGrossWeightKg()).isZero();
assertThat(inserted.getValue().getVolumeCbm()).isZero();
}
@Test void platformOwnedAttachmentUsesZeroTenantWithoutCustomerSnapshot() {
TruckAppointmentEntity appointment = entity(TruckAppointmentStatus.DRAFT, 0);
when(repository.find(1L)).thenReturn(appointment);
when(files.metadata("test-internal-token", 0L, "TRUCK_APPOINTMENT_ATTACHMENT", "1", 77L))
.thenReturn(ApiResponse.success(new TruckAppointmentFileClient.Metadata(77L, 0L,
"TRUCK_APPOINTMENT_ATTACHMENT", "1", "派车资料.pdf", "abc", "application/pdf", 12L)));
TruckAppointmentAttachmentEntity result = service.attach(1L,
new AttachmentRequest(77L, "DISPATCH_ORDER", "ignored", "ignored"), 9L);
assertThat(result.getFileId()).isEqualTo(77L);
assertThat(result.getVisibility()).isEqualTo("PLATFORM");
verify(files).metadata("test-internal-token", 0L, "TRUCK_APPOINTMENT_ATTACHMENT", "1", 77L);
verify(repository).attachment(result);
}
@Test void createFreezesReleasedBookingBillAndRejectsClientMismatch() {
SaveRequest request = validRequest();
when(request.oceanBookingId()).thenReturn(99L);
OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(99L); booking.setStatus("RELEASED"); booking.setCustomerId(7L);
booking.setId(99L); booking.setStatus("RELEASED");
booking.setBillOfLadingNo(" BL-BOOKING-1 ");
when(oceanBookings.find(99L)).thenReturn(booking);
@@ -152,39 +207,26 @@ class TruckAppointmentServiceTest {
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(88L);
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);
OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(99L); booking.setStatus("CONFIRMED"); booking.setWarehouseId(18L);
when(oceanBookings.find(99L)).thenReturn(booking);
assertThatThrownBy(() -> service.create(7L, "admin", request))
.isInstanceOf(BizException.class).hasMessageContaining("已放舱");
verify(repository, never()).insert(any());
}
@Test void createRejectsBookingFromAnotherCustomer() {
SaveRequest request = validRequest();
when(request.oceanBookingId()).thenReturn(99L);
OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(99L); booking.setStatus("RELEASED"); booking.setCustomerId(17L); booking.setBillOfLadingNo("BL-1");
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.setStatus("RELEASED"); booking.setCustomerId(7L); booking.setWarehouseId(18L); booking.setBillOfLadingNo("BL-1");
booking.setId(99L); booking.setStatus("RELEASED"); booking.setWarehouseId(18L); booking.setBillOfLadingNo("BL-1");
ContainerBookingEntity container = new ContainerBookingEntity();
container.setId(88L); container.setRecordStatus("ACTIVE"); container.setCustomerId(7L); container.setWarehouseId(18L); container.setOceanBookingId(99L); container.setContainerNo("MSCU6639870");
container.setId(88L); container.setRecordStatus("ACTIVE"); container.setWarehouseId(18L); container.setOceanBookingId(99L); container.setContainerNo("MSCU6639870");
when(oceanBookings.find(99L)).thenReturn(booking);
when(containers.find(88L)).thenReturn(container);
when(oceanBookings.allocations(99L)).thenReturn(List.of());
@@ -213,7 +255,7 @@ class TruckAppointmentServiceTest {
@Test void editCanSwitchOnlyToAnotherMatchedReleasedBookingAndContainer() {
TruckAppointmentEntity existing = entity(TruckAppointmentStatus.DRAFT, 3);
existing.setCustomerId(7L); existing.setOceanBookingId(99L); existing.setContainerBookingId(88L);
existing.setOceanBookingId(99L); existing.setContainerBookingId(88L);
when(repository.find(1L)).thenReturn(existing, existing);
SaveRequest request = validRequest();
when(request.expectedVersion()).thenReturn(3);
@@ -258,7 +300,6 @@ class TruckAppointmentServiceTest {
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);
@@ -283,7 +324,6 @@ class TruckAppointmentServiceTest {
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);
@@ -335,6 +375,40 @@ class TruckAppointmentServiceTest {
verify(repository, never()).history(any());
}
@Test void concurrentSameKeyBeforeFirstCompletionReturnsRetryableConflictWithoutDrift() throws Exception {
var request = new ActionRequest(0, "并发提交");
String hash = requestHash("SUBMIT", 1L, request);
TruckAppointmentOperationEntity processing = new TruckAppointmentOperationEntity();
processing.setAppointmentId(1L); processing.setAction("SUBMIT");
processing.setIdempotencyKey("concurrent-submit"); processing.setRequestHash(hash);
when(repository.claimOperation(anyLong(), eq(1L), eq("SUBMIT"), eq("concurrent-submit"), eq(hash)))
.thenReturn(false);
when(repository.operation("concurrent-submit")).thenReturn(processing);
assertThatThrownBy(() -> service.action(1L, "SUBMIT", request, "concurrent-submit", hash, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("正在处理中");
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt());
verify(repository, never()).history(any());
verify(repository, never()).completeOperation(anyLong(), anyString(), anyString(), anyString());
}
@Test void sameIdempotencyKeyWithDifferentPayloadConflictsWithoutDrift() {
TruckAppointmentOperationEntity existing = new TruckAppointmentOperationEntity();
existing.setAppointmentId(1L); existing.setAction("SUBMIT");
existing.setIdempotencyKey("reused-key"); existing.setRequestHash("different-hash");
when(repository.claimOperation(anyLong(), eq(1L), eq("SUBMIT"), eq("reused-key"), anyString()))
.thenReturn(false);
when(repository.operation("reused-key")).thenReturn(existing);
assertThatThrownBy(() -> service.action(1L, "SUBMIT", new ActionRequest(0, "载荷A"),
"reused-key", null, 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("不同请求");
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt());
verify(repository, never()).history(any());
}
@Test void dispatchRequiresBookingBillAndActiveBookingOwnedDocument() {
TruckAppointmentEntity missingBill = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1);
missingBill.setOceanBookingId(99L); missingBill.setContainerBookingId(88L);
@@ -349,7 +423,6 @@ class TruckAppointmentServiceTest {
TruckAppointmentEntity missingAttachment = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1);
missingAttachment.setOceanBookingId(99L); missingAttachment.setContainerBookingId(88L); missingAttachment.setBillOfLadingNo("BL-BOOKING-1");
missingAttachment.setCustomerId(7L);
when(oceanBookings.find(99L)).thenReturn(releasedBooking());
when(repository.claimOperation(anyLong(), eq(1L), eq("DISPATCH"), eq("dispatch-no-file"), anyString())).thenReturn(true);
when(repository.find(1L)).thenReturn(missingAttachment);
@@ -364,9 +437,9 @@ class TruckAppointmentServiceTest {
@Test void dispatchFreezesReleasedBookingBillAndTransitionsOnceWithActiveAttachment() {
TruckAppointmentEntity before = entity(TruckAppointmentStatus.PENDING_DISPATCH, 1);
before.setCustomerId(7L); before.setOceanBookingId(99L); before.setContainerBookingId(88L); before.setBillOfLadingNo(null);
before.setOceanBookingId(99L); before.setContainerBookingId(88L); before.setBillOfLadingNo(null);
TruckAppointmentEntity after = entity(TruckAppointmentStatus.DISPATCHED, 2);
after.setCustomerId(7L); after.setOceanBookingId(99L); after.setContainerBookingId(88L); after.setBillOfLadingNo("BL-BOOKING-1");
after.setOceanBookingId(99L); after.setContainerBookingId(88L); after.setBillOfLadingNo("BL-BOOKING-1");
OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(99L); booking.setStatus("RELEASED"); booking.setBillOfLadingNo("BL-BOOKING-1");
when(repository.claimOperation(anyLong(), eq(1L), eq("DISPATCH"), eq("dispatch-ok"), anyString())).thenReturn(true);
@@ -495,7 +568,6 @@ class TruckAppointmentServiceTest {
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(99L);
@@ -507,14 +579,14 @@ class TruckAppointmentServiceTest {
private static OceanBookingEntity releasedBooking() {
OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(99L); booking.setStatus("RELEASED"); booking.setCustomerId(7L);
booking.setId(99L); booking.setStatus("RELEASED");
booking.setWarehouseId(18L); booking.setBillOfLadingNo("BL-BOOKING-1");
return booking;
}
private static ContainerBookingEntity realContainer() {
ContainerBookingEntity container = new ContainerBookingEntity();
container.setId(88L); container.setRecordStatus("ACTIVE"); container.setCustomerId(7L);
container.setId(88L); container.setRecordStatus("ACTIVE");
container.setWarehouseId(18L); container.setOceanBookingId(99L); container.setContainerNo("MSCU6639870");
return container;
}