fix(dropship): generate pallet numbers atomically and extend full regressions
This commit is contained in:
+24
@@ -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);
|
||||
|
||||
+53
@@ -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");
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
+39
@@ -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);
|
||||
}
|
||||
}
|
||||
+51
@@ -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\"}}";
|
||||
}
|
||||
}
|
||||
+126
@@ -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)); }
|
||||
}
|
||||
Reference in New Issue
Block a user