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
@@ -107,6 +107,30 @@ class CustomerVisitServiceTest {
assertThat(tail.getSubOrderStatus()).isEqualTo("DELIVERY:DELIVERED");
}
@Test
void startMeansDeliveringAndOnlyCompleteSignsAndCreatesVisit() {
DeliveryOrder delivery = delivery("WAIT_DELIVERY"); delivery.setVersion(0L);
when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery);
var started = service.updateDeliveryStatus("1001", "9001", "DL-1",
new CustomerVisitRequests.DeliveryStatus("DELIVERING", 0L, "开始派送"));
assertThat(started.deliveryStatus()).isEqualTo("DELIVERING");
assertThat(delivery.getVersion()).isEqualTo(1);
assertThat(delivery.getDispatchedAt()).isNotNull();
assertThat(delivery.getSignedAt()).isNull();
verify(visits, never()).insert(org.mockito.ArgumentMatchers.any(CustomerVisit.class));
CustomerVisit visit = new CustomerVisit(); visit.setId(33L); visit.setStatus("PENDING");
when(visits.findByDelivery("1001", 11L)).thenReturn(visit);
var completed = service.updateDeliveryStatus("1001", "9001", "DL-1",
new CustomerVisitRequests.DeliveryStatus("DELIVERED", 1L, null));
assertThat(completed.deliveryStatus()).isEqualTo("DELIVERED");
assertThat(delivery.getSignedAt()).isNotNull();
assertThat(delivery.getVersion()).isEqualTo(2);
assertThat(completed.visitId()).isEqualTo(33);
assertThat(service.updateDeliveryStatus("1001", "9001", "DL-1",
new CustomerVisitRequests.DeliveryStatus("DELIVERED", 1L, null)).replayed()).isTrue();
verify(deliveryOrders, org.mockito.Mockito.times(2)).updateById(delivery);
}
private DeliveryOrder delivery(String status) {
DeliveryOrder delivery = new DeliveryOrder();
delivery.setId(11L);
@@ -0,0 +1,53 @@
package com.baiyue.lastleg.delivery;
import com.baiyue.lastleg.delivery.controller.DeliveryDocumentController;
import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.*;
import com.baiyue.lastleg.delivery.model.DeliveryDocumentCategory;
import com.baiyue.lastleg.customervisit.dto.CustomerVisitRequests;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.validation.Validation;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import java.util.*;
import java.nio.file.*;
import static org.assertj.core.api.Assertions.*;
class DeliveryDocumentContractTest {
@Test void missingVersionNestedNullIdsAndTooManyFilesAreRejected() {
try(var factory=Validation.buildDefaultValidatorFactory()) {
var validator=factory.getValidator();
assertThat(validator.validate(new CustomerVisitRequests.DeliveryStatus("DELIVERING",null,"123")))
.anyMatch(v->v.getPropertyPath().toString().equals("expectedVersion"));
assertThat(validator.validate(new Save(null,"key",null,List.of()))).isNotEmpty();
assertThat(validator.validate(new Save(0L,"key",null,List.of(new Binding(DeliveryDocumentCategory.BOL,
Arrays.asList((Long)null)))))).isNotEmpty();
assertThat(validator.validate(new Save(0L,"key",null,List.of(new Binding(DeliveryDocumentCategory.BOL,
java.util.stream.LongStream.rangeClosed(1,21).boxed().toList()))))).isNotEmpty();
}
}
@Test void removedCategoriesAreNotAcceptedAndOnlyThreeRemainInOrder() {
assertThat(DeliveryDocumentCategory.values()).containsExactly(DeliveryDocumentCategory.LOADING_PHOTO,
DeliveryDocumentCategory.BOL, DeliveryDocumentCategory.RECEIPT);
for(String category:List.of("POD","EXPRESS_RECEIPT","CUSTOMER_DOCUMENT","BILL_OF_LADING","DELIVERY_PHOTO")) {
assertThatThrownBy(()->new ObjectMapper().readValue("\""+category+"\"",DeliveryDocumentCategory.class))
.isInstanceOf(com.fasterxml.jackson.core.JsonProcessingException.class);
}
}
@Test void readDownloadAndWritePermissionsAreSeparate() {
for(var method:DeliveryDocumentController.class.getDeclaredMethods()) {
if(!Set.of("detail","history","download","save","upload").contains(method.getName())) continue;
String suffix=Set.of("save","upload").contains(method.getName())?"documents":"list";
assertThat(method.getAnnotation(PreAuthorize.class).value())
.isEqualTo("hasAuthority('platform:overseas-warehouse:delivery:"+suffix+"')");
}
}
@Test void baselineAndOverseasEntryContainSameImmutableRevisionTable() throws Exception {
String baseline=Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
String overseas=Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql"));
String marker="CREATE TABLE IF NOT EXISTS ll_delivery_document_revision (";
String a=baseline.substring(baseline.indexOf(marker),baseline.indexOf(";",baseline.indexOf(marker))+1);
String b=overseas.substring(overseas.indexOf(marker),overseas.indexOf(";",overseas.indexOf(marker))+1);
assertThat(a.replaceAll("\\s+","")).isEqualTo(b.replaceAll("\\s+",""));
assertThat(a).contains("uk_delivery_document_command","uk_delivery_document_version");
}
}
@@ -66,6 +66,7 @@ class DeliveryLongIdJsonContractTest {
DELIVERY_ID,
null, null, "PS-1", null, null, null, null, "CREATED", null,
null, null, null, null, null, null, null, null, null, null, null,
0, 0, null, null, null, null, null, null, null, null, 0, java.util.List.of());
0, 0, null, null, null, null, null, null, null, null, 0, java.util.List.of(), null,
com.baiyue.lastleg.delivery.model.DeliveryDocumentSnapshot.summary(null));
}
}
@@ -0,0 +1,39 @@
package com.baiyue.lastleg.delivery.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.*;
import com.baiyue.lastleg.delivery.service.DeliveryDocumentService;
import com.baiyue.lastleg.security.OverseasPermissions;
import org.junit.jupiter.api.*;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.test.util.ReflectionTestUtils;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.LocalDateTime;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
class DeliveryDocumentControllerTest {
@AfterEach void clear() { SecurityContextHolder.clearContext(); }
@Test void readonlyUserGetsNoWriteActionsInCurrentReplayOrHistory() throws Exception { verifyActions(false); }
@Test void currentWritePermissionRetainsManagementAction() throws Exception { verifyActions(true); }
private void verifyActions(boolean writer) throws Exception {
var service=mock(DeliveryDocumentService.class);
var controller=new DeliveryDocumentController(); ReflectionTestUtils.setField(controller,"service",service);
var user=new AuthenticatedUser(9L,0L,null,null,"reader","PLATFORM","WEB",Set.of());
var authorities=writer?List.of(new SimpleGrantedAuthority(OverseasPermissions.DELIVERY_DOCUMENTS)):
List.of(new SimpleGrantedAuthority(OverseasPermissions.DELIVERY_LIST));
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user,null,authorities));
var snapshot=new Detail("DL-1","100",1,null,0,3,List.of(),List.of("MANAGE_DOCUMENTS"),true);
when(service.detail("100","DL-1")).thenReturn(snapshot);
when(service.history("100","DL-1")).thenReturn(List.of(new History(1,"9",LocalDateTime.now(),snapshot)));
var request=new Save(0L,"key",null,List.of());
when(service.save("100","DL-1","9",request)).thenReturn(snapshot);
var json=new ObjectMapper().findAndRegisterModules();
assertThat(json.valueToTree(controller.detail(user,"DL-1","100")).at("/data/allowedActions").size()).isEqualTo(writer?1:0);
assertThat(json.valueToTree(controller.save(user,"DL-1","100",request)).at("/data/allowedActions").size()).isEqualTo(writer?1:0);
assertThat(json.valueToTree(controller.history(user,"DL-1","100")).at("/data/0/snapshot/allowedActions").size()).isEqualTo(writer?1:0);
}
}
@@ -0,0 +1,51 @@
package com.baiyue.lastleg.delivery.gateway;
import com.baiyue.lastleg.delivery.model.DeliveryDocumentCategory;
import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import org.springframework.http.MediaType;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestClient;
import static org.assertj.core.api.Assertions.*;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.*;
import static org.springframework.test.web.client.response.MockRestResponseCreators.*;
class HttpDeliveryDocumentFileGatewayTest {
HttpDeliveryDocumentFileGateway gateway;
MockRestServiceServer server;
@BeforeEach void setUp() {
var builder=RestClient.builder().baseUrl("http://file.test");
server=MockRestServiceServer.bindTo(builder).build();
gateway=new HttpDeliveryDocumentFileGateway();
ReflectionTestUtils.setField(gateway,"client",builder.build());
ReflectionTestUtils.setField(gateway,"internalToken","test-service-token");
}
@Test void validatesExactOwnershipAndUsesFileCreationTime() {
expect(metadata("100","10","DELIVERY_BOL","11"));
var file=gateway.metadata("100",10,DeliveryDocumentCategory.BOL,11);
assertThat(file.fileId()).isEqualTo(11);
assertThat(file.uploadedAt().toString()).isEqualTo("2026-09-09T08:00");
server.verify();
}
@ParameterizedTest
@ValueSource(strings={"customer","delivery","category","file"})
void rejectsCrossCustomerCrossDeliveryWrongCategoryAndWrongFile(String mismatch) {
expect(metadata(mismatch.equals("customer")?"200":"100",mismatch.equals("delivery")?"20":"10",
mismatch.equals("category")?"DELIVERY_RECEIPT":"DELIVERY_BOL",mismatch.equals("file")?"12":"11"));
assertThatThrownBy(()->gateway.metadata("100",10,DeliveryDocumentCategory.BOL,11))
.hasMessageContaining("不属于");
server.verify();
}
private void expect(String json) {
server.expect(requestTo("http://file.test/internal/file/v1/business-files/100/DELIVERY_BOL/10/11"))
.andExpect(header("X-Internal-Service-Token","test-service-token"))
.andRespond(withSuccess(json,MediaType.APPLICATION_JSON));
}
private String metadata(String customer,String delivery,String type,String file) {
return "{\"code\":\"200\",\"data\":{\"fileId\":"+file+",\"tenantId\":"+customer+
",\"businessType\":\""+type+"\",\"businessId\":\""+delivery+"\",\"fileName\":\"test.pdf\","+
"\"contentType\":\"application/pdf\",\"sizeBytes\":10,\"createdAt\":\"2026-09-09T08:00:00\"}}";
}
}
@@ -0,0 +1,126 @@
package com.baiyue.lastleg.delivery.service;
import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.*;
import com.baiyue.lastleg.delivery.entity.*;
import com.baiyue.lastleg.delivery.gateway.DeliveryDocumentFileGateway;
import com.baiyue.lastleg.delivery.mapper.*;
import com.baiyue.lastleg.delivery.model.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.*;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.http.HttpStatus;
import org.springframework.mock.web.MockMultipartFile;
import java.time.LocalDateTime;
import java.util.List;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import static com.baiyue.lastleg.delivery.model.DeliveryDocumentCategory.*;
@ExtendWith(MockitoExtension.class)
class DeliveryDocumentServiceTest {
@Mock DeliveryOrderMapper orders;
@Mock DeliveryDocumentRevisionMapper revisions;
@Mock DeliveryOperationMapper operations;
@Mock DeliveryDocumentFileGateway files;
DeliveryDocumentService service;
DeliveryOrder order;
@BeforeEach void setUp() {
service = new DeliveryDocumentService();
ReflectionTestUtils.setField(service,"orders",orders);
ReflectionTestUtils.setField(service,"revisions",revisions);
ReflectionTestUtils.setField(service,"operations",operations);
ReflectionTestUtils.setField(service,"files",files);
ReflectionTestUtils.setField(service,"objectMapper",new ObjectMapper().findAndRegisterModules());
order = new DeliveryOrder(); order.setId(10L); order.setCustomerId("100");
order.setDeliveryNo("DL-1"); order.setVersion(0L); order.setStatus("DELIVERED");
}
@Test void savedCategoryCountCountsCategoriesNotFilesAndSurvivesReload() {
when(orders.lockByNo("100","DL-1")).thenReturn(order);
when(files.metadata("100",10,BOL,11)).thenReturn(file(11));
when(files.metadata("100",10,BOL,12)).thenReturn(file(12));
var result=service.save("100","DL-1","9",request(0,"save",List.of(new Binding(BOL,List.of(11L,12L)))));
assertThat(result.uploadedCategoryCount()).isEqualTo(1);
assertThat(result.categories()).hasSize(3);
assertThat(result.categories().get(1).fileCount()).isEqualTo(2);
assertThat(result.version()).isEqualTo(1);
assertThat(result.categories().get(1).files().get(0).uploadedAt()).isEqualTo(file(11).uploadedAt());
when(orders.findByNo("100","DL-1")).thenReturn(order);
assertThat(service.detail("100","DL-1")).isEqualTo(result);
assertThat(DeliveryDocumentSnapshot.summary(order.getDocumentsJson()).uploadedCategoryCount()).isEqualTo(1);
verify(revisions).insert(any(DeliveryDocumentRevision.class)); verify(operations).insert(any(DeliveryOperation.class));
}
@Test void staleVersionDoesNotValidateFilesOrWrite() {
order.setVersion(2L); when(orders.lockByNo("100","DL-1")).thenReturn(order);
assertThatThrownBy(()->service.save("100","DL-1","9",request(1,"stale",List.of())))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("版本");
verifyNoInteractions(files,operations); verify(orders,never()).updateById(any(DeliveryOrder.class));
verify(revisions,never()).insert(any(DeliveryDocumentRevision.class));
}
@Test void anyInvalidOwnershipRejectsWholeSnapshotBeforeMutation() {
when(orders.lockByNo("100","DL-1")).thenReturn(order);
when(files.metadata("100",10,BOL,11)).thenReturn(file(11));
when(files.metadata("100",10,RECEIPT,12)).thenThrow(new ResponseStatusException(HttpStatus.BAD_REQUEST,"归属错误"));
assertThatThrownBy(()->service.save("100","DL-1","9",request(0,"bad",List.of(
new Binding(BOL,List.of(11L)),new Binding(RECEIPT,List.of(12L)))))).hasMessageContaining("归属");
assertThat(order.getDocumentsJson()).isNull(); assertThat(order.getVersion()).isZero();
verify(orders,never()).updateById(any(DeliveryOrder.class)); verifyNoInteractions(operations);
verify(revisions,never()).insert(any(DeliveryDocumentRevision.class));
}
@Test void exactReplayDoesNotWriteOrRevalidateAndChangedPayloadConflicts() {
when(orders.lockByNo("100","DL-1")).thenReturn(order);
var request=request(0,"same",List.of());
var first=service.save("100","DL-1","9",request);
ArgumentCaptor<DeliveryDocumentRevision> saved=ArgumentCaptor.forClass(DeliveryDocumentRevision.class);
verify(revisions).insert(saved.capture());
when(revisions.findCommand("100",10,"same")).thenReturn(saved.getValue());
assertThat(service.save("100","DL-1","9",request).replayed()).isTrue();
assertThat(first.version()).isEqualTo(1);
verify(orders,times(1)).updateById(any(DeliveryOrder.class));
assertThatThrownBy(()->service.save("100","DL-1","9",request(1,"same",List.of())))
.hasMessageContaining("幂等键");
}
@Test void replacementRemovalPreservesHistoryAndCurrentDownloadRejectsRemovedFile() {
when(orders.lockByNo("100","DL-1")).thenReturn(order);
when(files.metadata("100",10,BOL,11)).thenReturn(file(11));
service.save("100","DL-1","9",request(0,"first",List.of(new Binding(BOL,List.of(11L)))));
ArgumentCaptor<DeliveryDocumentRevision> first=ArgumentCaptor.forClass(DeliveryDocumentRevision.class);
verify(revisions).insert(first.capture());
assertThat(service.save("100","DL-1","9",request(1,"clear",List.of())).uploadedCategoryCount()).isZero();
when(orders.findByNo("100","DL-1")).thenReturn(order);
assertThatThrownBy(()->service.download("100","DL-1",BOL,11,null)).isInstanceOf(ResponseStatusException.class);
when(revisions.history("100",10)).thenReturn(List.of(first.getValue()));
when(files.download("100",10,BOL,11)).thenReturn(new byte[]{1});
assertThat(service.download("100","DL-1",BOL,11,1L).content()).containsExactly((byte)1);
}
@Test void uploadWithoutSaveNeverChangesSummaryAndUnboundFileCannotDownload() {
when(orders.findByNo("100","DL-1")).thenReturn(order);
var input=new MockMultipartFile("file","a.pdf","application/pdf",new byte[]{1});
when(files.upload("100",10,BOL,"9",input)).thenReturn(file(11));
assertThat(service.upload("100","DL-1",BOL,"9",input).fileId()).isEqualTo(11);
assertThat(service.detail("100","DL-1").uploadedCategoryCount()).isZero();
assertThatThrownBy(()->service.download("100","DL-1",BOL,11,null)).isInstanceOf(ResponseStatusException.class);
verifyNoInteractions(revisions,operations); verify(orders,never()).updateById(any(DeliveryOrder.class));
}
@Test void anotherCustomerOrDeliveryCannotLoadOrBindFiles() {
assertThatThrownBy(()->service.detail("200","DL-1")).isInstanceOf(ResponseStatusException.class);
assertThatThrownBy(()->service.save("100","DL-2","9",request(0,"wrong",List.of())))
.isInstanceOf(ResponseStatusException.class);
verifyNoInteractions(files,revisions,operations);
}
@Test void allThreeCategoriesCountToThreeRegardlessOfFileMultiplicity() {
when(orders.lockByNo("100","DL-1")).thenReturn(order);
when(files.metadata("100",10,LOADING_PHOTO,11)).thenReturn(file(11));
when(files.metadata("100",10,BOL,12)).thenReturn(file(12));
when(files.metadata("100",10,RECEIPT,13)).thenReturn(file(13));
var result=service.save("100","DL-1","9",request(0,"all",List.of(
new Binding(LOADING_PHOTO,List.of(11L)),new Binding(BOL,List.of(12L)),new Binding(RECEIPT,List.of(13L)))));
assertThat(result.uploadedCategoryCount()).isEqualTo(3); assertThat(result.totalCategoryCount()).isEqualTo(3);
assertThat(result.categories()).allMatch(c->c.status().equals("UPLOADED"));
}
private Save request(long version,String key,List<Binding> bindings) { return new Save(version,key,"BOL-1",bindings); }
private File file(long id) { return new File(id,"a.pdf","application/pdf",1,LocalDateTime.of(2026,9,9,8,0)); }
}