fix(lastleg): enforce delivery and return workflows

This commit is contained in:
2026-09-08 18:49:52 +08:00
parent d42629d289
commit d1090671ce
23 changed files with 546 additions and 61 deletions
@@ -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);
@@ -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());
}
}
@@ -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");
@@ -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;
}
}
@@ -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:",