fix(dropship): generate pallet numbers atomically and extend full regressions

This commit is contained in:
2026-09-09 12:19:16 +08:00
100 changed files with 2889 additions and 234 deletions
@@ -0,0 +1,93 @@
package com.baiyue.firstleg.controller;
import com.baiyue.firstleg.dto.ContainerFeeDetailDtos;
import com.baiyue.firstleg.entity.ContainerFeeDetailEntity;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.yaml.snakeyaml.Yaml;
import java.nio.file.Files;
import java.nio.file.Path;
import java.lang.reflect.Modifier;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
class ContainerFeeOpenApiContractTest {
@Test
@SuppressWarnings("unchecked")
void coversEveryControllerOperationPermissionAndResponse() throws Exception {
Map<String, Object> api = read();
Map<String, Map<String, Map<String, Object>>> paths = (Map<String, Map<String, Map<String, Object>>>) api.get("paths");
String base = PlatformContainerFeeDetailController.class.getAnnotation(RequestMapping.class).value()[0];
int operationCount = 0;
for (var method : PlatformContainerFeeDetailController.class.getDeclaredMethods()) {
var get = method.getAnnotation(GetMapping.class);
var post = method.getAnnotation(PostMapping.class);
if (get == null && post == null) continue;
String[] suffix = get != null ? get.value() : post.value();
String path = base + (suffix.length == 0 ? "" : suffix[0]);
assertThat(paths).containsKey(path);
var operation = paths.get(path).get(get != null ? "get" : "post");
assertThat(operation).isNotNull();
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('" + operation.get("x-permission") + "')");
var responses = (Map<String, Object>) operation.get("responses");
assertThat((Map<String, Object>) responses.get("200")).containsKey("content");
operationCount++;
}
assertThat(paths.values().stream().mapToInt(Map::size).sum()).isEqualTo(operationCount);
validateReferences(api, api);
}
@Test
@SuppressWarnings("unchecked")
void requestAndResponseSchemasMatchJavaFields() throws Exception {
var components = (Map<String, Object>) read().get("components");
var schemas = (Map<String, Map<String, Object>>) components.get("schemas");
for (Class<?> dto : List.of(ContainerFeeDetailDtos.SaveRequest.class,
ContainerFeeDetailDtos.AdjustRequest.class, ContainerFeeDetailDtos.Allocation.class,
ContainerFeeDetailDtos.AllocationGroupRequest.class, ContainerFeeDetailDtos.AllocationAmount.class,
ContainerFeeDetailDtos.AllocationGroupAdjustRequest.class, ContainerFeeDetailDtos.VersionRequest.class,
ContainerFeeDetailDtos.RejectRequest.class)) {
Set<String> fields = Arrays.stream(dto.getRecordComponents()).map(value -> value.getName()).collect(Collectors.toSet());
var properties = (Map<String, Object>) schemas.get(dto.getSimpleName()).get("properties");
assertThat(properties.keySet()).containsExactlyInAnyOrderElementsOf(fields);
}
Set<String> responseFields = Arrays.stream(ContainerFeeDetailEntity.class.getDeclaredFields())
.filter(field -> !Modifier.isStatic(field.getModifiers())).map(field -> field.getName()).collect(Collectors.toSet());
var properties = (Map<String, Object>) schemas.get("FeeDetail").get("properties");
assertThat(properties.keySet()).containsExactlyInAnyOrderElementsOf(responseFields);
Set<String> envelopeFields = Arrays.stream(com.baiyue.common.core.api.ApiResponse.class.getRecordComponents())
.map(component -> component.getName()).collect(Collectors.toSet());
for (String name : List.of("FeeResponse", "FeeListResponse", "EmptyResponse")) {
var envelope = (Map<String, Object>) schemas.get(name).get("properties");
assertThat(envelope.keySet()).containsExactlyInAnyOrderElementsOf(envelopeFields);
}
}
private static Map<String, Object> read() throws Exception {
return new Yaml().load(Files.readString(Path.of("src/main/resources/openapi/container-fees-v1.yaml")));
}
private static void validateReferences(Object value, Map<String, Object> root) {
if (value instanceof Map<?, ?> map) {
if (map.get("$ref") instanceof String ref) {
assertThat(ref).startsWith("#/");
Object target = root;
for (String segment : ref.substring(2).split("/")) target = ((Map<?, ?>) target).get(segment);
assertThat(target).as(ref).isNotNull();
}
map.values().forEach(child -> validateReferences(child, root));
} else if (value instanceof List<?> list) {
list.forEach(child -> validateReferences(child, root));
}
}
}
@@ -39,6 +39,8 @@ class OceanBookingMapperContractTest {
.flatMap(annotation -> Arrays.stream(annotation.value()))
.reduce("", (left, right) -> left + '\n' + right);
assertThat(sql)
.contains("customer_id=#{value.customerId},customer_name=#{value.customerName}")
.doesNotContain("customer_id=COALESCE", "customer_name=COALESCE")
.contains("status=#{fromStatus} AND version=#{expectedVersion}")
.contains("status='DRAFT' AND version=#{version}")
.doesNotContain("CONFIRMED", "CHANGE_PENDING", "CANCELLED", "SUBMITTED", "FAILED");
@@ -40,11 +40,12 @@ class OceanBookingDocumentServiceTest {
@Test void uploadIsIdempotentAndDoesNotUploadASecondFileOnReplay() throws Exception {
OceanBookingEntity booking = draft(2);
booking.setCustomerId(null);
MockMultipartFile file = new MockMultipartFile("file", "booking.pdf", "application/pdf", "pdf".getBytes());
when(repository.claimOperation(eq(10L), eq(7L), eq("UPLOAD_DOCUMENT"), eq("upload-1"), anyString()))
.thenReturn(true, false);
when(repository.findForUpdate(10L)).thenReturn(booking);
when(files.upload("token", 7L, 9L, "OCEAN_BOOKING_DOCUMENT", "10", file))
when(files.upload("token", 7L, 0L, "OCEAN_BOOKING_DOCUMENT", "10", file))
.thenReturn(ApiResponse.success(metadata(91L)));
when(repository.touchDraft(10L, 2, 7L, "admin")).thenReturn(1);
when(repository.completeOperation(eq(10L), eq(7L), eq("UPLOAD_DOCUMENT"), eq("upload-1"), anyString()))
@@ -75,9 +76,9 @@ class OceanBookingDocumentServiceTest {
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))
when(files.upload("token", 7L, 0L, "OCEAN_BOOKING_DOCUMENT", "10", confirmation))
.thenReturn(ApiResponse.success(metadata(91L)));
when(files.upload("token", 7L, 9L, "OCEAN_BOOKING_DOCUMENT", "10", bill))
when(files.upload("token", 7L, 0L, "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()))
@@ -101,7 +102,7 @@ class OceanBookingDocumentServiceTest {
@Test void attachRejectsCrossScopeFileBeforePersisting() {
when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
when(repository.findForUpdate(10L)).thenReturn(draft(1));
when(files.metadata("token", 9L, "OCEAN_BOOKING_DOCUMENT", "10", 92L))
when(files.metadata("token", 0L, "OCEAN_BOOKING_DOCUMENT", "10", 92L))
.thenReturn(ApiResponse.success(new OceanBookingFileClient.Metadata(92L, 19L,
"OCEAN_BOOKING_DOCUMENT", "10", "x.pdf", "hash", "application/pdf", 3L)));
@@ -144,12 +145,12 @@ class OceanBookingDocumentServiceTest {
document.setId(20L); document.setOceanBookingId(10L); document.setFileId(91L);
document.setDocumentType("BILL_OF_LADING"); document.setValid(true);
when(repository.activeDocuments(10L)).thenReturn(List.of(document));
when(files.metadata("token", 9L, "OCEAN_BOOKING_DOCUMENT", "10", 91L))
when(files.metadata("token", 0L, "OCEAN_BOOKING_DOCUMENT", "10", 91L))
.thenReturn(ApiResponse.success(metadata(91L)));
service.requireActiveValidDocument(booking);
verify(files).metadata("token", 9L, "OCEAN_BOOKING_DOCUMENT", "10", 91L);
verify(files).metadata("token", 0L, "OCEAN_BOOKING_DOCUMENT", "10", 91L);
}
@Test void releaseGateRejectsWhenBothActiveFileReferencesNoLongerExist() {
@@ -161,8 +162,8 @@ class OceanBookingDocumentServiceTest {
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(files).metadata("token", 0L, "OCEAN_BOOKING_DOCUMENT", "10", 91L);
verify(files).metadata("token", 0L, "OCEAN_BOOKING_DOCUMENT", "10", 92L);
verify(repository, never()).touchDraft(anyLong(), anyInt(), anyLong(), anyString());
}
@@ -173,13 +174,48 @@ class OceanBookingDocumentServiceTest {
return document;
}
@Test void changingOrClearingCustomerKeepsPlatformFileDownloadAndReleaseScope() {
OceanBookingEntity booking = draft(3);
OceanBookingDocumentEntity document = activeDocument(20L, 91L, "BOOKING_CONFIRMATION");
document.setFileName("booking.pdf");
when(repository.find(10L)).thenReturn(booking);
when(repository.document(10L, 20L)).thenReturn(document);
when(repository.activeDocuments(10L)).thenReturn(List.of(document));
when(files.metadata("token", 0L, "OCEAN_BOOKING_DOCUMENT", "10", 91L))
.thenReturn(ApiResponse.success(metadata(91L)));
when(files.download("token", 0L, "OCEAN_BOOKING_DOCUMENT", "10", 91L))
.thenReturn(org.springframework.http.ResponseEntity.ok(new byte[]{1, 2, 3}));
for (Long customer : java.util.Arrays.asList(9L, 19L, null)) {
booking.setCustomerId(customer);
service.requireActiveValidDocument(booking);
assertThat(service.download(10L, 20L).content()).containsExactly(1, 2, 3);
}
verify(files, times(6)).metadata("token", 0L, "OCEAN_BOOKING_DOCUMENT", "10", 91L);
verify(files, times(3)).download("token", 0L, "OCEAN_BOOKING_DOCUMENT", "10", 91L);
}
@Test void platformFileFromAnotherBookingIsRejected() {
when(repository.claimOperation(any(), anyLong(), anyString(), anyString(), anyString())).thenReturn(true);
OceanBookingEntity booking = draft(1);
booking.setCustomerId(null);
when(repository.findForUpdate(10L)).thenReturn(booking);
when(files.metadata("token", 0L, "OCEAN_BOOKING_DOCUMENT", "10", 92L))
.thenReturn(ApiResponse.success(new OceanBookingFileClient.Metadata(92L, 0L,
"OCEAN_BOOKING_DOCUMENT", "99", "x.pdf", "hash", "application/pdf", 3L)));
assertThatThrownBy(() -> service.attach(10L,
new com.baiyue.firstleg.oceanbooking.dto.OceanBookingDtos.DocumentRequest(1, 92L, "BILL_OF_LADING"),
"other-booking", 7L, "admin"))
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
verify(repository, never()).insert(any(OceanBookingDocumentEntity.class));
}
private static OceanBookingEntity draft(int version) {
OceanBookingEntity booking = new OceanBookingEntity();
booking.setId(10L); booking.setCustomerId(9L); booking.setStatus("DRAFT"); booking.setVersion(version);
return booking;
}
private static OceanBookingFileClient.Metadata metadata(long fileId) {
return new OceanBookingFileClient.Metadata(fileId, 9L, "OCEAN_BOOKING_DOCUMENT", "10",
return new OceanBookingFileClient.Metadata(fileId, 0L, "OCEAN_BOOKING_DOCUMENT", "10",
"booking.pdf", "hash", "application/pdf", 3L);
}
}
@@ -40,6 +40,7 @@ class OceanBookingServiceTest {
@Mock private com.baiyue.firstleg.client.WarehousePartnerClient partners;
@Mock private FirstlegDocumentNumberGenerator documentNumbers;
@Mock private OceanBookingDocumentService documents;
@Mock private com.baiyue.firstleg.service.IamCustomerSnapshotService customers;
@InjectMocks private OceanBookingService service;
private ObjectMapper objectMapper;
@@ -47,6 +48,8 @@ class OceanBookingServiceTest {
void setUp() throws Exception {
MockitoAnnotations.openMocks(this);
objectMapper = new ObjectMapper().findAndRegisterModules();
lenient().when(customers.active(eq(100L), any())).thenReturn(
new com.baiyue.firstleg.client.IamCustomerSnapshotClient.Customer(100L, "C100", "客户", "ACTIVE", null, null));
lenient().when(documentNumbers.next("OB")).thenReturn("OB-260818-0001");
var field = OceanBookingService.class.getDeclaredField("objectMapper");
field.setAccessible(true);
@@ -76,6 +79,70 @@ class OceanBookingServiceTest {
verifyNoInteractions(priceBooks, suppliers, warehouses);
}
@Test
void customerlessCreateSaveAndReleaseRetainsOtherRequirements() throws Exception {
saveAndReleaseWithoutCustomer(false);
}
@Test
void clearingExistingCustomerPersistsNullAndAllowsRelease() throws Exception {
saveAndReleaseWithoutCustomer(true);
}
private void saveAndReleaseWithoutCustomer(boolean initialCustomer) throws Exception {
stubMasterData();
when(suppliers.booking(81L, "订舱代理")).thenReturn(
new WarehouseSupplierClient.Snapshot(81L, "订舱代理", "BOOKING", "ACTIVE", List.of("MATSON")));
var tree = objectMapper.valueToTree(completeDraft());
((com.fasterxml.jackson.databind.node.ObjectNode) tree).putNull("customerId").putNull("customerName");
DraftData emptyCustomer = objectMapper.treeToValue(tree, DraftData.class);
AtomicReference<OceanBookingEntity> stored = stubCreatedDraft();
var created = service.create(9L, "admin", "optional-create",
new CreateRequest(0, initialCustomer ? completeDraft() : emptyCustomer));
long id = created.oceanBooking().getId();
when(repository.updateDraft(any(), eq(0), eq(9L), eq("admin"))).thenAnswer(invocation -> {
OceanBookingEntity value = invocation.getArgument(0);
assertThat(value.getCustomerId()).isNull();
assertThat(value.getCustomerName()).isNull();
value.setVersion(1);
return 1;
});
service.updateDraft(id, 9L, "admin", "optional-save", new UpdateRequest(0, emptyCustomer));
when(repository.parties(id)).thenReturn(parties());
when(repository.requirements(id)).thenReturn(List.of(requirement()));
when(repository.transition(id, "DRAFT", "RELEASED", 1, 9L, "admin")).thenAnswer(invocation -> {
stored.get().setStatus("RELEASED"); stored.get().setVersion(2); return 1;
});
var result = service.release(id, 9L, "admin", "optional-release", new ReleaseRequest(1, "确认"));
assertThat(result.oceanBooking().getStatus()).isEqualTo("RELEASED");
assertThat(result.oceanBooking().getCustomerId()).isNull();
assertThat(result.oceanBooking().getCustomerName()).isNull();
verify(documents).requireActiveValidDocument(stored.get());
verify(customers, times(initialCustomer ? 1 : 0)).active(anyLong(), any());
}
@Test
void invalidSpecifiedCustomerIsRejectedBeforeCreatingDraft() {
when(customers.active(100L, "客户")).thenThrow(new BizException(
com.baiyue.common.core.api.ResultCode.CONFLICT, "所选客户不存在或未启用"));
assertThatThrownBy(() -> service.create(9L, "admin", "bad-customer", new CreateRequest(0, completeDraft())))
.isInstanceOf(BizException.class).hasMessageContaining("客户不存在或未启用");
verify(repository, never()).insert(any(OceanBookingEntity.class));
}
@Test
void releaseRevalidatesSpecifiedCustomer() {
when(repository.find(1L)).thenReturn(completeBooking("DRAFT", 3));
when(repository.parties(1L)).thenReturn(parties());
when(repository.requirements(1L)).thenReturn(List.of(requirement()));
when(customers.active(100L, "客户")).thenThrow(new BizException(
com.baiyue.common.core.api.ResultCode.CONFLICT, "所选客户不存在或未启用"));
assertThatThrownBy(() -> service.release(1L, 9L, "admin", "inactive-customer", new ReleaseRequest(3, null)))
.isInstanceOf(BizException.class).hasMessageContaining("客户不存在或未启用");
verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt(), anyLong(), anyString());
}
@Test
void freezesServerSnapshotsAndPersistsQuantityRequirementsOnly() {
stubMasterData();
@@ -86,6 +153,9 @@ class OceanBookingServiceTest {
assertThat(inserted.get().getChannelName()).isEqualTo("美西海运");
assertThat(inserted.get().getWarehouseName()).isEqualTo("深圳中转仓");
assertThat(inserted.get().getBookingAgentName()).isEqualTo("订舱代理");
assertThat(inserted.get().getCustomerId()).isEqualTo(100L);
assertThat(inserted.get().getCustomerName()).isEqualTo("客户");
verify(customers).active(100L, "客户");
@SuppressWarnings("unchecked")
ArgumentCaptor<List<OceanBookingContainerRequirementEntity>> captor = ArgumentCaptor.forClass(List.class);
verify(repository).replaceRequirements(eq(inserted.get().getId()), captor.capture());
@@ -265,6 +265,48 @@ class ContainerFeeDetailServiceTest {
.doesNotContainNull().allMatch(captor.getAllValues().get(0).getAllocationGroupId()::equals);
}
@Test
void rejectsAllocationWhenPersistedRoundedRowsWouldExceedTotal() {
ownedPlan();
when(feeItems.findItem(11L)).thenReturn(item());
var fee = new ContainerFeeDetailDtos.SaveRequest(11L, "RECEIVABLE",
LocalDate.of(2026, 8, 20), new BigDecimal("0.01"), "CNY", null, null);
var request = new AllocationGroupRequest(fee, List.of(
new Allocation(10001L, 501L, "FL-501", null, null, new BigDecimal("0.005")),
new Allocation(10002L, 502L, "FL-502", null, null, new BigDecimal("0.005"))));
assertThatThrownBy(() -> service.createAllocationGroup(41L, 9L, request))
.isInstanceOf(BizException.class).hasMessageContaining("分摊合计必须等于费用总额");
verify(repository, never()).insert(any());
}
@Test
void rejectsAllocationThatRoundsToZero() {
ownedPlan();
when(feeItems.findItem(11L)).thenReturn(item());
var fee = new ContainerFeeDetailDtos.SaveRequest(11L, "RECEIVABLE",
LocalDate.of(2026, 8, 20), new BigDecimal("0.00"), "CNY", null, null);
var request = new AllocationGroupRequest(fee, List.of(
new Allocation(10001L, 501L, "FL-501", null, null, new BigDecimal("0.001"))));
assertThatThrownBy(() -> service.createAllocationGroup(41L, 9L, request))
.isInstanceOf(BizException.class).hasMessageContaining("取整后必须大于0");
verify(repository, never()).insert(any());
}
@Test
void rejectsGroupAdjustmentThatRoundsAnAllocationToZero() {
ownedPlan();
when(repository.listGroup(41L, 81L)).thenReturn(List.of(approvedFee(1)));
var request = new ContainerFeeDetailDtos.AllocationGroupAdjustRequest(1,
LocalDate.of(2026, 8, 20), new BigDecimal("0.001"), List.of(
new ContainerFeeDetailDtos.AllocationAmount(51L, new BigDecimal("0.001"))));
assertThatThrownBy(() -> service.adjustAllocationGroup(41L, 81L, 9L, request))
.isInstanceOf(BizException.class).hasMessageContaining("取整后必须大于0");
verify(repository, never()).adjust(anyLong(), anyLong(), anyLong(), anyInt(), any(), any(), anyLong(), any());
}
@Test
void rejectsAllocationWhenInboundIdAndCodeAreOnlyPartiallyProvided() {
LoadingPlanEntity plan = new LoadingPlanEntity();
@@ -16,10 +16,54 @@ import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class DropshipPalletizationServiceTest {
private final FirstlegDocumentNumberGenerator numbers=mock(FirstlegDocumentNumberGenerator.class);
private final DropshipPalletizationMapper mapper=mock(DropshipPalletizationMapper.class);
private final DropshipPalletizationService service=new DropshipPalletizationService();
DropshipPalletizationServiceTest(){ ReflectionTestUtils.setField(service,"mapper",mapper); }
DropshipPalletizationServiceTest(){ ReflectionTestUtils.setField(service,"mapper",mapper); ReflectionTestUtils.setField(service,"documentNumbers",numbers); }
@Test void generatesAndBindsOnlyWhenPendingCartonsExist(){
when(mapper.lockTask(10L,100L)).thenReturn(10L); when(mapper.task(10L,100L)).thenReturn(task());
when(mapper.unboundCount(10L)).thenReturn(2); when(numbers.next("DP")).thenReturn("DP-260909-0001");
when(mapper.bindPending(eq(10L),anyLong(),eq("create-1"),eq(7L),eq("张三"))).thenReturn(2);
when(mapper.adjustBound(10L,7L,2)).thenReturn(1);
service.createAndBind(10L,new com.baiyue.firstleg.dto.DropshipPalletizationDtos.CreateAndBindRequest(100L,"create-1"),7L,"张三");
verify(mapper).insertPallet(anyLong(),eq(10L),eq(1L),eq(30L),eq(100L),eq("DP-260909-0001"),eq("create-1"),eq(7L),eq("张三"));
verify(numbers,times(1)).next("DP");
}
@Test void noPendingCartonsNeverAllocatesNumberOrEmptyPallet(){
when(mapper.lockTask(10L,100L)).thenReturn(10L); when(mapper.task(10L,100L)).thenReturn(task());
assertThatThrownBy(()->service.createAndBind(10L,new com.baiyue.firstleg.dto.DropshipPalletizationDtos.CreateAndBindRequest(100L,"empty"),7L,"张三")).hasMessageContaining("没有待绑托箱");
verifyNoInteractions(numbers);
verify(mapper,never()).insertPallet(anyLong(),anyLong(),anyLong(),anyLong(),anyLong(),anyString(),anyString(),anyLong(),anyString());
}
@Test void replaysAfterCompletionWithoutGeneratingAnotherPallet(){
TaskRow completed=task(); completed.setTaskStatus("COMPLETED"); completed.setProcessStatus("WAIT_SPLIT");
when(mapper.lockTask(10L,100L)).thenReturn(10L); when(mapper.task(10L,100L)).thenReturn(completed);
when(mapper.palletByOpenOperation("replay")).thenReturn(pallet());
service.createAndBind(10L,new com.baiyue.firstleg.dto.DropshipPalletizationDtos.CreateAndBindRequest(100L,"replay"),7L,"张三");
verifyNoInteractions(numbers); verify(mapper,never()).unboundCount(anyLong());
}
@Test void rejectsOtherOperatorAndCrossTaskReplay(){
when(mapper.lockTask(10L,100L)).thenReturn(10L); when(mapper.task(10L,100L)).thenReturn(task());
var request=new com.baiyue.firstleg.dto.DropshipPalletizationDtos.CreateAndBindRequest(100L,"replay");
assertThatThrownBy(()->service.createAndBind(10L,request,8L,"李四")).hasMessageContaining("当前操作员");
PalletRow other=pallet(); other.setTaskId(11L); when(mapper.palletByOpenOperation("replay")).thenReturn(other);
assertThatThrownBy(()->service.createAndBind(10L,request,7L,"张三")).hasMessageContaining("不同任务");
verifyNoInteractions(numbers);
}
@Test void rejectsUnknownWarehouseAndChangedBindCount(){
var request=new com.baiyue.firstleg.dto.DropshipPalletizationDtos.CreateAndBindRequest(100L,"new");
assertThatThrownBy(()->service.createAndBind(10L,request,7L,"张三")).hasMessageContaining("当前仓库");
when(mapper.lockTask(10L,100L)).thenReturn(10L); when(mapper.task(10L,100L)).thenReturn(task());
when(mapper.unboundCount(10L)).thenReturn(2); when(numbers.next("DP")).thenReturn("DP-260909-0002");
assertThatThrownBy(()->service.createAndBind(10L,request,7L,"张三")).hasMessageContaining("待绑托箱已变化");
verify(mapper,never()).adjustBound(anyLong(),anyLong(),anyInt());
}
@Test void resolvesUncountedCartonBeforeQuantityConfirmation(){
TaskRow task=task(); LabelRow label=label();
@@ -9,6 +9,19 @@ import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class TerminalDrayageOpenApiContractTest {
@Test
void actualTimeRequestBindsRemarkFromPublicJson() throws Exception {
var mapper = new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules();
var request = mapper.readValue("""
{"expectedVersion":3,"actualAt":"2026-09-04T09:00:00","remark":"已还柜"}
""", com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.ActualTimeRequest.class);
assertThat(request.remark()).isEqualTo("已还柜");
assertThat(request.expectedVersion()).isEqualTo(3);
String yaml = Files.readString(Path.of("src/main/resources/openapi/terminal-drayage-v1.yaml"));
String schema = yaml.substring(yaml.indexOf(" ActualTimeRequest:"), yaml.indexOf(" Warning:"));
assertThat(schema).contains("remark:", "expectedVersion:", "actualAt:").doesNotContain("reason:");
}
@Test
void controllerUsesFrozenOverseasPermissionsWithoutGenericContainerFallback() throws Exception {
assertPermission("views", "platform:firstleg:terminal-drayage:list");
@@ -84,6 +84,65 @@ class ContainerWorkbenchServiceTest {
verifyNoInteractions(carrierSchedules);
}
@Test
void realOverseasListAndDetailProjectBothEmptyReturnActions() throws Exception {
verifyRealEmptyReturnProjection(true, true);
}
@Test
void realOverseasListAndDetailRemoveBothActionsWithoutPermission() throws Exception {
verifyRealEmptyReturnProjection(false, true);
}
@Test
void unloadedListWithoutDrayageAggregateDoesNotOfferUnexecutableReturn() throws Exception {
verifyRealEmptyReturnProjection(true, false);
}
private void verifyRealEmptyReturnProjection(boolean hasPermission, boolean hasAggregate) throws Exception {
var mapper = mock(com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.class);
var json = new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules();
var realDrayages = new TerminalDrayageService(mapper,
mock(com.baiyue.firstleg.service.WarehouseSupplierSnapshotService.class), carrierSchedules, json);
ReflectionTestUtils.setField(service, "terminalDrayages", realDrayages);
ContainerBookingEntity container = container();
container.setUnloadingCompletedAt(LocalDateTime.of(2026, 9, 5, 8, 0));
var context = new com.baiyue.firstleg.terminaldrayage.mapper.TerminalDrayageMapper.Context(
11L, "MSCU1234567", 21L, "ACTIVE", container.getUnloadingCompletedAt());
var row = new com.baiyue.firstleg.terminaldrayage.entity.TerminalDrayageEntity();
row.setId(51L);
row.setContainerBookingId(11L);
row.setVersion(3);
row.setActualPickupAt(LocalDateTime.of(2026, 9, 4, 9, 0));
when(repository.page(eq("OVERSEAS"), any())).thenReturn(new PageResult<>(List.of(container), 1, 20, 1, 1));
when(repository.find(11L)).thenReturn(container);
when(repository.departed(11L)).thenReturn(true);
when(mapper.contexts(List.of(11L))).thenReturn(List.of(context));
when(mapper.context(11L)).thenReturn(context);
when(mapper.findAll(List.of(11L))).thenReturn(hasAggregate ? List.of(row) : List.of());
when(mapper.find(11L)).thenReturn(hasAggregate ? row : null);
when(carrierSchedules.containerActualFacts(List.of(11L))).thenReturn(List.of(facts("PICKED_UP")));
Set<String> permissions = hasPermission
? Set.of("platform:firstleg:terminal-drayage:list", "platform:firstleg:terminal-drayage:empty-return:confirm")
: Set.of("platform:firstleg:terminal-drayage:list");
var page = service.overseasPage(query(WorkbenchScope.ACTIVE), permissions);
var listed = page.records().get(0);
var detail = service.overseasDetail(11L, permissions);
List<String> expected = hasPermission && hasAggregate ? List.of("RECORD_EMPTY_RETURN") : List.of();
assertThat(listed.workbenchStage()).isEqualTo("UNLOADED");
assertThat(listed.terminalDrayage().status()).isEqualTo("UNLOADED");
assertThat(listed.allowedActions()).isEqualTo(expected);
assertThat(listed.terminalDrayage().allowedActions()).isEqualTo(expected);
assertThat(detail.allowedActions()).isEqualTo(listed.allowedActions());
assertThat(detail.terminalDrayage().allowedActions()).isEqualTo(listed.terminalDrayage().allowedActions());
var serialized = json.readTree(json.writeValueAsString(com.baiyue.common.core.api.ApiResponse.success(page)));
assertThat(serialized.at("/data/records/0/allowedActions")).isEqualTo(json.valueToTree(expected));
assertThat(serialized.at("/data/records/0/terminalDrayage/allowedActions")).isEqualTo(json.valueToTree(expected));
verify(mapper).findAll(List.of(11L));
verify(mapper).contexts(List.of(11L));
}
@Test
void domesticHistoryDetailKeepsTheDepartedContainerAndItsUniqueLoadingPlan() {
ContainerBookingEntity container = container();
@@ -177,6 +177,23 @@ class TerminalDrayageServiceTest {
assertThat(result.allowedActions()).doesNotContain("RECORD_EMPTY_RETURN");
}
@Test
void unloadedContainerOffersEmptyReturnOnlyWithDedicatedPermission() {
Context context = context("PICKED_UP", LocalDateTime.of(2026, 9, 3, 0, 0));
TerminalDrayageEntity row = row(3);
row.setActualPickupAt(LocalDateTime.of(2026, 9, 2, 10, 0));
when(mapper.context(11L)).thenReturn(context);
when(mapper.find(11L)).thenReturn(row);
var raw = service.detail(11L);
assertThat(raw.status()).isEqualTo("UNLOADED");
assertThat(service.authorize(raw,
Set.of("platform:firstleg:terminal-drayage:empty-return:confirm")).allowedActions())
.containsExactly("RECORD_EMPTY_RETURN");
assertThat(service.authorize(raw,
Set.of("platform:firstleg:terminal-drayage:list")).allowedActions()).isEmpty();
}
@Test
void bulkViewOmitsContainerWhenActualFactPortHasNoTrackingRecord() {
when(mapper.contexts(List.of(11L))).thenReturn(List.of(
@@ -276,6 +293,9 @@ class TerminalDrayageServiceTest {
ArgumentCaptor<ManualNodeRequest> node = ArgumentCaptor.forClass(ManualNodeRequest.class);
verify(carrierSchedules).addManualNode(anyLong(), anyLong(), anyString(), anyString(), node.capture());
assertThat(node.getValue().standardNode()).isEqualTo("EMPTY_RETURNED");
assertThat(node.getValue().reason()).isEqualTo("已还柜");
assertThat(node.getValue().actualAt()).isEqualTo(LocalDateTime.of(2026, 9, 4, 9, 0));
assertThat(result.version()).isEqualTo(4);
}
private Context context(String node, LocalDateTime unloadedAt) {