Merge remote-tracking branch 'origin/dev' into dev
This commit is contained in:
+6
-3
@@ -62,8 +62,9 @@ class ContainerControllerContractTest {
|
||||
.contains("首次关联允许 WAIT_LOADING 或已有预排货的 EDITING 模拟柜")
|
||||
.contains("RELEASED oceanBookingId")
|
||||
.contains("未满配 requirementId")
|
||||
.contains("唯一 containerNo")
|
||||
.contains("校验ISO 6346结构、设备类别和校验位")
|
||||
.contains("containerNo 可空")
|
||||
.contains("提柜后由拖车链调用柜次服务原子补录真实柜号")
|
||||
.contains("校验 ISO 6346 结构、设备类别、校验位和全局唯一性")
|
||||
.doesNotContain("/api/platform/v1/containers/{id}/transport:")
|
||||
.doesNotContain("ContainerOceanBookingUnlinkRequest")
|
||||
.doesNotContain("ContainerTransportUpdateRequest")
|
||||
@@ -81,7 +82,9 @@ class ContainerControllerContractTest {
|
||||
assertThat(openapi)
|
||||
.contains("/api/platform/v1/containers/{id}/clearance/document/{fileId}/download:")
|
||||
.contains("Content-Disposition 与 Cache-Control no-store")
|
||||
.contains("required: [oceanBookingId, requirementId, containerNo, expectedVersion]")
|
||||
.contains("required: [oceanBookingId, requirementId, expectedVersion]")
|
||||
.contains("containerNo: {type: string, nullable: true")
|
||||
.doesNotContain("required: [oceanBookingId, requirementId, containerNo, expectedVersion]")
|
||||
.contains("required: [id, bookingNo, cabinetNo, sourceType, recordStatus")
|
||||
.contains("sourceType: {type: string, enum: [SIMULATION, OCEAN_BOOKING]")
|
||||
.contains("recordStatus: {type: string, enum: [ACTIVE, MERGED]")
|
||||
|
||||
+3
-1
@@ -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 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 schemaHasPlatformAggregateWithoutCustomerOrReceivableColumns() 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)","chk_truck_appointment_currency CHECK(currency IN ('RMB','USD'))").doesNotContain("customer_id", "customer_name", "idx_truck_appointment_customer", "receivable_amount");}
|
||||
@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,8 +30,10 @@ 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 openApiDocumentsActionableDuplicateCreateConflict() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("A0409","existing appointment number","continue editing","before booking-document validation","database unique key");}
|
||||
@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 openApiExposesOnlyPayableWithFixedCurrencies() throws Exception {String s=new ClassPathResource("openapi/truck-appointments-v1.yaml").getContentAsString(StandardCharsets.UTF_8);assertThat(s).contains("payableAmount:","enum: [RMB, USD]","default: RMB").doesNotContain("receivableAmount");}
|
||||
@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"),
|
||||
|
||||
+51
@@ -3,6 +3,7 @@ package com.baiyue.firstleg.customs.container;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.firstleg.customs.container.dto.ContainerCustomsDtos.ActionRequest;
|
||||
import com.baiyue.firstleg.customs.container.dto.ContainerCustomsDtos.ScopeRequest;
|
||||
import com.baiyue.firstleg.customs.container.mapper.ContainerCustomsMapper;
|
||||
import com.baiyue.firstleg.customs.container.service.ContainerCustomsService;
|
||||
import com.baiyue.firstleg.customs.client.CustomsFileClient;
|
||||
@@ -108,6 +109,35 @@ class ContainerCustomsServiceTest {
|
||||
verify(mapper,never()).freezeScopes(anyLong());
|
||||
}
|
||||
|
||||
@Test void declaringContainerFreezesUploadReplacementScopeAndDeletionWithoutDrift(){
|
||||
var inbound=new ContainerCustomsMapper.InboundRow(9L,"LP9",8L,"COMPLETED","CAB","C",11L,21L,"O","I",7L,"客户","TAX_REFUND");
|
||||
var declaring=container(ContainerCustomsService.DECLARING,3);
|
||||
var source=document(1L);
|
||||
var file=new MockMultipartFile("file","source.bin","application/octet-stream",new byte[]{1});
|
||||
when(mapper.planInbounds(9L)).thenReturn(List.of(inbound));
|
||||
when(mapper.container(9L)).thenReturn(declaring);
|
||||
when(mapper.document(1L)).thenReturn(source);
|
||||
when(mapper.lockContainerById(2L)).thenReturn(declaring);
|
||||
|
||||
assertThatThrownBy(()->service.uploadSource(7L,9L,5L,"upload-after-start",List.of(11L),file))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("资料已锁定");
|
||||
assertThatThrownBy(()->service.replaceSource(7L,1L,5L,"replace-after-start",new ScopeRequest(List.of(11L),1),file))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("资料已锁定");
|
||||
assertThatThrownBy(()->service.updateScope(7L,1L,5L,"scope-after-start",new ScopeRequest(List.of(11L),1)))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("资料已锁定");
|
||||
assertThatThrownBy(()->service.deleteSource(7L,1L,5L,"delete-after-start",1))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("资料已锁定");
|
||||
|
||||
verifyNoInteractions(files);
|
||||
verify(mapper,never()).insertDocument(anyLong(),anyLong(),anyLong(),anyLong(),anyString(),anyString(),anyString(),anyLong(),anyLong());
|
||||
verify(mapper,never()).replaceDocument(anyLong(),anyLong(),anyInt(),anyLong(),anyString(),anyString(),anyString(),anyLong(),anyLong());
|
||||
verify(mapper,never()).touchScope(anyLong(),anyLong(),anyInt());
|
||||
verify(mapper,never()).deleteDocument(anyLong(),anyLong(),anyInt(),anyLong());
|
||||
verify(mapper,never()).insertDocumentVersion(anyLong(),anyLong(),anyInt(),anyLong(),anyString(),anyString(),anyString(),anyLong(),anyString(),anyString(),anyLong());
|
||||
assertThat(source.version()).isEqualTo(1);
|
||||
assertThat(source.status()).isEqualTo("ACTIVE");
|
||||
}
|
||||
|
||||
@Test void concurrentStartWithSameKeyReplaysOneAggregateAndDifferentPayloadConflicts() throws Exception {
|
||||
var row=new ContainerCustomsMapper.InboundRow(9L,"LP9",8L,"COMPLETED","CAB","C",11L,21L,"O","I",7L,"客户","TAX_REFUND");
|
||||
var ready=container(ContainerCustomsService.READY,2);
|
||||
@@ -271,6 +301,27 @@ class ContainerCustomsServiceTest {
|
||||
verify(mapper,never()).complete(anyLong(),anyInt(),anyLong(),anyString());
|
||||
}
|
||||
|
||||
@Test void allBuyContainerCompletesOnceAndPublishesAutoDispatchTrigger(){
|
||||
var declaring=container(ContainerCustomsService.DECLARING,3);
|
||||
var completed=container(ContainerCustomsService.COMPLETED,4);
|
||||
var buy=new ContainerCustomsMapper.InboundRow(9L,"LP9",8L,"COMPLETED","CAB","C",11L,21L,"O","I",7L,"客户","BUY_DECLARATION");
|
||||
when(mapper.claimCommand(anyLong(),eq(5L),eq("COMPLETE_ALL_BUY_CUSTOMS_9"),eq("all-buy-key"),anyString())).thenReturn(1);
|
||||
when(mapper.lockContainer(9L)).thenReturn(declaring);
|
||||
when(mapper.planInbounds(9L)).thenReturn(List.of(buy));
|
||||
when(mapper.complete(2L,3,5L,"admin")).thenReturn(1);
|
||||
when(mapper.completeCommand(5L,"COMPLETE_ALL_BUY_CUSTOMS_9","all-buy-key",2L)).thenReturn(1);
|
||||
when(mapper.container(9L)).thenReturn(completed);
|
||||
when(mapper.documents(2L,null)).thenReturn(List.of());
|
||||
|
||||
var result=service.completeAllBuy(9L,5L,"admin","all-buy-key",new ActionRequest(3,true,"代理已完成"));
|
||||
|
||||
assertThat(result.status()).isEqualTo(ContainerCustomsService.COMPLETED);
|
||||
verify(mapper,times(1)).complete(2L,3,5L,"admin");
|
||||
verify(mapper,times(1)).insertHistory(anyLong(),eq(2L),eq("COMPLETE_CUSTOMS"),
|
||||
eq(ContainerCustomsService.DECLARING),eq(ContainerCustomsService.COMPLETED),eq("代理已完成"),eq(5L),eq("admin"));
|
||||
verify(events,times(1)).publishEvent(new ContainerCustomsService.CustomsCompletedEvent(8L));
|
||||
}
|
||||
|
||||
private static ContainerCustomsMapper.DocumentRow document(long id){return new ContainerCustomsMapper.DocumentRow(id,2L,7L,10L,"source.bin","a".repeat(64),"application/octet-stream",1L,"ACTIVE",5L,java.time.LocalDateTime.now(),1,java.time.LocalDateTime.now(),1);}
|
||||
private static ContainerCustomsMapper.ContainerRow container(String status,int version){return new ContainerCustomsMapper.ContainerRow(2L,9L,8L,status,null,null,null,null,null,null,null,null,version);}
|
||||
private void assertMerchantOrderNotFound(long tenantId,long orderId){
|
||||
|
||||
+74
@@ -14,8 +14,10 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
@SpringJUnitConfig(LoadingDeliveryService.class)
|
||||
class LoadingDeliveryServiceTest {
|
||||
@@ -45,4 +47,76 @@ class LoadingDeliveryServiceTest {
|
||||
|
||||
verify(cabinetEvidence).requireComplete(99L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void pdaFinalizeUsesSourceVersionIncrementedByFullSettlement() throws Exception {
|
||||
Snapshot refreshed = new Snapshot(20L, 10L, "LOADING", 8);
|
||||
when(repository.completionTarget(10L, 20L)).thenReturn(null);
|
||||
when(repository.findInboundForUpdate(10L, 20L)).thenReturn(refreshed);
|
||||
when(repository.claimOperation(eq(10L), eq(20L), eq(9L), eq("COMPLETE_LOADING"),
|
||||
eq("pda-full"), anyString())).thenReturn(true);
|
||||
when(repository.findInbound(10L, 20L)).thenReturn(refreshed);
|
||||
when(loadingPicks.inboundProgress(10L, 20L)).thenReturn(resolvedProgress());
|
||||
when(cabinetEvidence.requireContainerBookingId(10L, 20L)).thenReturn(99L);
|
||||
when(repository.completeLoading(10L, 20L, 9L, 8)).thenReturn(1);
|
||||
when(objectMapper.writeValueAsString(org.mockito.ArgumentMatchers.any())).thenReturn("result");
|
||||
|
||||
service.completeAfterPdaSettlement(10L, 20L, 9L, "operator", "pda-full", "done");
|
||||
|
||||
verify(repository).completeLoading(10L, 20L, 9L, 8);
|
||||
}
|
||||
|
||||
@Test
|
||||
void pdaFinalizeLocksAndUsesLoadedSliceVersionAfterPartialSettlement() throws Exception {
|
||||
var target = new com.baiyue.firstleg.loading.mapper.LoadingInboundMapper.CompletionTarget(
|
||||
20L, 21L, 22L, "LOADING", 6, "LOADING", 9);
|
||||
Snapshot source = new Snapshot(20L, 10L, "LOADING", 6);
|
||||
Snapshot loadedSlice = new Snapshot(21L, 10L, "LOADING", 9);
|
||||
when(repository.completionTarget(10L, 20L)).thenReturn(target);
|
||||
when(repository.findInboundForUpdate(10L, 21L)).thenReturn(loadedSlice);
|
||||
when(repository.claimOperation(eq(10L), eq(20L), eq(9L), eq("COMPLETE_LOADING"),
|
||||
eq("pda-partial"), anyString())).thenReturn(true);
|
||||
when(repository.findInbound(10L, 20L)).thenReturn(source);
|
||||
when(loadingPicks.inboundProgress(10L, 20L)).thenReturn(resolvedProgress());
|
||||
when(cabinetEvidence.requireContainerBookingId(10L, 20L)).thenReturn(99L);
|
||||
when(repository.completeLoadedChild(10L, 20L, 9L, 9)).thenReturn(1);
|
||||
when(objectMapper.writeValueAsString(org.mockito.ArgumentMatchers.any())).thenReturn("result");
|
||||
|
||||
service.completeAfterPdaSettlement(10L, 20L, 9L, "operator", "pda-partial", "done");
|
||||
|
||||
verify(repository).findInboundForUpdate(10L, 21L);
|
||||
verify(repository).completeLoadedChild(10L, 20L, 9L, 9);
|
||||
}
|
||||
|
||||
@Test
|
||||
void pdaFinalizeKeepsOptimisticConflictForRealConcurrentChange() {
|
||||
Snapshot refreshed = new Snapshot(20L, 10L, "LOADING", 8);
|
||||
when(repository.completionTarget(10L, 20L)).thenReturn(null);
|
||||
when(repository.findInboundForUpdate(10L, 20L)).thenReturn(refreshed);
|
||||
when(repository.claimOperation(eq(10L), eq(20L), eq(9L), eq("COMPLETE_LOADING"),
|
||||
eq("pda-conflict"), anyString())).thenReturn(true);
|
||||
when(repository.findInbound(10L, 20L)).thenReturn(refreshed);
|
||||
when(loadingPicks.inboundProgress(10L, 20L)).thenReturn(resolvedProgress());
|
||||
when(cabinetEvidence.requireContainerBookingId(10L, 20L)).thenReturn(99L);
|
||||
when(repository.completeLoading(10L, 20L, 9L, 8)).thenReturn(0);
|
||||
try {
|
||||
when(objectMapper.writeValueAsString(org.mockito.ArgumentMatchers.any())).thenReturn("request");
|
||||
} catch (com.fasterxml.jackson.core.JsonProcessingException exception) {
|
||||
throw new AssertionError(exception);
|
||||
}
|
||||
|
||||
assertThatThrownBy(() -> service.completeAfterPdaSettlement(10L, 20L, 9L, "operator",
|
||||
"pda-conflict", "done")).isInstanceOf(com.baiyue.common.core.exception.BizException.class);
|
||||
|
||||
verify(repository).completeLoading(10L, 20L, 9L, 8);
|
||||
verify(histories, never()).insert(org.mockito.ArgumentMatchers
|
||||
.any(com.baiyue.firstleg.entity.InboundStatusHistoryEntity.class));
|
||||
}
|
||||
|
||||
private static InboundProgress resolvedProgress() {
|
||||
InboundProgress progress = new InboundProgress();
|
||||
progress.setExpectedCount(2); progress.setPickedCount(2); progress.setTaskCount(1);
|
||||
progress.setCompletedTaskCount(0); progress.setUnresolvedCount(0);
|
||||
return progress;
|
||||
}
|
||||
}
|
||||
|
||||
+52
@@ -67,6 +67,37 @@ class OceanBookingDocumentServiceTest {
|
||||
verify(repository, times(1)).insert(any(OceanBookingDocumentEntity.class));
|
||||
}
|
||||
|
||||
@Test void draftAcceptsBookingConfirmationAndBillOfLadingAsTwoVersionedFiles() {
|
||||
MockMultipartFile confirmation = new MockMultipartFile("file", "confirmation.pdf",
|
||||
"application/pdf", "confirmation".getBytes());
|
||||
MockMultipartFile bill = new MockMultipartFile("file", "bill.pdf",
|
||||
"application/pdf", "bill".getBytes());
|
||||
when(repository.claimOperation(anyLong(), eq(7L), eq("UPLOAD_DOCUMENT"), anyString(), anyString()))
|
||||
.thenReturn(true);
|
||||
when(repository.findForUpdate(10L)).thenReturn(draft(0), draft(1));
|
||||
when(files.upload("token", 7L, 9L, "OCEAN_BOOKING_DOCUMENT", "10", confirmation))
|
||||
.thenReturn(ApiResponse.success(metadata(91L)));
|
||||
when(files.upload("token", 7L, 9L, "OCEAN_BOOKING_DOCUMENT", "10", bill))
|
||||
.thenReturn(ApiResponse.success(metadata(92L)));
|
||||
when(repository.touchDraft(eq(10L), anyInt(), eq(7L), eq("admin"))).thenReturn(1);
|
||||
when(repository.completeOperation(eq(10L), eq(7L), eq("UPLOAD_DOCUMENT"), anyString(), anyString()))
|
||||
.thenReturn(1);
|
||||
|
||||
var first = service.upload(10L, 0, "BOOKING_CONFIRMATION", "confirmation-key",
|
||||
confirmation, 7L, "admin");
|
||||
var second = service.upload(10L, 1, "BILL_OF_LADING", "bill-key", bill, 7L, "admin");
|
||||
|
||||
assertThat(first.bookingVersion()).isEqualTo(1);
|
||||
assertThat(second.bookingVersion()).isEqualTo(2);
|
||||
ArgumentCaptor<OceanBookingDocumentEntity> documents =
|
||||
ArgumentCaptor.forClass(OceanBookingDocumentEntity.class);
|
||||
verify(repository, times(2)).insert(documents.capture());
|
||||
assertThat(documents.getAllValues()).extracting(OceanBookingDocumentEntity::getDocumentType)
|
||||
.containsExactly("BOOKING_CONFIRMATION", "BILL_OF_LADING");
|
||||
assertThat(documents.getAllValues()).extracting(OceanBookingDocumentEntity::getFileId)
|
||||
.containsExactly(91L, 92L);
|
||||
}
|
||||
|
||||
@Test void attachRejectsCrossScopeFileBeforePersisting() {
|
||||
when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
|
||||
when(repository.findForUpdate(10L)).thenReturn(draft(1));
|
||||
@@ -121,6 +152,27 @@ class OceanBookingDocumentServiceTest {
|
||||
verify(files).metadata("token", 9L, "OCEAN_BOOKING_DOCUMENT", "10", 91L);
|
||||
}
|
||||
|
||||
@Test void releaseGateRejectsWhenBothActiveFileReferencesNoLongerExist() {
|
||||
OceanBookingEntity booking = draft(3);
|
||||
OceanBookingDocumentEntity confirmation = activeDocument(20L, 91L, "BOOKING_CONFIRMATION");
|
||||
OceanBookingDocumentEntity bill = activeDocument(21L, 92L, "BILL_OF_LADING");
|
||||
when(repository.activeDocuments(10L)).thenReturn(List.of(confirmation, bill));
|
||||
|
||||
assertThatThrownBy(() -> service.requireActiveValidDocument(booking))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("有效订舱确认或提单文件");
|
||||
|
||||
verify(files).metadata("token", 9L, "OCEAN_BOOKING_DOCUMENT", "10", 91L);
|
||||
verify(files).metadata("token", 9L, "OCEAN_BOOKING_DOCUMENT", "10", 92L);
|
||||
verify(repository, never()).touchDraft(anyLong(), anyInt(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
private static OceanBookingDocumentEntity activeDocument(long id, long fileId, String type) {
|
||||
OceanBookingDocumentEntity document = new OceanBookingDocumentEntity();
|
||||
document.setId(id); document.setOceanBookingId(10L); document.setFileId(fileId);
|
||||
document.setDocumentType(type); document.setValid(true);
|
||||
return document;
|
||||
}
|
||||
|
||||
private static OceanBookingEntity draft(int version) {
|
||||
OceanBookingEntity booking = new OceanBookingEntity();
|
||||
booking.setId(10L); booking.setCustomerId(9L); booking.setStatus("DRAFT"); booking.setVersion(version);
|
||||
|
||||
+11
-2
@@ -75,14 +75,17 @@ class ContainerBookingMapperContractTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("货柜草稿更新不得形成真实柜号或内部柜次号写入口")
|
||||
void onlyOceanBookingLinkWritesInitialContainerNumber() throws Exception {
|
||||
@DisplayName("真实柜号只允许订舱关联或提柜后原子补录")
|
||||
void onlyBookingLinkOrPickupAssignmentWritesContainerNumber() throws Exception {
|
||||
String draftSql = String.join("\n", ContainerBookingMapper.class
|
||||
.getDeclaredMethod("updateDraft", ContainerBookingEntity.class, int.class,
|
||||
long.class, String.class).getAnnotation(Update.class).value());
|
||||
String linkSql = String.join("\n", Arrays.stream(ContainerBookingMapper.class.getDeclaredMethods())
|
||||
.filter(method -> method.getName().equals("linkReleasedBooking"))
|
||||
.findFirst().orElseThrow().getAnnotation(Update.class).value());
|
||||
String pickupSql = String.join("\n", ContainerBookingMapper.class
|
||||
.getDeclaredMethod("assignRealContainerNo", long.class, String.class, int.class,
|
||||
long.class, String.class).getAnnotation(Update.class).value());
|
||||
assertThat(draftSql)
|
||||
.contains("capacity_cbm")
|
||||
.contains("p.plan_status!='EDITING'")
|
||||
@@ -92,6 +95,11 @@ class ContainerBookingMapperContractTest {
|
||||
.doesNotContain(">");
|
||||
assertThat(linkSql).contains("container_no=#{containerNo}", "source_type='SIMULATION'",
|
||||
"ocean_booking_id IS NULL");
|
||||
assertThat(pickupSql)
|
||||
.contains("container_no=#{containerNo}", "container_no IS NULL", "ocean_booking_id IS NOT NULL",
|
||||
"ocean_booking_requirement_id IS NOT NULL", "version=#{version}", "fl_loading_pick_task",
|
||||
"fl_loading_cabinet_evidence", "fl_container_document")
|
||||
.doesNotContain("ocean_booking_id=#{");
|
||||
assertThat(Arrays.stream(ContainerBookingMapper.class.getDeclaredMethods()).map(java.lang.reflect.Method::getName))
|
||||
.doesNotContain("updateTransport", "unlinkReleasedBooking");
|
||||
}
|
||||
@@ -105,6 +113,7 @@ class ContainerBookingMapperContractTest {
|
||||
}) {
|
||||
assertThat(source)
|
||||
.contains("国内唯一权威真实集装箱号")
|
||||
.contains("提柜后可原子补录")
|
||||
.contains("UNIQUE KEY uk_container_booking_container_no (container_no)")
|
||||
.contains("CONSTRAINT chk_container_booking_iso6346")
|
||||
.contains("container_no REGEXP '^[A-Z]{3}[UJZ][0-9]{7}$'");
|
||||
|
||||
+31
-2
@@ -78,7 +78,7 @@ class Phase3FinalUnifiedSqlContractTest {
|
||||
|
||||
for (int id = 5102160; id <= 5102164; id++) assertThat(sql).contains(Integer.toString(id));
|
||||
for (int id : java.util.List.of(5102170,5102172)) assertThat(sql).contains(Integer.toString(id));
|
||||
assertThat(sql).contains("DELETE FROM iam_permission WHERE id IN (5102171,5102173,5102174,5102175)")
|
||||
assertThat(sql).contains("DELETE FROM iam_permission WHERE id IN(5102171,5102173,5102174,5102175)")
|
||||
.doesNotContain("(5102171,0,'PLATFORM','BUTTON'","(5102173,0,'PLATFORM','BUTTON'",
|
||||
"(5102174,0,'PLATFORM','BUTTON'","(5102175,0,'PLATFORM','BUTTON'");
|
||||
for (int id = 5102180; id <= 5102183; id++) assertThat(sql).contains(Integer.toString(id));
|
||||
@@ -111,6 +111,35 @@ class Phase3FinalUnifiedSqlContractTest {
|
||||
"iam/src/main/resources/db/phase3-warehouse-roles.sql");
|
||||
}
|
||||
|
||||
@Test
|
||||
void customsBaselineIsUniqueSynchronisedAndRunsBeforeFinalSchemaAssertion() throws Exception {
|
||||
String unified = normalise(Files.readString(resolveUnified()));
|
||||
String marker = "-- 来源:firstleg/src/main/resources/db/phase3-container-customs-schema.sql";
|
||||
String finalAssertion = "CREATE PROCEDURE assert_phase3_final_schema()";
|
||||
List<String> tables = List.of(
|
||||
"fl_customs_container",
|
||||
"fl_customs_customer_document",
|
||||
"fl_customs_customer_document_inbound",
|
||||
"fl_customs_customer_document_version",
|
||||
"fl_customs_frozen_document_inbound",
|
||||
"fl_customs_completion_document",
|
||||
"fl_customs_completion_document_version",
|
||||
"fl_customs_container_history",
|
||||
"fl_customs_command"
|
||||
);
|
||||
|
||||
assertBlock(unified, marker, "/* Phase 3: link inbound preload plans",
|
||||
"firstleg/src/main/resources/db/phase3-container-customs-schema.sql");
|
||||
assertThat(count(unified, marker)).isEqualTo(1);
|
||||
assertThat(unified.indexOf(marker)).isLessThan(unified.indexOf(finalAssertion));
|
||||
for (String table : tables) {
|
||||
String create = "CREATE TABLE IF NOT EXISTS " + table + " (";
|
||||
assertThat(count(unified, create)).as(table).isEqualTo(1);
|
||||
assertThat(unified.indexOf(create)).as(table + " before final assertion")
|
||||
.isLessThan(unified.indexOf(finalAssertion));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void financeFeeSourceMigrationsAreContiguousAndSynchronised() throws Exception {
|
||||
String unified = normalise(Files.readString(resolveUnified()));
|
||||
@@ -155,7 +184,7 @@ class Phase3FinalUnifiedSqlContractTest {
|
||||
.contains("存在缺失中文注释的业务字段")
|
||||
.doesNotContain("CREATE PROCEDURE upgrade_column_comment");
|
||||
assertThat(count(sql, "CALL queue_table_comment(")).isEqualTo(144);
|
||||
assertThat(count(sql, "CALL queue_column_comment(")).isGreaterThanOrEqualTo(2379);
|
||||
assertThat(count(sql, "CALL queue_column_comment(")).isGreaterThanOrEqualTo(2378);
|
||||
assertThat(sql)
|
||||
.contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_id'")
|
||||
.contains("CALL queue_column_comment('baiyue_firstleg', 'fl_inbound_fee_detail', 'supplier_name'")
|
||||
|
||||
+4
@@ -23,6 +23,9 @@ class TruckAppointmentMapperContractTest {
|
||||
String active = TruckAppointmentMapper.class.getMethod("activeForUpdate", long.class).getAnnotation(Select.class).value()[0];
|
||||
assertThat(active).contains("container_booking_id=#{containerBookingId}", "FOR UPDATE")
|
||||
.contains("'COMPLETED','CANCELLED','EXCEPTION'");
|
||||
String firstActive = TruckAppointmentMapper.class.getMethod("firstActiveForUpdate", long.class).getAnnotation(Select.class).value()[0];
|
||||
assertThat(firstActive).contains("container_booking_id=#{containerBookingId}", "ORDER BY created_at,id", "LIMIT 1 FOR UPDATE")
|
||||
.contains("'COMPLETED','CANCELLED','EXCEPTION'");
|
||||
String pdaComplete = TruckAppointmentMapper.class.getMethod("completeLoadingFromPda", long.class, String.class, int.class).getAnnotation(Update.class).value()[0];
|
||||
assertThat(pdaComplete).contains("status='COMPLETED'", "actual_loading_at=COALESCE", "status=#{fromStatus}",
|
||||
"version=#{version}", "revision=revision+1", "version=version+1");
|
||||
@@ -34,5 +37,6 @@ class TruckAppointmentMapperContractTest {
|
||||
"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");
|
||||
assertThat(edit).doesNotContain("receivable_amount", "receivableAmount");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -212,6 +212,58 @@ class ContainerServiceTest {
|
||||
verify(oceanBookings).insert(any(OceanBookingContainerAllocationEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("模拟柜允许不带真实柜号原子关联已发布订舱需求")
|
||||
void linksReleasedBookingWithoutRealContainerNumber() {
|
||||
stubLinkContext();
|
||||
ContainerBookingEntity container = repository.find(11L);
|
||||
when(repository.find(11L)).thenReturn(container, container);
|
||||
when(repository.linkReleasedBooking(eq(11L), eq(21L), eq(31L), isNull(),
|
||||
any(OceanBookingEntity.class), eq(2), eq(9L), eq("admin"))).thenReturn(1);
|
||||
|
||||
ContainerBookingEntity result = service.linkOceanBooking(11L, 9L, "admin",
|
||||
new OceanBookingLinkRequest(21L, 31L, null, 2));
|
||||
|
||||
assertThat(result.getContainerNo()).isNull();
|
||||
assertThat(result.getCabinetNo()).isEqualTo("柜-001");
|
||||
verify(repository, never()).containerNoExists(anyString(), anyLong());
|
||||
verify(repository).linkReleasedBooking(eq(11L), eq(21L), eq(31L), isNull(),
|
||||
any(OceanBookingEntity.class), eq(2), eq(9L), eq("admin"));
|
||||
verify(oceanBookings).insert(any(OceanBookingContainerAllocationEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("提柜后按柜次版本原子补录真实柜号")
|
||||
void assignsRealContainerNumberAfterPickup() {
|
||||
ContainerBookingEntity linked = booking("EDITING", 3);
|
||||
linked.setContainerNo(null); linked.setOceanBookingId(21L); linked.setOceanBookingRequirementId(31L);
|
||||
ContainerBookingEntity assigned = booking("EDITING", 4);
|
||||
assigned.setContainerNo("MSCU6639870"); assigned.setOceanBookingId(21L); assigned.setOceanBookingRequirementId(31L);
|
||||
when(repository.find(11L)).thenReturn(linked, assigned);
|
||||
when(repository.assignRealContainerNo(11L, "MSCU6639870", 3, 9L, "admin")).thenReturn(1);
|
||||
|
||||
ContainerBookingEntity result = service.assignRealContainerNo(
|
||||
11L, " ms cu 663987 0 ", 3, 9L, "admin");
|
||||
|
||||
assertThat(result.getContainerNo()).isEqualTo("MSCU6639870");
|
||||
verify(repository).assignRealContainerNo(11L, "MSCU6639870", 3, 9L, "admin");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("提柜补录真实柜号仍拒绝全局重复号")
|
||||
void rejectsDuplicateRealContainerNumberAfterPickup() {
|
||||
ContainerBookingEntity linked = booking("EDITING", 3);
|
||||
linked.setContainerNo(null); linked.setOceanBookingId(21L); linked.setOceanBookingRequirementId(31L);
|
||||
when(repository.find(11L)).thenReturn(linked);
|
||||
when(repository.containerNoExists("MSCU6639870", 11L)).thenReturn(true);
|
||||
|
||||
assertThatThrownBy(() -> service.assignRealContainerNo(
|
||||
11L, "MSCU6639870", 3, 9L, "admin"))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("已存在");
|
||||
|
||||
verify(repository, never()).assignRealContainerNo(anyLong(), anyString(), anyInt(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("首次关联拒绝错误ISO 6346校验位")
|
||||
void rejectsInvalidContainerNumberCheckDigit() {
|
||||
|
||||
+70
-7
@@ -234,6 +234,38 @@ class InboundMeasurementServiceTest {
|
||||
assertThat(result.confidence()).isEqualByComparingTo("0.50972");
|
||||
}
|
||||
|
||||
@Test
|
||||
void ocrFailureCreatesManualWeightThatCanBeAdjustedAndConfirmed() {
|
||||
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
|
||||
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
|
||||
|
||||
InboundMeasurementResponses.WeightResult created = service.createWeightResult(
|
||||
1L, 10L, 9L,
|
||||
new InboundMeasurementRequests.CreateWeightResultRequest(
|
||||
120L, 501L, "FILE_WEIGHT_OCR", null, null, null, null));
|
||||
|
||||
assertThat(created.recognitionStatus()).isEqualTo("NEEDS_MANUAL");
|
||||
ArgumentCaptor<InboundWeightResultEntity> saved =
|
||||
ArgumentCaptor.forClass(InboundWeightResultEntity.class);
|
||||
verify(repository).insertWeight(saved.capture());
|
||||
InboundWeightResultEntity weight = saved.getValue();
|
||||
weight.setId(20L);
|
||||
when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight);
|
||||
when(repository.updateWeight(weight)).thenReturn(true);
|
||||
when(repository.listWeights(1L, 10L)).thenReturn(List.of(weight));
|
||||
when(repository.updateInboundWeightSummary(inbound)).thenReturn(true);
|
||||
|
||||
InboundMeasurementResponses.WeightResult confirmed = service.confirmWeightResult(
|
||||
1L, 10L, 20L, 9L, "操作员",
|
||||
new InboundMeasurementRequests.ConfirmWeightResultRequest(
|
||||
0, new BigDecimal("30.000"), "KG", "OCR失败后人工确认"));
|
||||
|
||||
assertThat(confirmed.recognitionStatus()).isEqualTo("CONFIRMED");
|
||||
assertThat(confirmed.grossWeightKg()).isEqualByComparingTo("30.000");
|
||||
assertThat(confirmed.netWeightKg()).isEqualByComparingTo("11.500");
|
||||
verify(repository, org.mockito.Mockito.times(2)).insertRevision(any(InboundWeightRevisionEntity.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void weightPalletOptionsUsesInboundWarehouseWithoutCustomerScope() {
|
||||
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
|
||||
@@ -254,6 +286,30 @@ class InboundMeasurementServiceTest {
|
||||
verify(warehousePallets).palletSnapshots("internal-token", 100L, 1L, 20L, "TP-5");
|
||||
}
|
||||
|
||||
@Test
|
||||
void weightPalletOptionsFailsClosedOnWarehouseNon2xxResponse() {
|
||||
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
|
||||
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
|
||||
when(warehousePallets.palletSnapshots("internal-token", 100L, 1L, 20L, "TP-5"))
|
||||
.thenThrow(mock(feign.FeignException.class));
|
||||
|
||||
assertThatThrownBy(() -> service.weightPalletOptions(1L, 10L, 1L, 20L, "TP-5"))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("仓库服务暂不可用");
|
||||
}
|
||||
|
||||
@Test
|
||||
void weightPalletOptionsFailsClosedOnWarehouseNetworkError() {
|
||||
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
|
||||
when(repository.findInbound(1L, 10L)).thenReturn(inbound);
|
||||
when(warehousePallets.palletSnapshots("internal-token", 100L, 1L, 20L, null))
|
||||
.thenThrow(mock(feign.RetryableException.class));
|
||||
|
||||
assertThatThrownBy(() -> service.weightPalletOptions(1L, 10L, 1L, 20L, null))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("仓库服务暂不可用");
|
||||
}
|
||||
|
||||
@Test
|
||||
void creatingWeightResultRejectsPalletOutsideTheInboundWarehouse() {
|
||||
InboundOrderEntity inbound = inbound("WAIT_MEASURE");
|
||||
@@ -775,9 +831,13 @@ class InboundMeasurementServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void editingAndResubmittingInboundMeasurementDoesNotRollBackMainStatus() {
|
||||
void approvedMeasurementCannotBeResubmittedOrMutateFrozenTotals() {
|
||||
InboundOrderEntity inbound = inbound("WAIT_SPLIT");
|
||||
inbound.setMeasureReviewStatus("APPROVED");
|
||||
inbound.setActualGrossWeightKg(new BigDecimal("40.000"));
|
||||
inbound.setActualNetWeightKg(new BigDecimal("30.000"));
|
||||
inbound.setActualVolumeCbm(new BigDecimal("2.0000"));
|
||||
inbound.setVolumeWeightKg(new BigDecimal("334.000"));
|
||||
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
|
||||
measurement.setId(30L);
|
||||
measurement.setStatus("APPROVED");
|
||||
@@ -791,14 +851,17 @@ class InboundMeasurementServiceTest {
|
||||
when(repository.listWeights(1L, 10L)).thenReturn(List.of());
|
||||
when(repository.updateInbound(inbound)).thenReturn(true);
|
||||
|
||||
InboundMeasurementResponses.MeasurementSubmitted result = service.submit(1L, 10L, 9L,
|
||||
new InboundMeasurementRequests.SubmitRequest(30L, 2));
|
||||
assertThatThrownBy(() -> service.submit(1L, 10L, 9L,
|
||||
new InboundMeasurementRequests.SubmitRequest(30L, 2)))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("审核完成");
|
||||
|
||||
assertThat(result.processStatus()).isEqualTo("WAIT_SPLIT");
|
||||
assertThat(result.measureReviewStatus()).isEqualTo("APPROVED");
|
||||
assertThat(measurement.getStatus()).isEqualTo("APPROVED");
|
||||
assertThat(inbound.getActualGrossWeightKg()).isEqualByComparingTo("40.000");
|
||||
assertThat(inbound.getActualNetWeightKg()).isEqualByComparingTo("30.000");
|
||||
assertThat(inbound.getActualVolumeCbm()).isEqualByComparingTo("2.0000");
|
||||
assertThat(inbound.getVolumeWeightKg()).isEqualByComparingTo("334.000");
|
||||
verify(repository, never()).updateMeasurement(any());
|
||||
verify(repository).updateInbound(inbound);
|
||||
verify(repository, never()).updateInbound(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -44,10 +44,13 @@ import org.mockito.InOrder;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
import org.springframework.transaction.TransactionStatus;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
@@ -74,6 +77,8 @@ class LoadingPickServiceTest {
|
||||
@MockBean
|
||||
private LoadingCompletionCommandRepository completionCommands;
|
||||
@MockBean
|
||||
private TransactionTemplate completionTransaction;
|
||||
@MockBean
|
||||
private SmartLoadingRepository smartLoadingRepository;
|
||||
@MockBean
|
||||
private OceanBookingRepository oceanBookingRepository;
|
||||
@@ -100,6 +105,12 @@ class LoadingPickServiceTest {
|
||||
|
||||
@BeforeEach
|
||||
void activeOperationalContainer() {
|
||||
doAnswer(invocation -> {
|
||||
@SuppressWarnings("unchecked")
|
||||
Consumer<TransactionStatus> action = invocation.getArgument(0);
|
||||
action.accept(mock(TransactionStatus.class));
|
||||
return null;
|
||||
}).when(completionTransaction).executeWithoutResult(any());
|
||||
ContainerBookingEntity booking = new ContainerBookingEntity();
|
||||
booking.setId(99L);
|
||||
booking.setContainerNo("CSLU1234567");
|
||||
|
||||
+49
-4
@@ -92,6 +92,7 @@ class MeasurementReviewServiceTest {
|
||||
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
|
||||
item.setPackageCount(5);
|
||||
item.setTotalVolumeCbm(new BigDecimal("0.7480"));
|
||||
item.setVolumeWeightKg(new BigDecimal("124.916"));
|
||||
when(fixture.repository.listMeasurementItems(10001L, 20L)).thenReturn(List.of(item));
|
||||
when(fixture.repository.transitionReview(10001L, 10L, 9L, 3, true, 20L)).thenReturn(1);
|
||||
when(fixture.repository.transitionMeasurement(10001L, 10L, 20L, "APPROVED")).thenReturn(1);
|
||||
@@ -119,6 +120,40 @@ class MeasurementReviewServiceTest {
|
||||
verify(fixture.repository, never()).transitionReview(anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("多SKU测量行的箱数、重量、体积和体积重守恒后才能进入WAIT_SPLIT")
|
||||
void shouldApproveMultipleCargoLinesOnlyWhenAllFrozenTotalsMatch() {
|
||||
Fixture fixture = fixture(false);
|
||||
InboundMeasurementItemEntity first = measurementItem(2, "0.3000", "50.100");
|
||||
InboundMeasurementItemEntity second = measurementItem(3, "0.4480", "74.816");
|
||||
when(fixture.repository.listMeasurementItems(10001L, 20L)).thenReturn(List.of(first, second));
|
||||
when(fixture.repository.transitionReview(10001L, 10L, 9L, 3, true, 20L)).thenReturn(1);
|
||||
when(fixture.repository.transitionMeasurement(10001L, 10L, 20L, "APPROVED")).thenReturn(1);
|
||||
|
||||
fixture.service.approve(10001L, 9L, "审核员", 10L, "approve-multi-sku",
|
||||
new MeasurementReviewRequest(20L, 3, null));
|
||||
|
||||
ArgumentCaptor<InboundStatusHistoryEntity> history =
|
||||
ArgumentCaptor.forClass(InboundStatusHistoryEntity.class);
|
||||
verify(fixture.repository).insertHistory(history.capture());
|
||||
assertThat(history.getValue().getToStatus()).isEqualTo("WAIT_SPLIT");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("体积或体积重任一层汇总不一致时审核零写入")
|
||||
void shouldRejectApprovalWhenVolumeSnapshotDoesNotConserve() {
|
||||
Fixture fixture = fixture(false);
|
||||
fixture.order.setActualVolumeCbm(new BigDecimal("0.7479"));
|
||||
|
||||
assertThatThrownBy(() -> fixture.service.approve(10001L, 9L, "审核员", 10L,
|
||||
"approve-volume-mismatch", new MeasurementReviewRequest(20L, 3, null)))
|
||||
.isInstanceOf(BizException.class)
|
||||
.hasMessageContaining("体积和体积重");
|
||||
|
||||
verify(fixture.repository, never()).transitionReview(anyLong(), anyLong(), anyLong(), anyInt(), anyBoolean(), anyLong());
|
||||
verify(fixture.repository, never()).transitionMeasurement(anyLong(), anyLong(), anyLong(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("驳回原因必填且状态推进为测量驳回")
|
||||
void shouldRequireRejectReason() {
|
||||
@@ -218,13 +253,15 @@ class MeasurementReviewServiceTest {
|
||||
order.setTotalPackageCount(5);
|
||||
order.setActualGrossWeightKg(new BigDecimal("1787.500"));
|
||||
order.setActualNetWeightKg(new BigDecimal("1747.500"));
|
||||
order.setActualVolumeCbm(new BigDecimal("0.7480"));
|
||||
order.setVolumeWeightKg(new BigDecimal("124.916"));
|
||||
InboundMeasurementEntity measurement = new InboundMeasurementEntity();
|
||||
measurement.setId(20L);
|
||||
measurement.setStatus("SUBMITTED");
|
||||
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
|
||||
item.setPackageCount(5);
|
||||
item.setTotalVolumeCbm(new BigDecimal("0.7480"));
|
||||
item.setVolumeWeightKg(new BigDecimal("124.916"));
|
||||
measurement.setTotalPackageCount(5);
|
||||
measurement.setTotalVolumeCbm(new BigDecimal("0.7480"));
|
||||
measurement.setVolumeWeightKg(new BigDecimal("124.916"));
|
||||
InboundMeasurementItemEntity item = measurementItem(5, "0.7480", "124.916");
|
||||
when(repository.findOwned(10001L, 10L)).thenReturn(order);
|
||||
when(repository.findMeasurement(10001L, 10L, 20L)).thenReturn(measurement);
|
||||
when(repository.listMeasurementItems(10001L, 20L)).thenReturn(List.of(item));
|
||||
@@ -237,6 +274,14 @@ class MeasurementReviewServiceTest {
|
||||
return new Fixture(service, repository, idempotency, order, measurement);
|
||||
}
|
||||
|
||||
private InboundMeasurementItemEntity measurementItem(int packages, String volume, String volumeWeight) {
|
||||
InboundMeasurementItemEntity item = new InboundMeasurementItemEntity();
|
||||
item.setPackageCount(packages);
|
||||
item.setTotalVolumeCbm(new BigDecimal(volume));
|
||||
item.setVolumeWeightKg(new BigDecimal(volumeWeight));
|
||||
return item;
|
||||
}
|
||||
|
||||
private InboundWeightResultEntity confirmedWeight(String grossWeightKg, String netWeightKg) {
|
||||
InboundWeightResultEntity weight = new InboundWeightResultEntity();
|
||||
weight.setRecognitionStatus("CONFIRMED");
|
||||
|
||||
+39
-3
@@ -125,24 +125,35 @@ class TruckAppointmentServiceTest {
|
||||
|
||||
@Test void createRejectsAnotherActiveAppointmentForTheSameRealContainerWithoutWriting() {
|
||||
SaveRequest request = validRequest();
|
||||
when(repository.activeForUpdate(88L)).thenReturn(List.of(entity(TruckAppointmentStatus.DRAFT, 0)));
|
||||
TruckAppointmentEntity existing = entity(TruckAppointmentStatus.DRAFT, 0);
|
||||
existing.setAppointmentNo("TA-260901-0003");
|
||||
when(repository.firstActiveForUpdate(88L)).thenReturn(existing);
|
||||
|
||||
assertThatThrownBy(() -> service.create(7L, "admin", request))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("已有有效拖车预约");
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("该柜已存在拖车预约 TA-260901-0003,请继续编辑");
|
||||
|
||||
verifyNoInteractions(oceanBookingDocuments);
|
||||
verify(repository, never()).insert(any());
|
||||
verify(repository, never()).history(any());
|
||||
assertThat(existing.getStatus()).isEqualTo(TruckAppointmentStatus.DRAFT);
|
||||
assertThat(existing.getVersion()).isZero();
|
||||
}
|
||||
|
||||
@Test void concurrentCreateUniqueCollisionBecomesBusinessConflictWithoutHistory() {
|
||||
SaveRequest request = validRequest();
|
||||
TruckAppointmentEntity concurrent = entity(TruckAppointmentStatus.DRAFT, 0);
|
||||
concurrent.setAppointmentNo("TA-260901-0003");
|
||||
when(repository.firstActiveForUpdate(88L)).thenReturn(null, concurrent);
|
||||
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("已有有效拖车预约");
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("该柜已存在拖车预约 TA-260901-0003,请继续编辑");
|
||||
|
||||
verify(repository, times(2)).firstActiveForUpdate(88L);
|
||||
verify(repository, never()).history(any());
|
||||
assertThat(concurrent.getStatus()).isEqualTo(TruckAppointmentStatus.DRAFT);
|
||||
assertThat(concurrent.getVersion()).isZero();
|
||||
}
|
||||
|
||||
@Test void createPreservesMaximumChineseRemarkAndBoundaryValues() {
|
||||
@@ -164,6 +175,31 @@ class TruckAppointmentServiceTest {
|
||||
assertThat(inserted.getValue().getPalletCount()).isZero();
|
||||
assertThat(inserted.getValue().getGrossWeightKg()).isZero();
|
||||
assertThat(inserted.getValue().getVolumeCbm()).isZero();
|
||||
assertThat(inserted.getValue().getCurrency()).isEqualTo("RMB");
|
||||
}
|
||||
|
||||
@Test void createAcceptsUsd() {
|
||||
SaveRequest request = validRequest();
|
||||
when(request.currency()).thenReturn("USD");
|
||||
when(request.payableAmount()).thenReturn(new java.math.BigDecimal("123.45"));
|
||||
|
||||
service.create(7L, "admin", request);
|
||||
|
||||
ArgumentCaptor<TruckAppointmentEntity> inserted = ArgumentCaptor.forClass(TruckAppointmentEntity.class);
|
||||
verify(repository).insert(inserted.capture());
|
||||
assertThat(inserted.getValue().getCurrency()).isEqualTo("USD");
|
||||
assertThat(inserted.getValue().getPayableAmount()).isEqualByComparingTo("123.45");
|
||||
}
|
||||
|
||||
@Test void createRejectsUnsupportedCurrencyWithoutWriting() {
|
||||
SaveRequest request = validRequest();
|
||||
when(request.currency()).thenReturn("CNY");
|
||||
|
||||
assertThatThrownBy(() -> service.create(7L, "admin", request))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("RMB或USD");
|
||||
|
||||
verify(repository, never()).insert(any());
|
||||
verify(repository, never()).history(any());
|
||||
}
|
||||
|
||||
@Test void platformOwnedAttachmentUsesZeroTenantWithoutCustomerSnapshot() {
|
||||
|
||||
Reference in New Issue
Block a user