feat: complete overseas pda warehouse operations
This commit is contained in:
+1
@@ -58,4 +58,5 @@ class HttpWarehouseResourceGatewayTest {
|
||||
assertThat(result.palletId()).isEqualTo(2092040891261427713L);
|
||||
server.verify();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package com.baiyue.lastleg.palletops.controller;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Arrays;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class PdaPalletOperationControllerContractTest {
|
||||
@Test void exposesOnlyDedicatedPdaPalletEndpointsWithCanonicalPickerAuthority() {
|
||||
RequestMapping mapping = PdaPalletOperationController.class.getAnnotation(RequestMapping.class);
|
||||
assertArrayEquals(new String[]{"/api/pda/v1/last-leg/pallet-operations"}, mapping.value());
|
||||
PreAuthorize authorize = PdaPalletOperationController.class.getAnnotation(PreAuthorize.class);
|
||||
assertEquals("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')", authorize.value());
|
||||
Set<String> paths = Arrays.stream(PdaPalletOperationController.class.getDeclaredMethods())
|
||||
.map(Method::getDeclaredAnnotations).flatMap(Arrays::stream)
|
||||
.filter(PostMapping.class::isInstance).map(PostMapping.class::cast)
|
||||
.flatMap(annotation -> Arrays.stream(annotation.value())).collect(Collectors.toSet());
|
||||
assertEquals(Set.of("/pallets/resolve", "/assemble", "/disassemble", "/swap", "/unbind"), paths);
|
||||
}
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
package com.baiyue.lastleg.palletops.service;
|
||||
|
||||
import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
|
||||
import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper;
|
||||
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
|
||||
import com.baiyue.lastleg.palletops.dto.PalletOperationRequests;
|
||||
import com.baiyue.lastleg.palletops.entity.PdaPalletOperation;
|
||||
import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway;
|
||||
import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper;
|
||||
import com.baiyue.lastleg.palletops.model.PalletInventoryRow;
|
||||
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
|
||||
import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService;
|
||||
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.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.transaction.TransactionDefinition;
|
||||
import org.springframework.transaction.support.AbstractPlatformTransactionManager;
|
||||
import org.springframework.transaction.support.DefaultTransactionStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class PdaPalletOperationServiceTest {
|
||||
@Mock private PdaPalletOperationMapper mapper;
|
||||
@Mock private InventoryPositionMapper positions;
|
||||
@Mock private InventoryBalanceMapper balances;
|
||||
@Mock private InventoryLedgerMapper ledgers;
|
||||
@Mock private PalletPhysicalGateway physical;
|
||||
@Mock private PdaWarehouseService history;
|
||||
private PdaPalletOperationService service;
|
||||
|
||||
@BeforeEach void setUp() {
|
||||
service = new PdaPalletOperationService();
|
||||
ReflectionTestUtils.setField(service, "mapper", mapper);
|
||||
ReflectionTestUtils.setField(service, "positions", positions);
|
||||
ReflectionTestUtils.setField(service, "balances", balances);
|
||||
ReflectionTestUtils.setField(service, "ledgers", ledgers);
|
||||
ReflectionTestUtils.setField(service, "physical", physical);
|
||||
ReflectionTestUtils.setField(service, "history", history);
|
||||
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
|
||||
service.configureTransactions(new NoOpTransactionManager());
|
||||
}
|
||||
|
||||
@Test void resolveRejectsPalletOutsideTokenFrozenWarehouse() {
|
||||
when(physical.resolve("P-1")).thenReturn(pallet(10L, "P-1", 99L, 1));
|
||||
assertThrows(ResponseStatusException.class, () -> service.resolve(identity(),
|
||||
new PalletOperationRequests.Scan("P-1")));
|
||||
verifyNoInteractions(mapper);
|
||||
}
|
||||
|
||||
@Test void localConcurrencyFailureCompensatesEveryAppliedPhysicalTransitionInReverseSafeForm() {
|
||||
var source = pallet(10L, "SOURCE", 88L, 1);
|
||||
var target = pallet(20L, "TARGET", 88L, 2);
|
||||
when(physical.resolve("SOURCE")).thenReturn(source);
|
||||
when(physical.resolve("TARGET")).thenReturn(target);
|
||||
when(physical.resolveLocation(88L, target.locationId())).thenReturn(
|
||||
new PalletPhysicalGateway.LocationReference(target.locationId(), "LOC-" + target.locationId(),
|
||||
"STORAGE", "ACTIVE"));
|
||||
PalletInventoryRow planned = row(100L, 10L, 1L, 5L);
|
||||
PalletInventoryRow stale = row(100L, 10L, 2L, 5L);
|
||||
when(mapper.content("88", 10L)).thenReturn(List.of(planned));
|
||||
when(mapper.content("88", 20L)).thenReturn(List.of());
|
||||
when(mapper.palletPackages("88", 10L)).thenReturn(5L);
|
||||
when(mapper.palletPackages("88", 20L)).thenReturn(0L);
|
||||
when(mapper.lockPosition("88", 100L)).thenReturn(stale);
|
||||
|
||||
AtomicReference<PdaPalletOperation> operation = new AtomicReference<>();
|
||||
doAnswer(invocation -> { operation.set(invocation.getArgument(0)); return 1; })
|
||||
.when(mapper).insert(any(PdaPalletOperation.class));
|
||||
when(mapper.selectById(anyLong())).thenAnswer(invocation -> operation.get());
|
||||
when(physical.apply(eq(88L), any(), anyString(), anyLong(), anyString(), any()))
|
||||
.thenAnswer(invocation -> transition(invocation.getArgument(1), invocation.getArgument(2)));
|
||||
|
||||
var request = new PalletOperationRequests.Assemble("idem-1",
|
||||
new PalletOperationRequests.PalletExpectation("TARGET", 2),
|
||||
List.of(new PalletOperationRequests.PalletExpectation("SOURCE", 1)),
|
||||
List.of(new PalletOperationRequests.MoveLine(100L, 5L, 1L)), "assemble");
|
||||
|
||||
assertThrows(ResponseStatusException.class, () -> service.assemble(identity(), request));
|
||||
|
||||
verify(physical, times(2)).compensate(eq(88L), any(PalletPhysicalGateway.Transition.class));
|
||||
assertEquals("FAILED_COMPENSATED", operation.get().getStatus());
|
||||
verify(positions, never()).updateById(any(com.baiyue.lastleg.inventory.entity.InventoryPosition.class));
|
||||
verifyNoInteractions(ledgers, history);
|
||||
}
|
||||
|
||||
private static PdaWarehouseIdentity identity() { return new PdaWarehouseIdentity("7", "picker", 88L); }
|
||||
private static PalletPhysicalGateway.PalletReference pallet(long id, String code, long warehouse, int version) {
|
||||
return new PalletPhysicalGateway.PalletReference(id, code, warehouse, 1000L + id,
|
||||
id == 20L ? "IDLE" : "OCCUPIED", null, version);
|
||||
}
|
||||
private static PalletPhysicalGateway.Transition transition(PalletPhysicalGateway.PalletReference pallet,
|
||||
String targetStatus) {
|
||||
return new PalletPhysicalGateway.Transition("op-" + pallet.palletId(), pallet.palletId(), pallet.palletCode(),
|
||||
pallet.warehouseId(), pallet.status(), targetStatus, pallet.locationId(), pallet.locationId(),
|
||||
pallet.version(), pallet.version() + 1, "APPLIED", false);
|
||||
}
|
||||
private static PalletInventoryRow row(long positionId, long palletId, long version, long packages) {
|
||||
PalletInventoryRow value = new PalletInventoryRow(); value.setCustomerId("100"); value.setCustomerName("C");
|
||||
value.setBalanceId(200L); value.setPositionId(positionId); value.setInboundCode("IN-1");
|
||||
value.setSkuCode("SKU-1"); value.setDestinationWarehouseCode("ONT8"); value.setPalletId(palletId);
|
||||
value.setPalletNo(palletId == 10L ? "SOURCE" : "TARGET"); value.setBatchNo("DEFAULT");
|
||||
value.setWarehouseLocationId(1010L); value.setWarehouseLocationCode("L-1");
|
||||
value.setAvailablePackages(packages); value.setHoldPackages(0L); value.setAllocatedPackages(0L);
|
||||
value.setPickedPackages(0L); value.setReceivingPackages(0L); value.setVersion(version); return value;
|
||||
}
|
||||
|
||||
private static final class NoOpTransactionManager extends AbstractPlatformTransactionManager {
|
||||
@Override protected Object doGetTransaction() { return new Object(); }
|
||||
@Override protected void doBegin(Object transaction, TransactionDefinition definition) { }
|
||||
@Override protected void doCommit(DefaultTransactionStatus status) { }
|
||||
@Override protected void doRollback(DefaultTransactionStatus status) { }
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package com.baiyue.lastleg.pda;
|
||||
|
||||
import com.baiyue.lastleg.pda.move.controller.PdaInventoryMoveController;
|
||||
import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveRequest;
|
||||
import com.baiyue.lastleg.pda.stocktake.controller.PdaStocktakeController;
|
||||
import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeRequests;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
|
||||
import java.util.Set;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class PdaInventoryOperationContractTest {
|
||||
@Test
|
||||
void controllersUseOnlyCanonicalPdaIdentityAndRoutes() {
|
||||
assertThat(PdaStocktakeController.class.getAnnotation(RequestMapping.class).value())
|
||||
.containsExactly("/api/pda/v1/last-leg/stocktakes");
|
||||
assertThat(PdaInventoryMoveController.class.getAnnotation(RequestMapping.class).value())
|
||||
.containsExactly("/api/pda/v1/last-leg/inventory-moves");
|
||||
assertThat(PdaStocktakeController.class.getAnnotation(PreAuthorize.class).value())
|
||||
.isEqualTo("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')");
|
||||
assertThat(PdaInventoryMoveController.class.getAnnotation(PreAuthorize.class).value())
|
||||
.isEqualTo("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_WAREHOUSE_PICKER')");
|
||||
}
|
||||
|
||||
@Test
|
||||
void deviceRequestsCannotSupplyIdentityOrWarehouseScope() {
|
||||
Set<String> forbidden = Set.of("customerId", "warehouseId", "overseasWarehouseId", "operator", "operatorId");
|
||||
for (Class<?> type : List.of(PdaStocktakeRequests.Create.class, PdaStocktakeRequests.Count.class,
|
||||
PdaStocktakeRequests.Submit.class, PdaInventoryMoveRequest.class)) {
|
||||
Set<String> fields = Stream.of(type.getRecordComponents()).map(component -> component.getName())
|
||||
.collect(Collectors.toSet());
|
||||
assertThat(fields).doesNotContainAnyElementsOf(forbidden);
|
||||
}
|
||||
}
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
package com.baiyue.lastleg.pda;
|
||||
|
||||
import com.baiyue.lastleg.palletops.dto.PalletOperationRequests;
|
||||
import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveRequest;
|
||||
import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeRequests;
|
||||
import com.baiyue.lastleg.relabel.pda.dto.PdaRelabelRequests;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.RecordComponent;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class PdaOperationsOpenApiContractTest {
|
||||
private static final Set<String> SERVER_OWNED = Set.of(
|
||||
"customerId", "warehouseId", "overseasWarehouseId", "operator", "operatorKey", "operatorName");
|
||||
|
||||
@Test
|
||||
void pdaOperationRequestsNeverAcceptServerOwnedScope() {
|
||||
List<Class<?>> containers = List.of(PdaStocktakeRequests.class, PalletOperationRequests.class,
|
||||
PdaRelabelRequests.class);
|
||||
for (Class<?> container : containers) {
|
||||
for (Class<?> type : container.getDeclaredClasses()) {
|
||||
if (type.isRecord()) assertNoServerOwnedFields(type);
|
||||
}
|
||||
}
|
||||
assertNoServerOwnedFields(PdaInventoryMoveRequest.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void versionedOpenApiPublishesEveryPdaWarehouseOperation() throws IOException {
|
||||
String yaml = resource("openapi/overseas-pda-operations-v1.yaml");
|
||||
List<String> paths = List.of(
|
||||
"/api/pda/v1/last-leg/stocktakes:",
|
||||
"/api/pda/v1/last-leg/stocktakes/{taskNo}:",
|
||||
"/api/pda/v1/last-leg/stocktakes/{taskNo}/counts:",
|
||||
"/api/pda/v1/last-leg/stocktakes/{taskNo}/submit:",
|
||||
"/api/pda/v1/last-leg/inventory-moves:",
|
||||
"/api/pda/v1/last-leg/pallet-operations/pallets/resolve:",
|
||||
"/api/pda/v1/last-leg/pallet-operations/assemble:",
|
||||
"/api/pda/v1/last-leg/pallet-operations/disassemble:",
|
||||
"/api/pda/v1/last-leg/pallet-operations/swap:",
|
||||
"/api/pda/v1/last-leg/pallet-operations/unbind:",
|
||||
"/api/pda/v1/last-leg/relabel-tasks/candidates:",
|
||||
"/api/pda/v1/last-leg/relabel-tasks/{relabelNo}:",
|
||||
"/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/claim:",
|
||||
"/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/units:",
|
||||
"/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/units/skip:",
|
||||
"/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/exceptions:",
|
||||
"/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/photos/files:",
|
||||
"/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/complete:",
|
||||
"/api/pda/v1/last-leg/relabel-tasks/{relabelNo}/review:");
|
||||
for (String path : paths) assertTrue(yaml.contains(path), path);
|
||||
assertEquals(19, paths.size());
|
||||
assertTrue(yaml.contains("enum: [LOCATION, PALLET]"));
|
||||
assertTrue(yaml.contains("enum: [APPROVE, REWORK]"));
|
||||
assertTrue(yaml.contains("enum: [DAMAGED, MISSING_LABEL, LABEL_MISMATCH, UNREADABLE, OTHER]"));
|
||||
for (String field : SERVER_OWNED) assertFalse(yaml.contains(field + ":"), field);
|
||||
for (String excluded : List.of("/outbound", "/dispatch", "/delivery")) {
|
||||
assertFalse(yaml.contains(excluded), excluded);
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertNoServerOwnedFields(Class<?> type) {
|
||||
Set<String> fields = Arrays.stream(type.getRecordComponents())
|
||||
.map(RecordComponent::getName).collect(Collectors.toSet());
|
||||
assertTrue(java.util.Collections.disjoint(fields, SERVER_OWNED), type.getSimpleName());
|
||||
}
|
||||
|
||||
private String resource(String name) throws IOException {
|
||||
try (var input = getClass().getClassLoader().getResourceAsStream(name)) {
|
||||
assertTrue(input != null, name);
|
||||
return new String(input.readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
}
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
package com.baiyue.lastleg.pda.move.service;
|
||||
|
||||
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
|
||||
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
|
||||
import com.baiyue.lastleg.inventory.entity.InventoryLedger;
|
||||
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
|
||||
import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
|
||||
import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper;
|
||||
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
|
||||
import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway;
|
||||
import com.baiyue.lastleg.pda.move.dto.PdaInventoryMoveRequest;
|
||||
import com.baiyue.lastleg.pda.move.entity.PdaInventoryMove;
|
||||
import com.baiyue.lastleg.pda.move.mapper.PdaInventoryMoveMapper;
|
||||
import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService;
|
||||
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 java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
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.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class PdaInventoryMoveServiceTest {
|
||||
@Mock PdaInventoryMoveMapper moves;
|
||||
@Mock InventoryPositionMapper positions;
|
||||
@Mock InventoryBalanceMapper balances;
|
||||
@Mock InventoryLedgerMapper ledgers;
|
||||
@Mock WarehouseResourceGateway warehouse;
|
||||
@Mock PalletPhysicalGateway physical;
|
||||
@Mock PdaWarehouseService history;
|
||||
private PdaInventoryMoveService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new PdaInventoryMoveService(); ReflectionTestUtils.setField(service, "moves", moves);
|
||||
ReflectionTestUtils.setField(service, "positions", positions); ReflectionTestUtils.setField(service, "balances", balances);
|
||||
ReflectionTestUtils.setField(service, "ledgers", ledgers); ReflectionTestUtils.setField(service, "warehouse", warehouse);
|
||||
ReflectionTestUtils.setField(service, "physical", physical);
|
||||
ReflectionTestUtils.setField(service, "history", history);
|
||||
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
|
||||
}
|
||||
|
||||
@Test
|
||||
void atomicallyRelocatesPhysicalPalletAndEveryWarehouseScopedPosition() {
|
||||
when(warehouse.resolvePallet("PAL-1")).thenReturn(pallet(900L, 20L, 2));
|
||||
when(warehouse.resolveLocation(900L, 20L)).thenReturn(location(20L, "A-01"));
|
||||
when(warehouse.resolveLocation(900L, "B-01")).thenReturn(location(21L, "B-01"));
|
||||
InventoryPosition position = new InventoryPosition(); position.setId(11L); position.setCustomerId("100");
|
||||
position.setBalanceId(10L); position.setOverseasWarehouseId("900"); position.setPalletId(30L);
|
||||
position.setPalletNo("PAL-1"); position.setWarehouseLocationId(20L); position.setWarehouseLocationCode("A-01");
|
||||
position.setAvailablePackages(5L); position.setHoldPackages(1L); position.setAllocatedPackages(0L);
|
||||
position.setPickedPackages(0L); position.setReceivingPackages(0L); position.setVersion(4L);
|
||||
when(moves.lockPositions("900", 30L)).thenReturn(List.of(position));
|
||||
InventoryBalance balance = new InventoryBalance(); balance.setId(10L); balance.setCustomerId("100");
|
||||
balance.setOverseasWarehouseId("900"); balance.setInboundOrderId(50L); balance.setInboundCode("IN-1");
|
||||
when(balances.selectById(10L)).thenReturn(balance);
|
||||
when(physical.apply(eq(900L), any(PalletPhysicalGateway.PalletReference.class), eq("OCCUPIED"),
|
||||
eq(21L), anyString(), eq("库位优化"))).thenReturn(transition(false));
|
||||
|
||||
var response = service.move(900L, "operator-1",
|
||||
new PdaInventoryMoveRequest("PAL-1", "B-01", 2, "move-1", "库位优化"));
|
||||
|
||||
verify(physical).apply(eq(900L), argThat(value -> value.palletId() == 30L
|
||||
&& value.locationId() == 20L && value.version() == 2), eq("OCCUPIED"),
|
||||
eq(21L), anyString(), eq("库位优化"));
|
||||
ArgumentCaptor<InventoryPosition> updated = ArgumentCaptor.forClass(InventoryPosition.class);
|
||||
verify(positions).updateById(updated.capture());
|
||||
assertThat(updated.getValue().getWarehouseLocationId()).isEqualTo(21L);
|
||||
assertThat(updated.getValue().getWarehouseLocationCode()).isEqualTo("B-01");
|
||||
assertThat(response.packageCount()).isEqualTo(6L);
|
||||
assertThat(response.palletVersion()).isEqualTo(3);
|
||||
assertThat(response.status()).isEqualTo("COMPLETED");
|
||||
verify(ledgers).insert(any(InventoryLedger.class)); verify(moves).updateById(any(PdaInventoryMove.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsCrossWarehousePalletWithoutMovingResources() {
|
||||
when(warehouse.resolvePallet("PAL-1")).thenReturn(pallet(901L, 20L, 2));
|
||||
assertThatThrownBy(() -> service.move(900L, "operator-1",
|
||||
new PdaInventoryMoveRequest("PAL-1", "B-01", 2, "move-1", "库位优化")))
|
||||
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
|
||||
verify(physical, never()).apply(anyLong(), any(), anyString(), anyLong(), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void resumesWhenWarehouseCommittedButLastlegTransactionRolledBack() {
|
||||
when(warehouse.resolvePallet("PAL-1")).thenReturn(pallet(900L, 21L, 3));
|
||||
when(warehouse.resolveLocation(900L, "B-01")).thenReturn(location(21L, "B-01"));
|
||||
when(warehouse.resolveLocation(900L, 20L)).thenReturn(location(20L, "A-01"));
|
||||
InventoryPosition position = new InventoryPosition(); position.setId(11L); position.setCustomerId("100");
|
||||
position.setBalanceId(10L); position.setOverseasWarehouseId("900"); position.setPalletId(30L);
|
||||
position.setPalletNo("PAL-1"); position.setWarehouseLocationId(20L); position.setWarehouseLocationCode("A-01");
|
||||
position.setAvailablePackages(5L); position.setHoldPackages(0L); position.setAllocatedPackages(0L);
|
||||
position.setPickedPackages(0L); position.setReceivingPackages(0L); position.setVersion(4L);
|
||||
when(moves.lockPositions("900", 30L)).thenReturn(List.of(position));
|
||||
InventoryBalance balance = new InventoryBalance(); balance.setId(10L); balance.setCustomerId("100");
|
||||
balance.setOverseasWarehouseId("900"); balance.setInboundOrderId(50L); balance.setInboundCode("IN-1");
|
||||
when(balances.selectById(10L)).thenReturn(balance);
|
||||
when(physical.apply(eq(900L), any(PalletPhysicalGateway.PalletReference.class), eq("OCCUPIED"),
|
||||
eq(21L), anyString(), eq("恢复移库"))).thenReturn(transition(true));
|
||||
|
||||
var response = service.move(900L, "operator-1",
|
||||
new PdaInventoryMoveRequest("PAL-1", "B-01", 2, "move-retry", "恢复移库"));
|
||||
|
||||
verify(physical).apply(eq(900L), argThat(value -> value.locationId() == 20L && value.version() == 2),
|
||||
eq("OCCUPIED"), eq(21L), anyString(), eq("恢复移库"));
|
||||
assertThat(response.sourceLocationCode()).isEqualTo("A-01");
|
||||
assertThat(response.targetLocationCode()).isEqualTo("B-01");
|
||||
verify(positions).updateById(any(InventoryPosition.class));
|
||||
}
|
||||
|
||||
private static WarehouseResourceGateway.WarehousePalletReference pallet(long warehouseId, long locationId, int version) {
|
||||
return new WarehouseResourceGateway.WarehousePalletReference(30L, "PAL-1", warehouseId, locationId,
|
||||
"OCCUPIED", null, version);
|
||||
}
|
||||
private static WarehouseResourceGateway.WarehouseLocationReference location(long id, String code) {
|
||||
return new WarehouseResourceGateway.WarehouseLocationReference(id, code, "STORAGE", BigDecimal.TEN, "OCCUPIED");
|
||||
}
|
||||
private static PalletPhysicalGateway.Transition transition(boolean replayed) {
|
||||
return new PalletPhysicalGateway.Transition("move-transition", 30L, "PAL-1", 900L,
|
||||
"OCCUPIED", "OCCUPIED", 20L, 21L, 2, 3, "APPLIED", replayed);
|
||||
}
|
||||
}
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
package com.baiyue.lastleg.pda.stocktake.service;
|
||||
|
||||
import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway;
|
||||
import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper;
|
||||
import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper;
|
||||
import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper;
|
||||
import com.baiyue.lastleg.inventory.entity.InventoryBalance;
|
||||
import com.baiyue.lastleg.inventory.entity.InventoryLedger;
|
||||
import com.baiyue.lastleg.inventory.entity.InventoryPosition;
|
||||
import com.baiyue.lastleg.pda.stocktake.dto.PdaStocktakeRequests;
|
||||
import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeItem;
|
||||
import com.baiyue.lastleg.pda.stocktake.entity.PdaStocktakeTask;
|
||||
import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeActionMapper;
|
||||
import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeInventoryMapper;
|
||||
import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeItemMapper;
|
||||
import com.baiyue.lastleg.pda.stocktake.mapper.PdaStocktakeTaskMapper;
|
||||
import com.baiyue.lastleg.pda.stocktake.model.StocktakeCandidate;
|
||||
import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService;
|
||||
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 java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
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.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class PdaStocktakeServiceTest {
|
||||
@Mock PdaStocktakeTaskMapper tasks;
|
||||
@Mock PdaStocktakeItemMapper items;
|
||||
@Mock PdaStocktakeActionMapper actions;
|
||||
@Mock PdaStocktakeInventoryMapper inventory;
|
||||
@Mock InventoryPositionMapper positions;
|
||||
@Mock InventoryBalanceMapper balances;
|
||||
@Mock InventoryLedgerMapper ledgers;
|
||||
@Mock WarehouseResourceGateway warehouse;
|
||||
@Mock PdaWarehouseService history;
|
||||
private PdaStocktakeService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new PdaStocktakeService();
|
||||
ReflectionTestUtils.setField(service, "tasks", tasks); ReflectionTestUtils.setField(service, "items", items);
|
||||
ReflectionTestUtils.setField(service, "actions", actions); ReflectionTestUtils.setField(service, "inventory", inventory);
|
||||
ReflectionTestUtils.setField(service, "positions", positions); ReflectionTestUtils.setField(service, "balances", balances);
|
||||
ReflectionTestUtils.setField(service, "ledgers", ledgers); ReflectionTestUtils.setField(service, "warehouse", warehouse);
|
||||
ReflectionTestUtils.setField(service, "history", history);
|
||||
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createsWarehouseBoundLocationTaskAndFreezesEveryPosition() {
|
||||
when(warehouse.resolveLocation(900L, "A-01")).thenReturn(new WarehouseResourceGateway.WarehouseLocationReference(
|
||||
20L, "A-01", "STORAGE", BigDecimal.TEN, "OCCUPIED"));
|
||||
StocktakeCandidate candidate = new StocktakeCandidate(); candidate.setCustomerId("100");
|
||||
candidate.setBalanceId(10L); candidate.setPositionId(11L); candidate.setSkuCode("SKU-1");
|
||||
candidate.setPalletId(30L); candidate.setPalletCode("PAL-1"); candidate.setLocationId(20L);
|
||||
candidate.setLocationCode("A-01"); candidate.setBookPackages(8L); candidate.setPositionVersion(3L);
|
||||
when(items.candidatesByLocation("900", 20L, "A-01")).thenReturn(List.of(candidate));
|
||||
when(items.views(anyLong())).thenReturn(List.of());
|
||||
|
||||
var response = service.create(900L, "operator-1", new PdaStocktakeRequests.Create(
|
||||
"location", "A-01", "create-1"));
|
||||
|
||||
ArgumentCaptor<PdaStocktakeTask> task = ArgumentCaptor.forClass(PdaStocktakeTask.class);
|
||||
ArgumentCaptor<PdaStocktakeItem> item = ArgumentCaptor.forClass(PdaStocktakeItem.class);
|
||||
verify(tasks).insert(task.capture()); verify(items).insert(item.capture());
|
||||
assertThat(task.getValue().getOverseasWarehouseId()).isEqualTo(900L);
|
||||
assertThat(task.getValue().getScopeType()).isEqualTo("LOCATION");
|
||||
assertThat(task.getValue().getCreatedBy()).isEqualTo("operator-1");
|
||||
assertThat(item.getValue().getCustomerId()).isEqualTo("100");
|
||||
assertThat(item.getValue().getBookPackages()).isEqualTo(8L);
|
||||
assertThat(item.getValue().getPositionVersion()).isEqualTo(3L);
|
||||
assertThat(response.task().status()).isEqualTo("IN_PROGRESS");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsPalletFromAnotherWarehouseBeforeReadingInventory() {
|
||||
when(warehouse.resolvePallet("PAL-1")).thenReturn(new WarehouseResourceGateway.WarehousePalletReference(
|
||||
30L, "PAL-1", 901L, 20L, "OCCUPIED", null, 2));
|
||||
|
||||
assertThatThrownBy(() -> service.create(900L, "operator-1",
|
||||
new PdaStocktakeRequests.Create("PALLET", "PAL-1", "create-1")))
|
||||
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
|
||||
verify(items, never()).candidatesByPallet(anyString(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void submitsCountedDifferenceAndWritesBalancedAuditLedger() {
|
||||
PdaStocktakeTask task = new PdaStocktakeTask(); task.setId(40L); task.setTaskNo("CC-40");
|
||||
task.setOverseasWarehouseId(900L); task.setScopeType("PALLET"); task.setScopeCode("PAL-1");
|
||||
task.setStatus("IN_PROGRESS"); task.setPositionCount(1); task.setCountedCount(1); task.setDifferenceCount(1);
|
||||
task.setVersion(2L);
|
||||
PdaStocktakeTask submitted = new PdaStocktakeTask(); submitted.setId(40L); submitted.setTaskNo("CC-40");
|
||||
submitted.setOverseasWarehouseId(900L); submitted.setScopeType("PALLET"); submitted.setScopeCode("PAL-1");
|
||||
submitted.setStatus("SUBMITTED"); submitted.setPositionCount(1); submitted.setCountedCount(1);
|
||||
submitted.setDifferenceCount(1); submitted.setVersion(3L);
|
||||
when(tasks.find(900L, "CC-40")).thenReturn(task, submitted);
|
||||
when(tasks.lock(900L, "CC-40")).thenReturn(task); when(tasks.submit(40L, 2L, "operator-1", "复盘确认")).thenReturn(1);
|
||||
|
||||
PdaStocktakeItem item = new PdaStocktakeItem(); item.setId(41L); item.setTaskId(40L); item.setCustomerId("100");
|
||||
item.setBalanceId(10L); item.setPositionId(11L); item.setSkuCode("SKU-1"); item.setBookPackages(5L);
|
||||
item.setActualPackages(7L); item.setDifferencePackages(2L); item.setPositionVersion(3L); item.setVersion(1L);
|
||||
when(items.lockAll(40L)).thenReturn(List.of(item)); when(items.views(40L)).thenReturn(List.of());
|
||||
|
||||
InventoryPosition position = new InventoryPosition(); position.setId(11L); position.setCustomerId("100");
|
||||
position.setBalanceId(10L); position.setOverseasWarehouseId("900"); position.setPalletId(30L);
|
||||
position.setPalletNo("PAL-1"); position.setAvailablePackages(5L); position.setHoldPackages(0L);
|
||||
position.setAllocatedPackages(0L); position.setPickedPackages(0L); position.setReceivingPackages(0L);
|
||||
position.setVersion(3L);
|
||||
InventoryBalance balance = new InventoryBalance(); balance.setId(10L); balance.setCustomerId("100");
|
||||
balance.setOverseasWarehouseId("900"); balance.setAvailablePackages(5L); balance.setVersion(8L);
|
||||
when(inventory.lockPosition(11L, "100", "900")).thenReturn(position);
|
||||
when(inventory.lockBalance(10L, "100", "900")).thenReturn(balance);
|
||||
|
||||
var response = service.submit(900L, "operator-1", "CC-40",
|
||||
new PdaStocktakeRequests.Submit(2L, "复盘确认", "submit-1"));
|
||||
|
||||
ArgumentCaptor<InventoryPosition> changedPosition = ArgumentCaptor.forClass(InventoryPosition.class);
|
||||
ArgumentCaptor<InventoryBalance> changedBalance = ArgumentCaptor.forClass(InventoryBalance.class);
|
||||
ArgumentCaptor<InventoryLedger> ledger = ArgumentCaptor.forClass(InventoryLedger.class);
|
||||
verify(positions).updateById(changedPosition.capture()); verify(balances).updateById(changedBalance.capture());
|
||||
verify(ledgers).insert(ledger.capture());
|
||||
assertThat(changedPosition.getValue().getAvailablePackages()).isEqualTo(7L);
|
||||
assertThat(changedBalance.getValue().getAvailablePackages()).isEqualTo(7L);
|
||||
assertThat(ledger.getValue().getBeforePackages()).isEqualTo(5L);
|
||||
assertThat(ledger.getValue().getDeltaPackages()).isEqualTo(2L);
|
||||
assertThat(ledger.getValue().getAfterPackages()).isEqualTo(7L);
|
||||
assertThat(ledger.getValue().getBusinessType()).isEqualTo("STOCKTAKE_ADJUST");
|
||||
assertThat(response.task().status()).isEqualTo("SUBMITTED");
|
||||
}
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
package com.baiyue.lastleg.relabel.pda.controller;
|
||||
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.relabel.pda.dto.PdaRelabelRequests;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.lang.reflect.RecordComponent;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Arrays;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class PdaRelabelContractTest {
|
||||
@Test
|
||||
void controllerUsesStablePdaPathAndExactPickerAuthority() {
|
||||
assertEquals("/api/pda/v1/last-leg/relabel-tasks",
|
||||
PdaRelabelController.class.getAnnotation(RequestMapping.class).value()[0]);
|
||||
assertEquals("hasAuthority('lastleg:pda:operate')",
|
||||
PdaRelabelController.class.getAnnotation(PreAuthorize.class).value());
|
||||
}
|
||||
|
||||
@Test
|
||||
void deviceRequestsNeverAcceptServerOwnedScopeOrOperatorFields() {
|
||||
Set<String> forbidden = Set.of("customerId", "warehouseId", "overseasWarehouseId",
|
||||
"operatorId", "operatorName", "tenantId");
|
||||
for (Class<?> type : PdaRelabelRequests.class.getDeclaredClasses()) {
|
||||
Set<String> fields = Arrays.stream(type.getRecordComponents()).map(RecordComponent::getName)
|
||||
.collect(Collectors.toSet());
|
||||
assertTrue(fields.stream().noneMatch(forbidden::contains), type.getSimpleName() + " exposes server scope");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void identityRequiresPlatformPickerPermissionAndFrozenWarehouse() {
|
||||
AuthenticatedUser valid = user(Set.of("lastleg:pda:operate", "ROLE_WAREHOUSE_PICKER"), 900L);
|
||||
assertEquals(900L, PdaRelabelController.identity(valid).overseasWarehouseId());
|
||||
assertThrows(ResponseStatusException.class,
|
||||
() -> PdaRelabelController.identity(user(Set.of("lastleg:pda:operate"), 900L)));
|
||||
assertThrows(ResponseStatusException.class,
|
||||
() -> PdaRelabelController.identity(user(Set.of("lastleg:pda:operate", "ROLE_WAREHOUSE_PICKER"), null)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void baselineDefinesPdaRelabelFactsWithoutOutboundFields() throws Exception {
|
||||
String sql = Files.readString(Path.of("src/main/resources/db/lastleg.sql"), StandardCharsets.UTF_8);
|
||||
for (String table : Set.of("ll_relabel_pda_task", "ll_relabel_pda_unit_fact",
|
||||
"ll_relabel_pda_operation", "ll_relabel_pda_history")) {
|
||||
assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS " + table));
|
||||
}
|
||||
assertTrue(sql.contains("uk_ll_relabel_pda_operation_key (operator_key,idempotency_key)"));
|
||||
assertTrue(sql.contains("uk_ll_relabel_pda_old_label (old_label)"));
|
||||
int start = sql.indexOf("CREATE TABLE IF NOT EXISTS ll_relabel_pda_task");
|
||||
int end = sql.indexOf("CREATE TABLE IF NOT EXISTS ll_dropship", start);
|
||||
String block = sql.substring(start, end);
|
||||
assertFalse(block.contains("dispatch_no"));
|
||||
assertFalse(block.contains("delivery_no"));
|
||||
assertFalse(block.contains("outbound_order_no"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void everyStateMutationIsTransactional() throws Exception {
|
||||
Class<?> service = Class.forName("com.baiyue.lastleg.relabel.pda.service.PdaRelabelService");
|
||||
for (String method : Set.of("claim", "scan", "skip", "exception", "complete", "review")) {
|
||||
assertTrue(Arrays.stream(service.getDeclaredMethods())
|
||||
.filter(candidate -> method.equals(candidate.getName()))
|
||||
.anyMatch(candidate -> candidate.isAnnotationPresent(Transactional.class)),
|
||||
method + " must be transactional");
|
||||
}
|
||||
}
|
||||
|
||||
private static AuthenticatedUser user(Set<String> permissions, Long warehouseId) {
|
||||
return new AuthenticatedUser(7L, 0L, 0L, 70L, "picker", "PLATFORM", "PDA",
|
||||
permissions, false, null, null, false, warehouseId);
|
||||
}
|
||||
}
|
||||
+245
@@ -0,0 +1,245 @@
|
||||
package com.baiyue.lastleg.relabel.pda.service;
|
||||
|
||||
import com.baiyue.lastleg.relabel.dto.RelabelRequests;
|
||||
import com.baiyue.lastleg.relabel.entity.RelabelApplication;
|
||||
import com.baiyue.lastleg.relabel.entity.RelabelItem;
|
||||
import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper;
|
||||
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
|
||||
import com.baiyue.lastleg.relabel.pda.dto.PdaRelabelRequests;
|
||||
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelOperation;
|
||||
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask;
|
||||
import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelUnitFact;
|
||||
import com.baiyue.lastleg.relabel.pda.gateway.RelabelPhotoFileGateway;
|
||||
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper;
|
||||
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelOperationMapper;
|
||||
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper;
|
||||
import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelUnitFactMapper;
|
||||
import com.baiyue.lastleg.relabel.service.RelabelApplicationService;
|
||||
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 java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class PdaRelabelServiceTest {
|
||||
@Mock RelabelApplicationMapper applications;
|
||||
@Mock RelabelItemMapper items;
|
||||
@Mock PdaRelabelTaskMapper tasks;
|
||||
@Mock PdaRelabelUnitFactMapper unitFacts;
|
||||
@Mock PdaRelabelOperationMapper operations;
|
||||
@Mock PdaRelabelHistoryMapper histories;
|
||||
@Mock RelabelApplicationService applicationService;
|
||||
@Mock RelabelPhotoFileGateway photoFiles;
|
||||
PdaRelabelService service;
|
||||
RelabelApplication application;
|
||||
RelabelItem item;
|
||||
List<PdaRelabelUnitFact> persistedFacts;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new PdaRelabelService();
|
||||
ReflectionTestUtils.setField(service, "applications", applications);
|
||||
ReflectionTestUtils.setField(service, "items", items);
|
||||
ReflectionTestUtils.setField(service, "tasks", tasks);
|
||||
ReflectionTestUtils.setField(service, "unitFacts", unitFacts);
|
||||
ReflectionTestUtils.setField(service, "operations", operations);
|
||||
ReflectionTestUtils.setField(service, "histories", histories);
|
||||
ReflectionTestUtils.setField(service, "applicationService", applicationService);
|
||||
ReflectionTestUtils.setField(service, "photoFiles", photoFiles);
|
||||
ReflectionTestUtils.setField(service, "sourceObjectMapper", new ObjectMapper());
|
||||
service.initializeObjectMapper();
|
||||
application = application("PENDING_PROCESSING");
|
||||
item = item();
|
||||
persistedFacts = new ArrayList<>();
|
||||
lenient().when(operations.find(anyString(), anyString())).thenReturn(null);
|
||||
lenient().when(items.selectList(any())).thenReturn(List.of(item));
|
||||
lenient().when(histories.selectList(any())).thenReturn(List.of());
|
||||
lenient().when(unitFacts.selectList(any())).thenAnswer(call -> List.copyOf(persistedFacts));
|
||||
lenient().when(unitFacts.insert(any(PdaRelabelUnitFact.class))).thenAnswer(call -> { persistedFacts.add(call.getArgument(0)); return 1; });
|
||||
lenient().when(tasks.updateWithVersion(any(), anyInt())).thenReturn(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void claimUsesFrozenWarehouseAndAdvancesExistingApplicationStateMachine() {
|
||||
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
|
||||
when(tasks.lock("100", 1L)).thenReturn(null);
|
||||
ArgumentCaptor<PdaRelabelTask> task = ArgumentCaptor.forClass(PdaRelabelTask.class);
|
||||
|
||||
var result = service.claim("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.Claim("claim-1"));
|
||||
|
||||
verify(tasks).insert(task.capture());
|
||||
assertEquals(900L, task.getValue().getOverseasWarehouseId());
|
||||
assertEquals("100", task.getValue().getCustomerId());
|
||||
assertEquals(PdaRelabelService.PROCESSING, result.taskStatus());
|
||||
verify(applicationService).changeStatus(eq("100"), eq("FBR-1"), eq("7"), any(RelabelRequests.ChangeStatus.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void crossWarehouseClaimIsHiddenAndCannotCreateTask() {
|
||||
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
|
||||
ResponseStatusException error = assertThrows(ResponseStatusException.class,
|
||||
() -> service.claim("7", "picker", 901L, "FBR-1", new PdaRelabelRequests.Claim("claim-1")));
|
||||
assertEquals(404, error.getStatusCode().value());
|
||||
verify(tasks, never()).insert(any(PdaRelabelTask.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void unitScanLocksItemToApplicationValidatesNewSkuAndUpdatesVersion() {
|
||||
application.setStatus("PROCESSING");
|
||||
PdaRelabelTask task = task("PROCESSING", 0);
|
||||
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
|
||||
when(tasks.lock("100", 1L)).thenReturn(task);
|
||||
when(items.selectOne(any())).thenReturn(item);
|
||||
when(unitFacts.findOldLabel("BOX-1")).thenReturn(null);
|
||||
ArgumentCaptor<PdaRelabelUnitFact> fact = ArgumentCaptor.forClass(PdaRelabelUnitFact.class);
|
||||
|
||||
var result = service.scan("7", "picker", 900L, "FBR-1",
|
||||
new PdaRelabelRequests.Scan(10L, "BOX-1", "SKU-NEW", 0, "scan-1"));
|
||||
|
||||
verify(unitFacts).insert(fact.capture());
|
||||
assertEquals("RELABELLED", fact.getValue().getResult());
|
||||
assertEquals("BOX-1", fact.getValue().getOldLabel());
|
||||
assertEquals(1, result.relabelledPackages());
|
||||
assertEquals(1, result.version());
|
||||
}
|
||||
|
||||
@Test
|
||||
void duplicateOldLabelAndStaleVersionFailBeforeWritingAnotherFact() {
|
||||
application.setStatus("PROCESSING");
|
||||
PdaRelabelTask task = task("PROCESSING", 1);
|
||||
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
|
||||
when(tasks.lock("100", 1L)).thenReturn(task);
|
||||
|
||||
assertThrows(ResponseStatusException.class, () -> service.skip("7", "picker", 900L, "FBR-1",
|
||||
new PdaRelabelRequests.Skip(10L, "BOX-2", "damaged", 0, "skip-stale")));
|
||||
verify(unitFacts, never()).insert(any(PdaRelabelUnitFact.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void oldLabelAlreadyAttributedToAnotherTaskIsRejected() {
|
||||
application.setStatus("PROCESSING");
|
||||
PdaRelabelTask task = task("PROCESSING", 0);
|
||||
PdaRelabelUnitFact otherTaskFact = fact("RELABELLED");
|
||||
otherTaskFact.setTaskId(99L);
|
||||
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
|
||||
when(tasks.lock("100", 1L)).thenReturn(task);
|
||||
when(items.selectOne(any())).thenReturn(item);
|
||||
when(unitFacts.findOldLabel("BOX-1")).thenReturn(otherTaskFact);
|
||||
|
||||
assertThrows(ResponseStatusException.class, () -> service.scan("7", "picker", 900L, "FBR-1",
|
||||
new PdaRelabelRequests.Scan(10L, "BOX-1", "SKU-NEW", 0, "scan-cross-task")));
|
||||
verify(unitFacts, never()).insert(any(PdaRelabelUnitFact.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void completeRequiresEveryUnitAndMovesOnlyPdaTaskToWaitReview() {
|
||||
application.setStatus("PROCESSING");
|
||||
PdaRelabelTask task = task("PROCESSING", 1);
|
||||
task.setRelabelledPackages(1);
|
||||
persistedFacts.add(fact("RELABELLED"));
|
||||
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
|
||||
when(tasks.lock("100", 1L)).thenReturn(task);
|
||||
|
||||
var result = service.complete("7", "picker", 900L, "FBR-1",
|
||||
new PdaRelabelRequests.Complete(List.of(501L), 1, "complete-1", "done"));
|
||||
|
||||
verify(photoFiles).validate("100", 20L, 501L);
|
||||
assertEquals(PdaRelabelService.WAIT_REVIEW, result.taskStatus());
|
||||
assertEquals("PROCESSING", result.applicationStatus());
|
||||
verify(applicationService, never()).changeStatus(anyString(), anyString(), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void approvalUsesExistingApplicationCompletionAndReworkDoesNot() {
|
||||
application.setStatus("PROCESSING");
|
||||
PdaRelabelTask approved = task("WAIT_REVIEW", 2);
|
||||
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
|
||||
when(tasks.lock("100", 1L)).thenReturn(approved);
|
||||
|
||||
var result = service.review("8", "reviewer", 900L, "FBR-1",
|
||||
new PdaRelabelRequests.Review("APPROVE", 2, "review-1", "ok"));
|
||||
|
||||
assertEquals("COMPLETED", result.taskStatus());
|
||||
assertEquals("COMPLETED", result.applicationStatus());
|
||||
verify(applicationService).changeStatus(eq("100"), eq("FBR-1"), eq("8"), any(RelabelRequests.ChangeStatus.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reviewCanReturnTaskForReworkWithoutCompletingApplication() {
|
||||
application.setStatus("PROCESSING");
|
||||
PdaRelabelTask task = task("WAIT_REVIEW", 2);
|
||||
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
|
||||
when(tasks.lock("100", 1L)).thenReturn(task);
|
||||
|
||||
var result = service.review("8", "reviewer", 900L, "FBR-1",
|
||||
new PdaRelabelRequests.Review("REWORK", 2, "review-rework", "wrong label"));
|
||||
|
||||
assertEquals("PROCESSING", result.taskStatus());
|
||||
assertEquals("PROCESSING", result.applicationStatus());
|
||||
verify(applicationService, never()).changeStatus(anyString(), anyString(), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void completionRejectsMissingPackageFactsBeforePhotoValidation() {
|
||||
application.setStatus("PROCESSING");
|
||||
PdaRelabelTask task = task("PROCESSING", 0);
|
||||
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
|
||||
when(tasks.lock("100", 1L)).thenReturn(task);
|
||||
|
||||
assertThrows(ResponseStatusException.class, () -> service.complete("7", "picker", 900L, "FBR-1",
|
||||
new PdaRelabelRequests.Complete(List.of(501L), 0, "complete-1", null)));
|
||||
verify(photoFiles, never()).validate(anyString(), anyLong(), anyLong());
|
||||
}
|
||||
|
||||
private RelabelApplication application(String status) {
|
||||
RelabelApplication value = new RelabelApplication();
|
||||
value.setId(1L); value.setCustomerId("100"); value.setRelabelNo("FBR-1");
|
||||
value.setCustomerOrderNo("ORD-1"); value.setOverseasWarehouseId("900");
|
||||
value.setStatus(status); value.setItemCount(1); value.setNotifiedPackageCount(1);
|
||||
value.setLabelDeadline(LocalDateTime.now().plusDays(1));
|
||||
return value;
|
||||
}
|
||||
|
||||
private RelabelItem item() {
|
||||
RelabelItem value = new RelabelItem(); value.setId(10L); value.setApplicationId(1L); value.setCustomerId("100");
|
||||
value.setSkuCode("SKU-OLD"); value.setNewSkuCode("SKU-NEW"); value.setProductNameZh("product");
|
||||
value.setShipmentNo("SHIP-1"); value.setNotifiedPackageCount(1); return value;
|
||||
}
|
||||
|
||||
private PdaRelabelTask task(String status, int version) {
|
||||
PdaRelabelTask value = new PdaRelabelTask(); value.setId(20L); value.setApplicationId(1L); value.setCustomerId("100");
|
||||
value.setRelabelNo("FBR-1"); value.setOverseasWarehouseId(900L); value.setStatus(status);
|
||||
value.setOperatorKey("7"); value.setOperatorName("picker"); value.setExpectedPackages(1);
|
||||
value.setRelabelledPackages(0); value.setSkippedPackages(0); value.setExceptionPackages(0);
|
||||
value.setClaimedAt(LocalDateTime.now()); value.setVersion(version); return value;
|
||||
}
|
||||
|
||||
private PdaRelabelUnitFact fact(String result) {
|
||||
PdaRelabelUnitFact value = new PdaRelabelUnitFact(); value.setId(30L); value.setTaskId(20L);
|
||||
value.setApplicationId(1L); value.setItemId(10L); value.setCustomerId("100");
|
||||
value.setOldLabel("BOX-1"); value.setNewLabel("SKU-NEW"); value.setResult(result);
|
||||
value.setOperatorName("picker"); value.setOccurredAt(LocalDateTime.now()); return value;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user