This commit is contained in:
2026-10-14 18:01:25 +08:00
parent 8bc8518496
commit 471e50ffe1
34 changed files with 1088 additions and 71 deletions
@@ -42,6 +42,7 @@ class PdaCargoDiscardServiceTest {
@BeforeEach void setup() {
service = new PdaCargoDiscardService();
ReflectionTestUtils.setField(service, "outboundService", org.mockito.Mockito.mock(com.baiyue.lastleg.discard.service.CargoDiscardOutboundService.class));
ReflectionTestUtils.setField(service, "photoService", photoService);
ReflectionTestUtils.setField(service, "applications", applications);
ReflectionTestUtils.setField(service, "items", items);
@@ -122,8 +123,9 @@ class PdaCargoDiscardServiceTest {
@Test void loadedForeignCustomerItemCannotRecover() { finished("COMPLETED"); item.setCustomerId("OTHER"); assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(1))); noWrites(); }
@Test void loadedDifferentApplicationItemCannotRecover() { finished("COMPLETED"); item.setApplicationId(2L); assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(1))); noWrites(); }
@ParameterizedTest @ValueSource(ints = {3, 1, 0})
void threeLocalOrdersUploadConfirmAndReplayWithFullPartialAndZeroActualDisposal(int actual) {
@Test
void fullDisposalUploadsConfirmsAndReplaysWithoutDoubleDeduction() {
int actual = 3;
var realPhotos = new com.baiyue.lastleg.discard.service.CargoDiscardPhotoService();
var photoMapper = mock(com.baiyue.lastleg.discard.mapper.CargoDiscardPhotoMapper.class);
var photoFiles = mock(com.baiyue.lastleg.discard.gateway.CargoDiscardPhotoFileGateway.class);
@@ -151,4 +153,56 @@ class PdaCargoDiscardServiceTest {
verify(photoMapper, times(1)).updateById(any(com.baiyue.lastleg.discard.entity.CargoDiscardPhoto.class));
assertThrows(ResponseStatusException.class, () -> service.uploadPhoto(identity, "CD-1", "PALLET-1", 9L, new org.springframework.mock.web.MockMultipartFile("file", new byte[]{1})));
}
@ParameterizedTest @ValueSource(ints = {0, 1, 2})
void partialAndZeroDisposalDoNotWriteInventoryOrPhotos(int actual) {
item.setDiscardPackages(3);
assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(actual)));
noWrites(); verifyNoInteractions(photoService);
}
private void looseTask() {
var stockService = mock(com.baiyue.lastleg.discard.service.CargoDiscardStockService.class);
ReflectionTestUtils.setField(service, "stockService", stockService);
item.setSourceType("INVENTORY"); item.setInventoryPositionId(42L); item.setDiscardPackages(2);
var position = new com.baiyue.lastleg.inventory.entity.InventoryPosition();
position.setId(42L); position.setCustomerId("CUSTOMER"); position.setOverseasWarehouseId("99");
when(stockService.position("CUSTOMER", 42L)).thenReturn(position);
when(stockService.cartonMarks(item)).thenReturn(List.of("BOX-1", "BOX-2"));
}
private PdaCargoDiscardDtos.Confirm looseRequest(List<String> marks, boolean bulk) {
return new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(11L, 2)), List.of(101L), marks, bulk);
}
@Test void looseTaskExposesAllRealMarksAndRejectsMissingDuplicateAndUnknownScans() {
looseTask(); when(applications.selectList(any())).thenReturn(List.of(application));
var task = service.resolve(identity, "BOX-1", "CD-1");
assertEquals("CARTON", task.scanMode()); assertEquals(List.of("BOX-1", "BOX-2"), task.cartonMarks());
for (var marks : List.of(List.of("BOX-1"), List.of("BOX-1", "BOX-1"), List.of("BOX-1", "OTHER")))
assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "散箱", looseRequest(marks, false)));
noWrites(); verifyNoInteractions(photoService);
}
@ParameterizedTest @ValueSource(booleans = {false, true})
void looseTaskCompletesAfterAllScansOrExplicitBulkAndReplays(boolean bulk) {
looseTask(); var request = looseRequest(bulk ? List.of() : List.of("BOX-2", "BOX-1"), bulk);
assertEquals("COMPLETED", service.confirm(identity, "CD-1", "散箱", request).status());
assertEquals(CargoDiscardService.PDA_COMPLETED, application.getStatus());
service.confirm(identity, "CD-1", "散箱", request);
verify(adjustments, times(1)).discardPalletItem(eq("CUSTOMER"), same(application), same(item), anyList(), eq(2), eq("OP"), any());
verify(photoService).confirm(same(application), eq("散箱"), eq(List.of(101L)), eq(false));
}
@Test void partialBoundPalletOnlyExposesSelectedBoxesAndRequiresTheirScan() {
looseTask(); item.setCartonScanRequired(true);
var stockService = (com.baiyue.lastleg.discard.service.CargoDiscardStockService) ReflectionTestUtils.getField(service, "stockService");
var position = new com.baiyue.lastleg.inventory.entity.InventoryPosition(); position.setOverseasWarehouseId("99"); position.setPalletNo("P-42");
when(stockService.position("CUSTOMER", 42L)).thenReturn(position);
when(applications.selectList(any())).thenReturn(List.of(application));
var task = service.resolve(identity, "P-42", "CD-1");
assertEquals("CARTON", task.scanMode()); assertEquals("P-42", task.palletNo());
assertEquals(List.of("BOX-1", "BOX-2"), task.cartonMarks());
assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "P-42", looseRequest(List.of("BOX-1"), false)));
verifyNoInteractions(adjustments);
service.confirm(identity, "CD-1", "P-42", looseRequest(List.of("BOX-1", "BOX-2"), false));
service.confirm(identity, "CD-1", "P-42", looseRequest(List.of("BOX-1", "BOX-2"), false));
verify(adjustments, times(1)).discardPalletItem(eq("CUSTOMER"), same(application), same(item), anyList(), eq(2), eq("OP"), any());
}
}
@@ -0,0 +1,98 @@
package com.baiyue.lastleg.discard.service;
import com.baiyue.lastleg.delivery.entity.*;
import com.baiyue.lastleg.delivery.mapper.*;
import com.baiyue.lastleg.discard.entity.*;
import com.baiyue.lastleg.discard.mapper.*;
import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile;
import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper;
import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton;
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper;
import com.baiyue.lastleg.service.LastlegOrderNoGenerator;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class CargoDiscardOutboundServiceTest {
private final OutboundOrderMapper outbounds = mock(OutboundOrderMapper.class);
private final OutboundPalletItemMapper pallets = mock(OutboundPalletItemMapper.class);
private final CargoDiscardItemMapper items = mock(CargoDiscardItemMapper.class);
private final CargoDiscardCartonMapper cartons = mock(CargoDiscardCartonMapper.class);
private final DropshipOrderProfileMapper profiles = mock(DropshipOrderProfileMapper.class);
private final ReturnInventoryCartonMapper returnCartons = mock(ReturnInventoryCartonMapper.class);
private CargoDiscardOutboundService service;
private CargoDiscardApplication application;
private CargoDiscardItem first, second;
private OutboundOrder document;
private final List<OutboundPalletItem> snapshots = new ArrayList<>();
@BeforeEach void setup() {
service = new CargoDiscardOutboundService();
ReflectionTestUtils.setField(service, "outbounds", outbounds);
ReflectionTestUtils.setField(service, "pallets", pallets);
ReflectionTestUtils.setField(service, "items", items);
ReflectionTestUtils.setField(service, "cartons", cartons);
ReflectionTestUtils.setField(service, "profiles", profiles);
ReflectionTestUtils.setField(service, "returnCartons", returnCartons);
ReflectionTestUtils.setField(service, "orderNoGenerator", new LastlegOrderNoGenerator());
application = new CargoDiscardApplication(); application.setId(1L); application.setCustomerId("C1");
application.setDiscardNo("CD-TEST"); application.setOverseasWarehouseId("99");
application.setTotalPackages(5); application.setStatus("APPROVED");
first = line(11L, 21L, 3); second = line(12L, 22L, 2);
when(items.listApplication("C1", 1L)).thenReturn(List.of(first, second));
when(cartons.listApplication("C1", 1L)).thenReturn(List.of());
for (long id : List.of(21L, 22L)) {
var profile = new DropshipOrderProfile(); profile.setId(id); profile.setCustomerId("C1"); profile.setPalletNo("P-" + id);
when(profiles.selectById(id)).thenReturn(profile);
}
when(outbounds.findByIdempotency("C1", "CARGO_DISCARD:1")).thenAnswer(i -> document);
when(outbounds.lockByIdempotency("C1", "CARGO_DISCARD:1")).thenAnswer(i -> document);
when(outbounds.insert(any(OutboundOrder.class))).thenAnswer(i -> { document = i.getArgument(0); return 1; });
when(pallets.insert(any(OutboundPalletItem.class))).thenAnswer(i -> { snapshots.add(i.getArgument(0)); return 1; });
when(pallets.findByOutbound(eq("C1"), anyLong())).thenAnswer(i -> snapshots);
}
private CargoDiscardItem line(long id, long profile, int quantity) {
var line = new CargoDiscardItem(); line.setId(id); line.setApplicationId(1L); line.setCustomerId("C1");
line.setProfileId(profile); line.setDiscardPackages(quantity); line.setSkuCode("SKU-" + id); return line;
}
@Test void approvalCreatesExactlyOneDocumentAndImmutablePlannedLines() {
service.create(application, "OP"); service.create(application, "OP");
assertEquals("DISCARD_OUTBOUND", document.getOutboundType()); assertEquals("WAIT_PICK", document.getStatus());
assertEquals(5, document.getPackageCount()); assertEquals(2, document.getPalletCount());
assertTrue(service.outboundNo(application).startsWith("OB-"));
assertEquals(List.of(3, 2), snapshots.stream().map(OutboundPalletItem::getPackageCount).toList());
verify(outbounds, times(1)).insert(any(OutboundOrder.class)); verify(pallets, times(2)).insert(any(OutboundPalletItem.class));
}
@Test void partialThenFinalCompletionTracksActualsWithoutChangingPlannedQuantities() {
service.create(application, "OP"); first.setPdaStatus("COMPLETED"); first.setActualDiscardPackages(2);
service.sync(application); assertEquals("PICKING", document.getStatus());
assertEquals(2, snapshots.get(0).getPickedPackageCount()); assertEquals(0, snapshots.get(1).getPickedPackageCount());
second.setPdaStatus("COMPLETED"); second.setActualDiscardPackages(0);
application.setStatus("PDA_COMPLETED"); application.setTotalPackages(2); service.sync(application);
assertEquals("OUTBOUNDED", document.getStatus()); assertEquals(2, document.getPackageCount());
assertEquals(3, snapshots.get(0).getPackageCount()); assertEquals("PICKED", snapshots.get(1).getStatus());
application.setStatus("COMPLETED"); service.sync(application); assertEquals("OUTBOUNDED", document.getStatus());
}
@Test void cancellationSynchronizesDocumentAndEveryLine() {
service.create(application, "OP"); application.setStatus("CANCELLED"); service.sync(application);
assertEquals("CANCELLED", document.getStatus()); assertTrue(snapshots.stream().allMatch(s -> "CANCELLED".equals(s.getStatus())));
}
@Test void returnSourceKeepsRealCartonMarkWithoutInventingPalletNumber() {
first.setReturnCartonId(31L); first.setSourceType("RETURN_CARTON");
var carton = new ReturnInventoryCarton(); carton.setId(31L); carton.setCustomerId("C1");
carton.setOverseasWarehouseId("99"); carton.setCartonMark("BOX-31");
when(returnCartons.selectById(31L)).thenReturn(carton);
service.create(application, "OP"); assertNull(snapshots.get(0).getPalletNo()); assertEquals("BOX-31", snapshots.get(0).getCartonMarks());
assertEquals(3L, snapshots.get(0).getReturnSkuQuantity());
}
@Test void invalidSourcePreventsAnyOutboundInsert() {
when(profiles.selectById(22L)).thenReturn(null);
assertThrows(ResponseStatusException.class, () -> service.create(application, "OP"));
verify(outbounds, never()).insert(any(OutboundOrder.class)); verify(pallets, never()).insert(any(OutboundPalletItem.class));
}
}
@@ -59,6 +59,7 @@ class CargoDiscardServiceTest {
@Mock ReturnInventoryCartonItemMapper returnCartonItems;
private CargoDiscardService service;
private final CargoDiscardOutboundService outboundService = org.mockito.Mockito.mock(CargoDiscardOutboundService.class);
private final AtomicReference<CargoDiscardApplication> storedApplication = new AtomicReference<>();
private final List<CargoDiscardItem> storedItems = new ArrayList<>();
private final List<CargoDiscardCarton> storedLinks = new ArrayList<>();
@@ -67,6 +68,8 @@ class CargoDiscardServiceTest {
@BeforeEach
void setUp() {
service = new CargoDiscardService();
ReflectionTestUtils.setField(service, "outboundService", outboundService);
ReflectionTestUtils.setField(service, "stockService", org.mockito.Mockito.mock(CargoDiscardStockService.class));
ReflectionTestUtils.setField(service, "photoService", org.mockito.Mockito.mock(CargoDiscardPhotoService.class));
ReflectionTestUtils.setField(service, "applications", applications);
ReflectionTestUtils.setField(service, "items", items);
@@ -96,10 +99,10 @@ class CargoDiscardServiceTest {
LastlegOrder order = order();
DropshipSkuInventory sku = skuInventory();
when(orders.selectById(101L)).thenReturn(order);
when(inventory.countAvailable("100", 101L, "SKU-1")).thenReturn(3L);
when(inventory.countAvailable("100", 101L, "SKU-1")).thenReturn(2L);
when(inventory.lockAvailable("100", 101L, "SKU-1", 2)).thenReturn(List.of(carton(1L), carton(2L)));
when(inventory.lockSkuInventoryDimension("100", 101L, 201L, "SKU-1")).thenReturn(sku);
when(inventory.countAvailableProfile("100", 101L, 201L, "SKU-1")).thenReturn(3L);
when(inventory.countAvailableProfile("100", 101L, 201L, "SKU-1")).thenReturn(2L);
when(inventory.changeCartonStatus(eq("100"), anyLong(), eq("AVAILABLE"), eq("DISCARD_PENDING"))).thenReturn(1);
var detail = service.create("100", "7", new CargoDiscardRequests.Create(
@@ -113,6 +116,30 @@ class CargoDiscardServiceTest {
verify(inventory, never()).deductSkuInventory(any(), anyLong(), anyInt());
}
@Test void selectedBoxesCreatePartialApplicationWithoutReservingUnselectedStock() {
when(orders.selectById(101L)).thenReturn(order());
when(inventory.lockSelected("100", 101L, "SKU-1", List.of(2L))).thenReturn(List.of(carton(2L)));
when(inventory.lockSkuInventoryDimension("100", 101L, 201L, "SKU-1")).thenReturn(skuInventory());
when(inventory.countAvailableProfile("100", 101L, 201L, "SKU-1")).thenReturn(3L);
when(inventory.availableOrderCartons("100", 101L)).thenReturn(List.of(carton(1L), carton(2L), carton(3L)));
when(inventory.changeCartonStatus("100", 2L, "AVAILABLE", "DISCARD_PENDING")).thenReturn(1);
var detail = service.create("100", "7", new CargoDiscardRequests.Create(
List.of(new CargoDiscardRequests.Item(101L, null, "SKU-1", 1, null, List.of(2L))), "partial-create", "damage"));
assertEquals(1, detail.summary().totalPackages()); assertEquals(new BigDecimal("5.00"), detail.summary().totalFee());
assertEquals(List.of("C-2"), detail.items().get(0).cartonMarks());
assertEquals(1, storedLinks.size()); assertEquals(2L, storedLinks.get(0).getCartonId());
org.junit.jupiter.api.Assertions.assertTrue(storedItems.get(0).getCartonScanRequired());
verify(inventory, never()).changeCartonStatus(eq("100"), eq(1L), any(), any());
verify(inventory, never()).changeCartonStatus(eq("100"), eq(3L), any(), any());
}
@Test void duplicatePhysicalBoxAcrossSkuLinesIsRejectedBeforeCreation() {
when(orders.selectById(101L)).thenReturn(order());
org.junit.jupiter.api.Assertions.assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> service.create("100", "7", new CargoDiscardRequests.Create(
List.of(new CargoDiscardRequests.Item(101L, null, "SKU-A", 1, null, List.of(2L)),
new CargoDiscardRequests.Item(101L, null, "SKU-B", 1, null, List.of(2L))), "duplicate-box", null)));
verify(applications, never()).insert(any(CargoDiscardApplication.class));
}
@Test
void candidateTotalCountsPhysicalCartonsInsteadOfSkuRows() {
CargoDiscardCandidateRow skuA = candidate("SKU-A", 20L);
@@ -167,6 +194,7 @@ class CargoDiscardServiceTest {
var detail = service.review("100", "FH-1", "9", new CargoDiscardRequests.Review("APPROVE", "ok"));
assertEquals(CargoDiscardService.APPROVED, detail.summary().status());
verify(outboundService).create(application, "9");
verify(adjustments, never()).discard(any(), any(), any(), any(), any(), any());
verify(inventory, never()).deductSkuInventory(any(), anyLong(), anyInt());
assertEquals("ok", storedHistories.get(storedHistories.size() - 1).getRemark());
@@ -0,0 +1,132 @@
package com.baiyue.lastleg.discard.service;
import com.baiyue.lastleg.discard.dto.CargoDiscardRequests;
import com.baiyue.lastleg.discard.entity.*;
import com.baiyue.lastleg.discard.mapper.*;
import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests;
import com.baiyue.lastleg.inventory.command.service.InventoryCommandService;
import com.baiyue.lastleg.inventory.entity.*;
import com.baiyue.lastleg.inventory.mapper.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class CargoDiscardStockServiceTest {
final CargoDiscardInventoryMapper stock = mock(CargoDiscardInventoryMapper.class);
final InventoryPositionMapper positions = mock(InventoryPositionMapper.class);
final InventoryBalanceMapper balances = mock(InventoryBalanceMapper.class);
final CargoDiscardCartonMapper links = mock(CargoDiscardCartonMapper.class);
final CargoDiscardItemMapper items = mock(CargoDiscardItemMapper.class);
final InventoryCommandService commands = mock(InventoryCommandService.class);
final CargoDiscardStockService service = new CargoDiscardStockService();
final InventoryPosition position = new InventoryPosition();
final InventoryBalance balance = new InventoryBalance();
final CargoDiscardApplication application = new CargoDiscardApplication();
CargoDiscardRequests.Item request(int count) { return new CargoDiscardRequests.Item(null, null, "SKU", count, 42L); }
@BeforeEach void setup() {
ReflectionTestUtils.setField(service, "stock", stock);
ReflectionTestUtils.setField(service, "positions", positions);
ReflectionTestUtils.setField(service, "balances", balances);
ReflectionTestUtils.setField(service, "commands", commands);
ReflectionTestUtils.setField(service, "items", items);
ReflectionTestUtils.setField(service, "links", links);
ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper());
position.setId(42L); position.setCustomerId("C1"); position.setBalanceId(51L);
position.setOverseasWarehouseId("99"); position.setPalletNo("P-42"); position.setAvailablePackages(100L);
balance.setId(51L); balance.setCustomerId("C1"); balance.setOverseasWarehouseId("99");
balance.setInboundCode("WT-WH-1"); balance.setInboundOrderNo("WT-WH-1"); balance.setSkuCode("SKU"); balance.setAvailablePackages(100L);
application.setId(11L); application.setDiscardNo("CD-1"); application.setCustomerId("C1"); application.setCreatedBy("OP");
when(positions.findTenantPosition("C1", 42L)).thenReturn(position);
when(stock.lockGenericPosition("C1", 42L)).thenReturn(position);
when(balances.selectById(51L)).thenReturn(balance);
}
@Test void warehouseTransferStockIsEligibleAndKeepsTrueSourcePosition() {
when(stock.genericPositions("C1", "WT")).thenReturn(List.of(position));
var candidate = service.candidates("C1", "WT").get(0);
assertEquals("INVENTORY", candidate.sourceType()); assertNull(candidate.orderId());
assertEquals(42L, candidate.inventoryPositionId()); assertEquals("WT-WH-1", candidate.inboundCode());
service.reserve("C1", "OP", application, request(100), LocalDateTime.now());
var capture = ArgumentCaptor.forClass(CargoDiscardItem.class); verify(items).insert(capture.capture());
var line = capture.getValue(); assertEquals(42L, line.getInventoryPositionId()); assertEquals(100, line.getDiscardPackages());
var move = ArgumentCaptor.forClass(InventoryCommandRequests.Move.class); verify(commands).hold(eq("C1"), eq("OP"), move.capture());
assertEquals(100, move.getValue().packages()); assertEquals("CARGO_DISCARD:11:" + line.getId(), move.getValue().sourceEventId());
service.release("C1", application, line); verify(commands).release(eq("C1"), eq("OP"), any());
service.discard("C1", "OP", application, line, 100); verify(commands).discardHeld(eq("C1"), eq("OP"), any());
}
@Test void partialForeignSkuAndDropshipBypassAreRejectedBeforeHolding() {
assertThrows(ResponseStatusException.class, () -> service.reserve("C1", "OP", application, request(99), LocalDateTime.now()));
assertThrows(ResponseStatusException.class, () -> service.validate("C2", request(100)));
assertThrows(ResponseStatusException.class, () -> service.validate("C1", new CargoDiscardRequests.Item(null, null, "OTHER", 100, 42L)));
when(stock.isDropshipPosition("C1", 42L)).thenReturn(1L);
assertThrows(ResponseStatusException.class, () -> service.validate("C1", request(100)));
verifyNoInteractions(commands, items);
}
@Test void looseStockRequiresCompleteRealMarksAndSnapshotsThem() {
position.setPalletNo(null); position.setAvailablePackages(2L); balance.setAvailablePackages(2L);
when(stock.looseCartonMarks("C1", 42L)).thenReturn(List.of("BOX-1"));
assertThrows(ResponseStatusException.class, () -> service.reserve("C1", "OP", application, request(2), LocalDateTime.now()));
verifyNoInteractions(commands, items);
when(stock.looseCartonMarks("C1", 42L)).thenReturn(List.of("BOX-1", "BOX-2"));
service.reserve("C1", "OP", application, request(2), LocalDateTime.now());
var capture = ArgumentCaptor.forClass(CargoDiscardItem.class); verify(items).insert(capture.capture());
assertEquals(List.of("BOX-1", "BOX-2"), service.cartonMarks(capture.getValue()));
}
private com.baiyue.lastleg.discard.model.CargoDiscardStockCartonRow carton(long id, String mark) {
var carton = new com.baiyue.lastleg.discard.model.CargoDiscardStockCartonRow();
carton.setSourceType("INBOUND"); carton.setId(id); carton.setCartonMark(mark); carton.setSkuCode("SKU"); return carton;
}
@Test void partialDisposalFreezesExactlySelectedBoxesAndLeavesTheOthersUntouched() {
position.setAvailablePackages(3L); balance.setAvailablePackages(3L);
when(stock.genericCartons("C1", 42L)).thenReturn(List.of(carton(1L, "BOX-1"), carton(2L, "BOX-2"), carton(3L, "BOX-3")));
when(stock.changeGenericCartonStatus("C1", 2L, "AVAILABLE", "DISCARD_PENDING")).thenReturn(1);
var request = new CargoDiscardRequests.Item(null, null, "SKU", 1, 42L, List.of(2L));
service.reserve("C1", "OP", application, request, LocalDateTime.now());
var capture = ArgumentCaptor.forClass(CargoDiscardItem.class); verify(items).insert(capture.capture());
var line = capture.getValue(); assertEquals(1, line.getDiscardPackages()); assertTrue(line.getCartonScanRequired());
assertEquals(List.of("BOX-2"), service.cartonMarks(line));
var boxLink = ArgumentCaptor.forClass(CargoDiscardCarton.class); verify(links).insert(boxLink.capture());
assertEquals(2L, boxLink.getValue().getCartonId());
verify(stock, never()).changeGenericCartonStatus(eq("C1"), eq(1L), anyString(), anyString());
verify(stock, never()).changeGenericCartonStatus(eq("C1"), eq(3L), anyString(), anyString());
when(links.listApplication("C1", 11L)).thenReturn(List.of(boxLink.getValue()));
when(stock.changeGenericCartonStatus("C1", 2L, "DISCARD_PENDING", "AVAILABLE")).thenReturn(1);
service.release("C1", application, line);
verify(stock).changeGenericCartonStatus("C1", 2L, "DISCARD_PENDING", "AVAILABLE");
}
@Test void partialBoxSelectionRejectsForeignRepeatedAndMismatchedQuantity() {
position.setAvailablePackages(3L); balance.setAvailablePackages(3L);
when(stock.genericCartons("C1", 42L)).thenReturn(List.of(carton(1L, "BOX-1"), carton(2L, "BOX-2"), carton(3L, "BOX-3")));
for (var request : List.of(new CargoDiscardRequests.Item(null, null, "SKU", 1, 42L, List.of(9L)),
new CargoDiscardRequests.Item(null, null, "SKU", 2, 42L, List.of(1L, 1L)),
new CargoDiscardRequests.Item(null, null, "SKU", 2, 42L, List.of(1L)))) {
assertThrows(ResponseStatusException.class, () -> service.validate("C1", request));
}
verifyNoInteractions(commands, items, links);
}
@Test void singlePositionTransferBoxesReleaseBackToTheirOriginalExpectedStatus() {
position.setAvailablePackages(2L); balance.setAvailablePackages(2L);
var first = carton(1L, "WT-1#2-1"); first.setSourceType("TRANSFER");
var second = carton(2L, "WT-1#2-2"); second.setSourceType("TRANSFER");
when(stock.genericCartons("C1", 42L)).thenReturn(List.of(first, second));
when(stock.changeTransferCartonStatus("C1", 2L, "EXPECTED", "DISCARD_PENDING")).thenReturn(1);
service.reserve("C1", "OP", application, new CargoDiscardRequests.Item(null, null, "SKU", 1, 42L, List.of(2L)), LocalDateTime.now());
var capture = ArgumentCaptor.forClass(CargoDiscardCarton.class); verify(links).insert(capture.capture());
assertEquals("TRANSFER", capture.getValue().getSourceType());
when(links.listApplication("C1", 11L)).thenReturn(List.of(capture.getValue()));
when(stock.changeTransferCartonStatus("C1", 2L, "DISCARD_PENDING", "EXPECTED")).thenReturn(1);
var line = ArgumentCaptor.forClass(CargoDiscardItem.class); verify(items).insert(line.capture());
service.release("C1", application, line.getValue());
verify(stock).changeTransferCartonStatus("C1", 2L, "DISCARD_PENDING", "EXPECTED");
verify(stock, never()).changeGenericCartonStatus(anyString(), anyLong(), anyString(), anyString());
}
}
@@ -594,6 +594,35 @@ class InventoryCommandServiceTest {
"return-1", List.of("FILE-1"));
}
@Test void discardOnlyConsumesOwnedHoldAndKeepsAvailableStockConserved() {
var balance = balance(200L, 7); balance.setHoldPackages(3L);
var position = position(201L, 200L, 7); position.setHoldPackages(3L);
when(commands.lockPosition("100", 201L)).thenReturn(position);
when(commands.lockBalance("100", 200L)).thenReturn(balance);
var hold = new InventoryLedger(); hold.setPositionId(201L); hold.setBusinessOrderNo("CD-1"); hold.setDeltaPackages(3L);
when(ledgers.orderHoldCycle("100", 101L, "CARGO_DISCARD:1:2")).thenReturn(List.of(hold));
when(commands.palletPackages(300L)).thenReturn(7L);
service.discardHeld("100", "OP", new InventoryCommandRequests.Move(201L, 3, "CD-1", "OB-1", "CARGO_DISCARD:1:2", "DISCARD-1", null));
assertEquals(7L, position.getAvailablePackages()); assertEquals(7L, balance.getAvailablePackages());
assertEquals(0L, position.getHoldPackages()); assertEquals(0L, balance.getHoldPackages());
var capture = ArgumentCaptor.forClass(InventoryLedger.class); verify(ledgers).insert(capture.capture());
assertEquals("DISCARD", capture.getValue().getBusinessType()); assertEquals(-3L, capture.getValue().getDeltaPackages());
assertEquals("OB-1", capture.getValue().getOutboundOrderNo());
verify(warehouseGateway, never()).scrapPallet(any(), org.mockito.ArgumentMatchers.anyLong());
}
@Test void foreignDiscardHoldCannotConsumeAnotherApplicationsStock() {
var balance = balance(200L, 0); balance.setHoldPackages(3L);
var position = position(201L, 200L, 0); position.setHoldPackages(3L);
when(commands.lockPosition("100", 201L)).thenReturn(position);
when(commands.lockBalance("100", 200L)).thenReturn(balance);
var hold = new InventoryLedger(); hold.setPositionId(201L); hold.setBusinessOrderNo("OTHER-CD"); hold.setDeltaPackages(3L);
when(ledgers.orderHoldCycle("100", 101L, "CARGO_DISCARD:1:2")).thenReturn(List.of(hold));
assertThrows(ResponseStatusException.class, () -> service.discardHeld("100", "OP", new InventoryCommandRequests.Move(201L, 3, "CD-1", null, "CARGO_DISCARD:1:2", "DISCARD-1", null)));
assertEquals(3L, position.getHoldPackages());
verify(positions, never()).updateById(any(InventoryPosition.class)); verify(ledgers, never()).insert(any(InventoryLedger.class));
}
private InventoryBalance balance(Long id, long available) {
InventoryBalance value = new InventoryBalance();
value.setId(id); value.setCustomerId("100"); value.setCustomerName("Customer 100");
@@ -53,6 +53,7 @@ class PdaRelabelServiceTest {
@Mock RelabelApplicationService applicationService;
@Mock RelabelPhotoFileGateway photoFiles;
@Mock com.baiyue.lastleg.relabel.service.ReturnSkuConversionService skuConversions;
@Mock com.baiyue.lastleg.relabel.service.RelabelPalletMeasurementService palletMeasurements;
PdaRelabelService service;
RelabelApplication application;
RelabelItem item;
@@ -62,6 +63,7 @@ class PdaRelabelServiceTest {
void setUp() {
service = new PdaRelabelService();
ReflectionTestUtils.setField(service, "skuConversions", skuConversions);
ReflectionTestUtils.setField(service, "palletMeasurements", palletMeasurements);
ReflectionTestUtils.setField(service, "applications", applications);
ReflectionTestUtils.setField(service, "bindingGuard", org.mockito.Mockito.mock(com.baiyue.lastleg.relabel.service.RelabelBindingGuard.class));
ReflectionTestUtils.setField(service, "items", items);
@@ -452,12 +454,44 @@ class PdaRelabelServiceTest {
new PdaRelabelRequests.Complete(List.of(501L), 1, "complete-1", "done"));
verify(photoFiles).validate("100", 20L, 501L);
verify(palletMeasurements).requireMeasured(application);
verify(skuConversions).apply(eq(application), org.mockito.ArgumentMatchers.anyList(), org.mockito.ArgumentMatchers.anyList(), anyString(), org.mockito.ArgumentMatchers.anyList());
assertEquals(PdaRelabelService.WAIT_REVIEW, result.taskStatus());
assertEquals("CUSTOMER_CONFIRM_PENDING", result.applicationStatus());
verify(applicationService).changeStatus(eq("100"), eq("FBR-1"), eq("7"), any());
}
@Test
void missingOriginalRemainderMeasurementBlocksNormalAndManualCompletionBeforeWrites() {
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 measurements = org.mockito.Mockito.spy(new com.baiyue.lastleg.relabel.service.RelabelPalletMeasurementService());
var measuredNew = new com.baiyue.lastleg.relabel.service.RelabelPalletMeasurementService.Measurement(
200L, "NEW", 1, "2:1:1", java.math.BigDecimal.TEN, java.math.BigDecimal.TEN,
java.math.BigDecimal.TEN, java.math.BigDecimal.ONE, new java.math.BigDecimal("0.001"), false);
var unmeasuredRemainder = new com.baiyue.lastleg.relabel.service.RelabelPalletMeasurementService.Measurement(
100L, "OLD", 2, "1:3:2", null, null, null, null, null, false);
org.mockito.Mockito.doReturn(List.of(measuredNew, unmeasuredRemainder)).when(measurements).snapshots(application);
ReflectionTestUtils.setField(service, "palletMeasurements", measurements);
for (boolean manual : List.of(false, true)) {
var error = assertThrows(ResponseStatusException.class, () -> service.complete("7", "picker", 900L, "FBR-1",
new PdaRelabelRequests.Complete(List.of(501L), 1, "complete-" + manual, "done", manual)));
assertTrue(error.getReason().contains("托盘 OLD"));
assertEquals("PROCESSING", task.getStatus());
assertEquals("PROCESSING", application.getStatus());
}
verify(tasks, never()).updateWithVersion(any(), anyInt());
verify(unitFacts, never()).insert(any(PdaRelabelUnitFact.class));
org.mockito.Mockito.verifyNoInteractions(applicationService, photoFiles, skuConversions);
verify(histories, never()).insert(any(com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory.class));
verify(operations, never()).insert(any(PdaRelabelOperation.class));
}
@Test
void approvalUsesExistingApplicationCompletionAndReworkDoesNot() {
assertThrows(ResponseStatusException.class, () -> service.review("8", "reviewer", 900L, "FBR-1",