fix(lastleg): enforce delivery and return workflows
This commit is contained in:
+41
-4
@@ -6,6 +6,8 @@ import com.baiyue.lastleg.customervisit.mapper.CustomerVisitMapper;
|
||||
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
|
||||
import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper;
|
||||
import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper;
|
||||
import com.baiyue.lastleg.entity.LastlegOrder;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
@@ -26,6 +28,7 @@ class CustomerVisitServiceTest {
|
||||
@Mock private CustomerVisitMapper visits;
|
||||
@Mock private DeliveryOrderMapper deliveryOrders;
|
||||
@Mock private DeliveryOperationMapper deliveryOperations;
|
||||
@Mock private LastlegOrderMapper lastMileOrders;
|
||||
private CustomerVisitService service;
|
||||
|
||||
@BeforeEach
|
||||
@@ -34,15 +37,16 @@ class CustomerVisitServiceTest {
|
||||
ReflectionTestUtils.setField(service, "visits", visits);
|
||||
ReflectionTestUtils.setField(service, "deliveryOrders", deliveryOrders);
|
||||
ReflectionTestUtils.setField(service, "deliveryOperations", deliveryOperations);
|
||||
ReflectionTestUtils.setField(service, "lastMileOrders", lastMileOrders);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsRollbackFromDeliveredToWaitDeliveryWithoutWritingHistory() {
|
||||
DeliveryOrder delivery = delivery("DELIVERED");
|
||||
when(deliveryOrders.findByNo("1001", "DL-1")).thenReturn(delivery);
|
||||
when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery);
|
||||
|
||||
assertThatThrownBy(() -> service.updateDeliveryStatus("1001", "9001", "DL-1",
|
||||
new CustomerVisitRequests.DeliveryStatus("WAIT_DELIVERY", "错误倒退")))
|
||||
new CustomerVisitRequests.DeliveryStatus("WAIT_DELIVERY", 0L, "错误倒退")))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.satisfies(error -> assertThat(((ResponseStatusException) error).getStatusCode().value()).isEqualTo(409))
|
||||
.hasMessageContaining("DELIVERED").hasMessageContaining("WAIT_DELIVERY");
|
||||
@@ -57,11 +61,11 @@ class CustomerVisitServiceTest {
|
||||
CustomerVisit visit = new CustomerVisit();
|
||||
visit.setId(33L);
|
||||
visit.setStatus("PENDING");
|
||||
when(deliveryOrders.findByNo("1001", "DL-1")).thenReturn(delivery);
|
||||
when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery);
|
||||
when(visits.findByDelivery("1001", 11L)).thenReturn(visit);
|
||||
|
||||
var result = service.updateDeliveryStatus("1001", "9001", "DL-1",
|
||||
new CustomerVisitRequests.DeliveryStatus("DELIVERED", null));
|
||||
new CustomerVisitRequests.DeliveryStatus("DELIVERED", 0L, null));
|
||||
|
||||
assertThat(result.replayed()).isTrue();
|
||||
assertThat(result.deliveryStatus()).isEqualTo("DELIVERED");
|
||||
@@ -70,6 +74,39 @@ class CustomerVisitServiceTest {
|
||||
verifyNoInteractions(deliveryOperations);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsForwardTransitionWithStaleVersion() {
|
||||
DeliveryOrder delivery = delivery("WAIT_DELIVERY");
|
||||
delivery.setVersion(3L);
|
||||
when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery);
|
||||
|
||||
assertThatThrownBy(() -> service.updateDeliveryStatus("1001", "9001", "DL-1",
|
||||
new CustomerVisitRequests.DeliveryStatus("DELIVERING", 2L, null)))
|
||||
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("版本");
|
||||
|
||||
verify(deliveryOrders, never()).updateById(delivery);
|
||||
verifyNoInteractions(deliveryOperations);
|
||||
}
|
||||
|
||||
@Test
|
||||
void completingDeliveryUpdatesOnlySubOrderStatusAndKeepsMainFlowStatus() {
|
||||
DeliveryOrder delivery = delivery("DELIVERING");
|
||||
delivery.setVersion(1L);
|
||||
LastlegOrder tail = new LastlegOrder();
|
||||
tail.setId(21L); tail.setCustomerId("1001"); tail.setCurrentStatus("INBOUND_COMPLETED");
|
||||
CustomerVisit visit = new CustomerVisit(); visit.setId(33L); visit.setStatus("PENDING");
|
||||
when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery);
|
||||
when(lastMileOrders.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(java.util.List.of(tail));
|
||||
when(visits.findByDelivery("1001", 11L)).thenReturn(visit);
|
||||
|
||||
var result = service.updateDeliveryStatus("1001", "9001", "DL-1",
|
||||
new CustomerVisitRequests.DeliveryStatus("DELIVERED", 1L, null));
|
||||
|
||||
assertThat(result.replayed()).isFalse();
|
||||
assertThat(tail.getCurrentStatus()).isEqualTo("INBOUND_COMPLETED");
|
||||
assertThat(tail.getSubOrderStatus()).isEqualTo("DELIVERY:DELIVERED");
|
||||
}
|
||||
|
||||
private DeliveryOrder delivery(String status) {
|
||||
DeliveryOrder delivery = new DeliveryOrder();
|
||||
delivery.setId(11L);
|
||||
|
||||
+1
-1
@@ -66,6 +66,6 @@ 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, java.util.List.of());
|
||||
0, 0, null, null, null, null, null, null, null, null, 0, java.util.List.of());
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ class DeliveryStatusPolicyTest {
|
||||
@Test
|
||||
void exposesOnlyServerAuthorizedActionsForEachState() {
|
||||
assertThat(DeliveryStatusPolicy.allowedActions("WAIT_DELIVERY"))
|
||||
.containsExactly("START_DELIVERY", "COMPLETE_DELIVERY", "REJECT_DELIVERY", "CANCEL_DELIVERY", "MARK_RETURN");
|
||||
.containsExactly("START_DELIVERY", "COMPLETE_DELIVERY", "REJECT_DELIVERY", "CANCEL_DELIVERY");
|
||||
assertThat(DeliveryStatusPolicy.allowedActions("DELIVERING"))
|
||||
.containsExactly("COMPLETE_DELIVERY", "REJECT_DELIVERY", "MARK_RETURN");
|
||||
assertThat(DeliveryStatusPolicy.allowedActions("DELIVERED")).containsExactly("MARK_RETURN");
|
||||
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
package com.baiyue.lastleg.exceptionorder.service;
|
||||
|
||||
import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderRequests;
|
||||
import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderResponses;
|
||||
import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrder;
|
||||
import com.baiyue.lastleg.exceptionorder.entity.ExceptionOrderCommand;
|
||||
import com.baiyue.lastleg.exceptionorder.entity.ExceptionReturnItem;
|
||||
import com.baiyue.lastleg.exceptionorder.gateway.ExceptionEvidenceFileGateway;
|
||||
import com.baiyue.lastleg.exceptionorder.mapper.ExceptionOrderCommandMapper;
|
||||
import com.baiyue.lastleg.exceptionorder.mapper.ExceptionOrderMapper;
|
||||
import com.baiyue.lastleg.exceptionorder.mapper.ExceptionReturnItemMapper;
|
||||
import com.baiyue.lastleg.inventory.command.dto.ReturnedInventoryRestoreRequest;
|
||||
import com.baiyue.lastleg.inventory.command.dto.ReturnedInventoryRestoreResponse;
|
||||
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
|
||||
import com.baiyue.lastleg.delivery.entity.DeliveryOrder;
|
||||
import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper;
|
||||
import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper;
|
||||
import com.baiyue.lastleg.mapper.LastlegOrderMapper;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ExceptionOrderLifecycleTest {
|
||||
@Mock private ExceptionOrderMapper exceptionOrders;
|
||||
@Mock private ExceptionOrderCommandMapper commands;
|
||||
@Mock private ExceptionReturnItemMapper returnItems;
|
||||
@Mock private InventoryCommandService inventoryCommands;
|
||||
@Mock private ExceptionEvidenceFileGateway evidenceFiles;
|
||||
@Mock private DeliveryOrderMapper deliveryOrders;
|
||||
@Mock private DeliveryOperationMapper deliveryOperations;
|
||||
@Mock private LastlegOrderMapper lastMileOrders;
|
||||
private ExceptionOrderService service;
|
||||
private ObjectMapper objectMapper;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new ExceptionOrderService();
|
||||
objectMapper = new ObjectMapper().findAndRegisterModules();
|
||||
ReflectionTestUtils.setField(service, "exceptionOrders", exceptionOrders);
|
||||
ReflectionTestUtils.setField(service, "commands", commands);
|
||||
ReflectionTestUtils.setField(service, "returnItems", returnItems);
|
||||
ReflectionTestUtils.setField(service, "inventoryCommands", inventoryCommands);
|
||||
ReflectionTestUtils.setField(service, "evidenceFiles", evidenceFiles);
|
||||
ReflectionTestUtils.setField(service, "deliveryOrders", deliveryOrders);
|
||||
ReflectionTestUtils.setField(service, "deliveryOperations", deliveryOperations);
|
||||
ReflectionTestUtils.setField(service, "lastMileOrders", lastMileOrders);
|
||||
ReflectionTestUtils.setField(service, "objectMapper", objectMapper);
|
||||
}
|
||||
|
||||
@Test
|
||||
void startsPendingExceptionWithVersionGateAndPersistsIdempotentResult() {
|
||||
ExceptionOrder order = order("PENDING", 0);
|
||||
when(commands.findByKey("1001", "START_PROCESSING", "start-1")).thenReturn(null);
|
||||
when(exceptionOrders.lockByNo("1001", "EX-1")).thenReturn(order);
|
||||
|
||||
var result = service.startProcessing("1001", "9001", "EX-1",
|
||||
new ExceptionOrderRequests.StartProcessing(0, "start-1", "联系客户并核对退件"));
|
||||
|
||||
assertThat(result.status()).isEqualTo("PROCESSING");
|
||||
assertThat(result.version()).isEqualTo(1);
|
||||
assertThat(result.replayed()).isFalse();
|
||||
verify(exceptionOrders).updateById(order);
|
||||
ArgumentCaptor<ExceptionOrderCommand> command = ArgumentCaptor.forClass(ExceptionOrderCommand.class);
|
||||
verify(commands).insert(command.capture());
|
||||
assertThat(command.getValue().getResultJson()).contains("PROCESSING");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsStaleVersionWithoutMutation() {
|
||||
when(commands.findByKey("1001", "START_PROCESSING", "start-2")).thenReturn(null);
|
||||
when(exceptionOrders.lockByNo("1001", "EX-1")).thenReturn(order("PENDING", 2));
|
||||
|
||||
assertThatThrownBy(() -> service.startProcessing("1001", "9001", "EX-1",
|
||||
new ExceptionOrderRequests.StartProcessing(1, "start-2", null)))
|
||||
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("版本");
|
||||
|
||||
verify(exceptionOrders, never()).updateById(any(ExceptionOrder.class));
|
||||
verify(commands, never()).insert(any(ExceptionOrderCommand.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void replaysSameCommandWithoutUpdatingAgain() throws Exception {
|
||||
ExceptionOrderResponses.CommandResult saved = new ExceptionOrderResponses.CommandResult(
|
||||
1L, "EX-1", "PROCESSING", 1, null, 0, null, false);
|
||||
ExceptionOrderRequests.StartProcessing request =
|
||||
new ExceptionOrderRequests.StartProcessing(0, "start-3", null);
|
||||
ExceptionOrderCommand command = new ExceptionOrderCommand();
|
||||
command.setRequestHash(hashFor(request));
|
||||
command.setResultJson(objectMapper.writeValueAsString(saved));
|
||||
when(commands.findByKey("1001", "START_PROCESSING", "start-3")).thenReturn(command);
|
||||
|
||||
var replayed = service.startProcessing("1001", "9001", "EX-1", request);
|
||||
|
||||
assertThat(replayed.replayed()).isTrue();
|
||||
assertThat(replayed.status()).isEqualTo("PROCESSING");
|
||||
verify(exceptionOrders, never()).lockByNo(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void completesWarehouseReturnOnlyAfterInventoryRestoreSucceeds() {
|
||||
ExceptionOrder order = order("PROCESSING", 1);
|
||||
order.setExceptionType("RETURN"); order.setReturnDirection("OVERSEAS_WAREHOUSE");
|
||||
order.setOutboundNo("OUT-1"); order.setOverseasWarehouseId("WH-1"); order.setInboundOrderId(44L);
|
||||
order.setDeliveryNo("DL-1");
|
||||
ExceptionReturnItem source = new ExceptionReturnItem();
|
||||
source.setOutboundPalletItemId(51L); source.setInventoryPositionId(61L);
|
||||
source.setSkuCode("SKU-1"); source.setApprovedReturnPackages(2L);
|
||||
DeliveryOrder delivery = new DeliveryOrder();
|
||||
delivery.setId(71L); delivery.setCustomerId("1001"); delivery.setDeliveryNo("DL-1");
|
||||
delivery.setStatus("RETURN_PENDING"); delivery.setVersion(2L);
|
||||
LocalDateTime completedAt = LocalDateTime.of(2026, 9, 8, 18, 30);
|
||||
when(commands.findByKey("1001", "COMPLETE", "complete-1")).thenReturn(null);
|
||||
when(exceptionOrders.lockByNo("1001", "EX-1")).thenReturn(order);
|
||||
when(returnItems.findByException("1001", 1L)).thenReturn(List.of(source));
|
||||
when(inventoryCommands.restoreReturnedInventory(any(), any(), any(ReturnedInventoryRestoreRequest.class)))
|
||||
.thenReturn(new ReturnedInventoryRestoreResponse(81L, List.of(91L), 0, 2, 2, false, completedAt));
|
||||
when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery);
|
||||
when(lastMileOrders.selectList(any())).thenReturn(List.of());
|
||||
ExceptionOrderRequests.Complete request = new ExceptionOrderRequests.Complete(1, "complete-1", "已退回入仓",
|
||||
List.of(101L), List.of(new ExceptionOrderRequests.ReturnReceipt(51L, 61L, 2, 201L,
|
||||
"PLT-201", 301L, "LOC-301")));
|
||||
|
||||
var result = service.complete("1001", "9001", "EX-1", request);
|
||||
|
||||
assertThat(result.status()).isEqualTo("COMPLETED");
|
||||
assertThat(result.inventoryAdjustmentId()).isEqualTo(81L);
|
||||
assertThat(result.restoredPackages()).isEqualTo(2);
|
||||
assertThat(delivery.getStatus()).isEqualTo("RETURNED");
|
||||
verify(evidenceFiles).validate("1001", 1L, 101L);
|
||||
verify(inventoryCommands).restoreReturnedInventory(any(), any(), any(ReturnedInventoryRestoreRequest.class));
|
||||
}
|
||||
|
||||
private String hashFor(Object request) {
|
||||
return (String) ReflectionTestUtils.invokeMethod(service, "hash", request);
|
||||
}
|
||||
|
||||
private ExceptionOrder order(String status, long version) {
|
||||
ExceptionOrder order = new ExceptionOrder();
|
||||
order.setId(1L); order.setCustomerId("1001"); order.setExceptionNo("EX-1");
|
||||
order.setStatus(status); order.setVersion(version);
|
||||
return order;
|
||||
}
|
||||
}
|
||||
+10
@@ -24,6 +24,11 @@ class Phase5AdminPermissionContractTest {
|
||||
assertEquals(authority(OverseasPermissions.EXCEPTION_ORDER_LIST), permissions.get("page"));
|
||||
assertEquals(authority(OverseasPermissions.EXCEPTION_ORDER_LIST), permissions.get("resolve"));
|
||||
assertEquals(authority(OverseasPermissions.EXCEPTION_ORDER_CREATE), permissions.get("create"));
|
||||
assertEquals(authority(OverseasPermissions.EXCEPTION_ORDER_LIST), permissions.get("detail"));
|
||||
assertEquals(authority(OverseasPermissions.EXCEPTION_ORDER_PROCESS), permissions.get("uploadEvidence"));
|
||||
assertEquals(authority(OverseasPermissions.EXCEPTION_ORDER_PROCESS), permissions.get("startProcessing"));
|
||||
assertEquals(authority(OverseasPermissions.EXCEPTION_ORDER_PROCESS), permissions.get("complete"));
|
||||
assertEquals(authority(OverseasPermissions.EXCEPTION_ORDER_PROCESS), permissions.get("cancel"));
|
||||
assertEquals(authority(OverseasPermissions.EXCEPTION_ORDER_LIST), permissions.get("returnOptions"));
|
||||
assertEquals(authority(OverseasPermissions.DELIVERY_RETURN), permissions.get("markReturn"));
|
||||
}
|
||||
@@ -49,6 +54,7 @@ class Phase5AdminPermissionContractTest {
|
||||
OverseasPermissions.DISPATCH_LIST,
|
||||
OverseasPermissions.EXCEPTION_ORDER_LIST,
|
||||
OverseasPermissions.EXCEPTION_ORDER_CREATE,
|
||||
OverseasPermissions.EXCEPTION_ORDER_PROCESS,
|
||||
OverseasPermissions.DELIVERY_RETURN,
|
||||
OverseasPermissions.DELIVERY_STATUS,
|
||||
OverseasPermissions.CUSTOMER_VISIT_LIST,
|
||||
@@ -59,6 +65,10 @@ class Phase5AdminPermissionContractTest {
|
||||
for (String path : new String[] {
|
||||
"/api/platform/v1/last-leg/dispatches:",
|
||||
"/api/platform/v1/last-leg/exception-orders:",
|
||||
"/api/platform/v1/last-leg/exception-orders/{exceptionNo}:",
|
||||
"/api/platform/v1/last-leg/exception-orders/{exceptionNo}/start-processing:",
|
||||
"/api/platform/v1/last-leg/exception-orders/{exceptionNo}/complete:",
|
||||
"/api/platform/v1/last-leg/exception-orders/{exceptionNo}/cancel:",
|
||||
"/api/platform/v1/last-leg/delivery-orders/{deliveryNo}/mark-return:",
|
||||
"/api/platform/v1/last-leg/delivery-orders/{deliveryNo}/status:",
|
||||
"/api/platform/v1/last-leg/customer-visits:",
|
||||
|
||||
Reference in New Issue
Block a user