贴标申请,仓储转运

This commit is contained in:
2026-10-12 11:18:58 +08:00
parent 29b562ebf5
commit b4b00ae3dd
39 changed files with 990 additions and 315 deletions
@@ -67,6 +67,7 @@ import static org.junit.jupiter.api.Assertions.assertNull;
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.anyString;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.mock;
@@ -209,105 +210,52 @@ class DeliveryTransferServiceTest {
var candidates = service.returnOutboundCandidates("TENANT-A", "WH-1");
assertEquals(List.of("RC-PAL"), candidates.stream().map(DeliveryResponses.ReturnOutboundCandidate::cartonMark).toList());
assertEquals(List.of("RC-PAL", "RC-PARCEL"), candidates.stream().map(DeliveryResponses.ReturnOutboundCandidate::cartonMark).toList());
}
@Test
void directPartialReturnOutboundRequiresPackingBeforeAnyInventoryLock() {
for (var request : List.of(
new DeliveryRequests.ReturnOutbound("receiver", "WH-1", "RT-1", List.of("RP-1"), null, "partial-sku", "SKU-A", "BOX", 1L),
new DeliveryRequests.ReturnOutbound("receiver", "WH-1", "RT-1", List.of("BOX-1"), null, "partial-box", null, "BOX", null,
List.of(new DeliveryRequests.ReturnSkuOutboundItem("BOX-1", "SKU-A", 1L))))) {
var error = assertThrows(ResponseStatusException.class, () -> service.createReturnOutbound("TENANT-A", "U-1", request));
assertTrue(error.getReason().contains("先合箱"));
}
verify(returnCartons, never()).lockMark(anyString());
verify(outboundOrders, never()).insert(any(OutboundOrder.class));
}
@Test
void singleSkuReturnOutboundReservesParcelAndCreatesDelivery() {
ReturnInventoryCarton parcel = returnCarton("RC-PARCEL", null);
parcel.setId(901L); parcel.setSourceUnitType("PARCEL"); parcel.setTotalQuantity(3L);
ReturnInventoryCartonItem line = new ReturnInventoryCartonItem();
line.setSkuCode("SKU-A"); line.setQuantity(3L);
when(returnCartons.lockMark("RC-PARCEL")).thenReturn(parcel);
when(returnCartonItems.lockCarton(901L)).thenReturn(List.of(line));
service.createReturnOutbound("TENANT-A", "U-1",
new DeliveryRequests.ReturnOutbound("receiver", "WH-1", "RT-1",
List.of("RC-PARCEL"), null, "sku-return-1", "SKU-A"));
ArgumentCaptor<OutboundPalletItem> item = ArgumentCaptor.forClass(OutboundPalletItem.class);
ArgumentCaptor<DeliveryOrder> delivery = ArgumentCaptor.forClass(DeliveryOrder.class);
verify(outboundPallets).insert(item.capture());
verify(deliveryOrders).insert(delivery.capture());
assertEquals("SKU-A", item.getValue().getSkuCode());
assertEquals("RC-PARCEL", item.getValue().getCartonMarks());
void wholeMultiSkuParcelCreatesBarcodePickWithoutSkuQuantity() {
var parcel = returnCarton("RP-1", null); parcel.setId(901L); parcel.setSourceUnitType("PARCEL"); parcel.setTotalQuantity(5L);
var a = new ReturnInventoryCartonItem(); a.setSkuCode("SKU-A"); a.setQuantity(3L);
var b = new ReturnInventoryCartonItem(); b.setSkuCode("SKU-B"); b.setQuantity(2L);
when(returnCartons.lockMark("RP-1")).thenReturn(parcel);
when(returnCartonItems.lockCarton(901L)).thenReturn(List.of(a,b));
service.createReturnOutbound("TENANT-A", "U-1", new DeliveryRequests.ReturnOutbound("receiver","WH-1","RT-1",List.of("RP-1"),null,"whole-package",null,"BOX"));
var capture=ArgumentCaptor.forClass(OutboundPalletItem.class); verify(outboundPallets).insert(capture.capture());
assertEquals("RP-1", capture.getValue().getCartonMarks());
assertNull(capture.getValue().getSkuCode()); assertNull(capture.getValue().getReturnSkuQuantity());
assertEquals("OUTBOUND_PENDING", parcel.getStatus());
assertEquals("WAIT_DELIVERY", delivery.getValue().getStatus());
assertEquals(1, delivery.getValue().getPackageCount());
}
@Test
void partialSkuOutboundDistributesSixPiecesAcrossTwoFivePieceSources() {
var first = returnCarton("RC-1", null); first.setId(901L); first.setTotalQuantity(5L);
var second = returnCarton("RC-2", null); second.setId(902L); second.setTotalQuantity(5L);
first.setSourceUnitType("PARCEL"); second.setSourceUnitType("PARCEL");
when(returnCartons.lockMark("RC-1")).thenReturn(first); when(returnCartons.lockMark("RC-2")).thenReturn(second);
var line = new ReturnInventoryCartonItem(); line.setSkuCode("SKU-A"); line.setQuantity(5L);
when(returnCartonItems.lockCarton(901L)).thenReturn(List.of(line));
when(returnCartonItems.lockCarton(902L)).thenReturn(List.of(line));
service.createReturnOutbound("TENANT-A", "U-1", new DeliveryRequests.ReturnOutbound("receiver", "WH-1", "RT-1",
List.of("RC-1", "RC-2"), null, "partial-six", "SKU-A", "BOX", 6L));
var capture = ArgumentCaptor.forClass(OutboundPalletItem.class);
verify(outboundPallets, org.mockito.Mockito.times(2)).insert(capture.capture());
assertEquals(List.of(5L, 1L), capture.getAllValues().stream().map(OutboundPalletItem::getReturnSkuQuantity).toList());
}
@Test
void singleSkuReturnOutboundRejectsPackedSingleSkuWithoutReservingInventory() {
ReturnInventoryCarton carton = returnCarton("RC-BOX", "PAL-1");
carton.setSourceUnitType("BOX"); carton.setTotalQuantity(5L);
when(returnCartons.lockMark("RC-BOX")).thenReturn(carton);
var error = assertThrows(ResponseStatusException.class, () -> service.createReturnOutbound("TENANT-A", "U-1",
new DeliveryRequests.ReturnOutbound("receiver", "WH-1", "RT-1", List.of("RC-BOX"), null, "packed-sku", "SKU-A")));
assertTrue(error.getReason().contains("单SKU出库只允许散件库存"));
verify(outboundOrders, never()).insert(any(OutboundOrder.class));
verify(returnCartons, never()).updateById(any(ReturnInventoryCarton.class));
assertEquals("AVAILABLE", carton.getStatus());
}
@Test
void multipleLooseSkusCreateOneOutboundWithExactPerSourceQuantities() {
var first = returnCarton("SP-A", null); first.setId(901L); first.setSourceUnitType("PARCEL"); first.setTotalQuantity(5L);
var second = returnCarton("SP-B", null); second.setId(902L); second.setSourceUnitType("PARCEL"); second.setTotalQuantity(8L);
when(returnCartons.lockMark("SP-A")).thenReturn(first); when(returnCartons.lockMark("SP-B")).thenReturn(second);
var a = new ReturnInventoryCartonItem(); a.setSkuCode("SKU-A"); a.setQuantity(5L);
var b = new ReturnInventoryCartonItem(); b.setSkuCode("SKU-B"); b.setQuantity(8L);
when(returnCartonItems.lockCarton(901L)).thenReturn(List.of(a)); when(returnCartonItems.lockCarton(902L)).thenReturn(List.of(b));
service.createReturnOutbound("TENANT-A", "U-1", new DeliveryRequests.ReturnOutbound("receiver", "WH-1", "RT-1",
List.of("SP-A", "SP-B"), null, "multi-loose", null, "BOX", null,
List.of(new DeliveryRequests.ReturnSkuOutboundItem("SP-A", "SKU-A", 2L), new DeliveryRequests.ReturnSkuOutboundItem("SP-B", "SKU-B", 3L))));
var capture = ArgumentCaptor.forClass(OutboundPalletItem.class);
verify(outboundPallets, org.mockito.Mockito.times(2)).insert(capture.capture());
assertEquals(List.of("SKU-A", "SKU-B"), capture.getAllValues().stream().map(OutboundPalletItem::getSkuCode).toList());
assertEquals(List.of(2L, 3L), capture.getAllValues().stream().map(OutboundPalletItem::getReturnSkuQuantity).toList());
verify(outboundOrders).insert(any(OutboundOrder.class)); verify(deliveryOrders).insert(any(DeliveryOrder.class));
}
@Test
void looseItemQuantityCannotExceedItsOwnSource() {
var first = returnCarton("SP-A", null); first.setId(901L); first.setSourceUnitType("PARCEL"); first.setTotalQuantity(1L);
when(returnCartons.lockMark("SP-A")).thenReturn(first);
var a = new ReturnInventoryCartonItem(); a.setSkuCode("SKU-A"); a.setQuantity(1L);
when(returnCartonItems.lockCarton(901L)).thenReturn(List.of(a));
var error = assertThrows(ResponseStatusException.class, () -> service.createReturnOutbound("TENANT-A", "U-1", new DeliveryRequests.ReturnOutbound("receiver", "WH-1", "RT-1",
List.of("SP-A"), null, "over-source", null, "BOX", null, List.of(new DeliveryRequests.ReturnSkuOutboundItem("SP-A", "SKU-A", 2L)))));
assertTrue(error.getReason().contains("超过")); verify(outboundOrders, never()).insert(any(OutboundOrder.class));
}
@Test
void singleSkuReturnOutboundRejectsMixedCarton() {
ReturnInventoryCarton mixed = returnCarton("RC-MIXED", null);
mixed.setSourceUnitType("PARCEL");
mixed.setId(901L); mixed.setTotalQuantity(5L);
ReturnInventoryCartonItem skuA = new ReturnInventoryCartonItem(); skuA.setSkuCode("SKU-A"); skuA.setQuantity(3L);
ReturnInventoryCartonItem skuB = new ReturnInventoryCartonItem(); skuB.setSkuCode("SKU-B"); skuB.setQuantity(2L);
when(returnCartons.lockMark("RC-MIXED")).thenReturn(mixed);
when(returnCartonItems.lockCarton(901L)).thenReturn(List.of(skuA, skuB));
assertEquals(409, assertThrows(ResponseStatusException.class, () -> service.createReturnOutbound("TENANT-A", "U-1",
new DeliveryRequests.ReturnOutbound("receiver", "WH-1", "RT-1",
List.of("RC-MIXED"), null, "sku-return-2", "SKU-A"))).getStatusCode().value());
verify(outboundOrders, never()).insert(any(OutboundOrder.class));
void newlyReceivedParcelCanOutboundBeforeItsLabelIsConfirmed() {
var parcel = returnCarton("RP-1", null); parcel.setLabelStatus("WAIT_LABEL");
when(returnCartons.lockMark("RP-1")).thenReturn(parcel);
var line = new ReturnInventoryCartonItem(); line.setSkuCode("SKU-A"); line.setQuantity(3L);
when(returnCartonItems.lockCarton(anyLong())).thenReturn(List.of(line));
service.createReturnOutbound("TENANT-A", "U-1",
new DeliveryRequests.ReturnOutbound("receiver", "WH-1", "RT-1", List.of("RP-1"), null, "waiting-label"));
verify(outboundOrders).insert(any(OutboundOrder.class));
assertEquals("OUTBOUND_PENDING", parcel.getStatus());
}
@Test
@@ -71,27 +71,13 @@ class ReturnOutboundClosureTest {
verify(outbounds, times(1)).insert(any(OutboundOrder.class));
}
@Test void singleSkuPackedBoxIsExcludedFromLooseOutbound() {
assertThatThrownBy(() -> service.createReturnOutbound("C1", "USER", command("SKU1")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("散件库存");
verify(items, never()).insert(any(OutboundPalletItem.class));
assertThat(carton.getStatus()).isEqualTo("AVAILABLE");
}
@Test void singleSkuCannotConsumeMixedSkuCarton() {
ReturnInventoryCartonItem other = new ReturnInventoryCartonItem(); other.setSkuCode("SKU2"); other.setQuantity(1L);
var first = contents.lockCarton(10L).get(0);
when(contents.lockCarton(10L)).thenReturn(List.of(first, other));
assertThatThrownBy(() -> service.createReturnOutbound("C1", "USER", command("SKU1")))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("SKU");
verify(outbounds, never()).insert(any(OutboundOrder.class));
}
@Test void singleSkuParcelReservesAndKeepsScanSku() {
@Test void wholeParcelUsesItsPhysicalBarcode() {
carton.setSourceUnitType("PARCEL");
service.createReturnOutbound("C1", "USER", command("SKU1"));
verify(items).insert(argThat((OutboundPalletItem i) -> "SKU1".equals(i.getSkuCode())));
assertThat(carton.getStatus()).isEqualTo("OUTBOUND_PENDING");
service.createReturnOutbound("C1", "USER", command(null));
verify(items).insert(argThat((OutboundPalletItem i) -> i.getSkuCode() == null && i.getReturnSkuQuantity() == null && "RC#1-1".equals(i.getCartonMarks())));
}
@Test void refusesPackingReservedQuantity() {
@@ -123,29 +123,6 @@ class ReturnOutboundPdaPersistenceTest {
assertEquals(1, after.items().stream().flatMap(item -> item.positions().stream()).mapToLong(row -> row.availablePackages()).sum());
}
@Test void multipleLooseSkusPersistPickAndDeductTheirOwnPieceQuantitiesWithReplayProtection() {
jdbc.update("UPDATE ll_return_inventory_carton SET source_unit_type='PARCEL' WHERE id IN (1,2)");
jdbc.update("UPDATE ll_return_inventory_carton_item SET sku_code='SKU-B' WHERE id=2");
jdbc.update("UPDATE ll_return_inventory_balance SET total_quantity=5,available_quantity=5,carton_quantity=1 WHERE id=1");
jdbc.update("INSERT INTO ll_return_inventory_balance(id,customer_id,overseas_warehouse_id,sku_code,total_quantity,available_quantity,carton_quantity) VALUES(10,'C1','WH-1','SKU-B',5,5,1)");
var task = service.createReturnOutbound("C1", "operator", new DeliveryRequests.ReturnOutbound("recipient", "WH-1", "RT-1",
List.of("BOX-A", "BOX-B"), null, "multi-sku", null, "BOX", null,
List.of(new DeliveryRequests.ReturnSkuOutboundItem("BOX-A", "SKU-A", 2L), new DeliveryRequests.ReturnSkuOutboundItem("BOX-B", "SKU-B", 3L))));
var detail = service.pdaTaskDetail("WH-1", task.taskNo());
assertEquals(2, detail.items().size());
for (var item : detail.items()) {
String sku = item.skuCode(); long quantity = "SKU-A".equals(sku) ? 2 : 3;
var request = new DeliveryRequests.PdaPick(item.outboundPalletItemId(), null, 1L, "pick-" + sku, List.of(), sku, quantity);
service.pdaPick("WH-1", "operator", task.taskNo(), request);
service.pdaPick("WH-1", "operator", task.taskNo(), request);
}
assertEquals(3L, jdbc.queryForObject("SELECT available_quantity FROM ll_return_inventory_balance WHERE id=1", Long.class));
assertEquals(2L, jdbc.queryForObject("SELECT available_quantity FROM ll_return_inventory_balance WHERE id=10", Long.class));
assertEquals(5L, jdbc.queryForObject("SELECT SUM(quantity) FROM ll_return_inventory_outbound_movement", Long.class));
assertEquals(2, jdbc.queryForObject("SELECT COUNT(*) FROM ll_outbound_pda_pick", Integer.class));
assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM ll_outbound_order", Integer.class));
assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM ll_delivery_order", Integer.class));
}
@Test void wholeReturnPalletRejectsPartialScansAndChangedContentsThenShipsAtomically() {
jdbc.update("UPDATE ll_return_inventory_carton SET pallet_code='PAL-1' WHERE id IN (1,2)");
var task = service.createReturnOutbound("C1", "operator", new DeliveryRequests.ReturnOutbound(
@@ -308,7 +308,7 @@ class PdaRelabelServiceTest {
}
@Test
void returnSkuTotalCompletesOneSkuOnceAndAllocatesAcrossSourcePalletItems() {
void returnSkuTotalRejectsAutomaticAllocationAcrossSourceCartons() {
application.setStatus("PROCESSING"); application.setNotifiedPackageCount(100);
item.setOperationType("RETURN_BULK_SKU_LABEL"); item.setSkuCode("sku-fba100");
item.setNotifiedPackageCount(50); item.setCartonMark("PALLET-A");
@@ -319,15 +319,42 @@ class PdaRelabelServiceTest {
when(tasks.lock("100", 1L)).thenReturn(task);
when(items.selectList(any())).thenReturn(List.of(item, second));
var result = service.completeReturnSkuTotal("7", "picker", 900L, "FBR-1",
new PdaRelabelRequests.BulkSkuTotal("sku-fba100", 100, 0, "sku-total-1"));
assertThrows(ResponseStatusException.class, () -> service.completeReturnSkuTotal("7", "picker", 900L, "FBR-1",
new PdaRelabelRequests.BulkSkuTotal("sku-fba100", 100, 0, "sku-total-1")));
assertTrue(persistedFacts.isEmpty());
}
assertEquals(100, result.relabelledPackages());
assertEquals(2, persistedFacts.size());
assertEquals(List.of(50, 50), persistedFacts.stream().map(PdaRelabelUnitFact::getQuantity).toList());
assertEquals(List.of(10L, 11L), persistedFacts.stream().map(PdaRelabelUnitFact::getItemId).toList());
@Test void returnSkuScanRequiresPhysicalSourceNewSkuAndApprovedQuantity() {
application.setStatus("PROCESSING"); application.setDeliveryMethod("EXPRESS");
item.setOperationType("SKU_LABEL"); item.setReturnCartonId(20L); item.setCartonMark("PARCEL-A");
item.setNotifiedPackageCount(1);
item.setSkuRelabelItemsJson("[{\"skuCode\":\"OLD\",\"newSkuCode\":\"NEW\",\"quantity\":3}]");
var cartons = org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper.class);
ReflectionTestUtils.setField(service, "returnCartons", cartons);
var carton = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton();
carton.setId(20L); carton.setCountStatus("CONFIRMED"); carton.setTotalQuantity(10L);
when(cartons.selectById(20L)).thenReturn(carton);
when(applications.lockAnyByRelabelNo("FBR-1")).thenReturn(application);
when(tasks.lock("100", 1L)).thenReturn(task("PROCESSING", 0));
when(items.selectOne(any())).thenReturn(item);
assertThrows(ResponseStatusException.class, () -> service.scanSku("7", "picker", 900L, "FBR-1",
new PdaRelabelRequests.SkuScan(10L, 0, "OLD", 0, "wrong-source", "PARCEL-B", "NEW", 3)));
assertThrows(ResponseStatusException.class, () -> service.scanSku("7", "picker", 900L, "FBR-1",
new PdaRelabelRequests.SkuScan(10L, 0, "OLD", 0, "wrong-quantity", "PARCEL-A", "NEW", 2)));
assertTrue(persistedFacts.isEmpty());
var result = service.scanSku("7", "picker", 900L, "FBR-1",
new PdaRelabelRequests.SkuScan(10L, 0, "OLD", 0, "good", "PARCEL-A", "NEW", 3));
assertEquals(1, result.relabelledPackages()); assertEquals("EXPRESS", result.deliveryMethod());
assertEquals(false, result.requiresPalletBinding()); assertEquals(10, result.items().get(0).returnQuantity());
var second = item(); second.setId(11L); second.setReturnCartonId(20L); second.setCartonMark("PARCEL-A");
second.setOperationType("SKU_LABEL"); second.setNotifiedPackageCount(1);
second.setSkuRelabelItemsJson(item.getSkuRelabelItemsJson());
when(items.selectList(any())).thenReturn(List.of(item, second));
when(items.selectOne(any())).thenReturn(second);
var again = service.scanSku("7", "picker", 900L, "FBR-1",
new PdaRelabelRequests.SkuScan(11L, 0, "OLD", 1, "same-merged-box", "PARCEL-A", "NEW", 3));
assertEquals(2, again.relabelledPackages());
assertTrue(persistedFacts.stream().allMatch(fact -> fact.getOldLabel() == null));
assertEquals(1, result.version());
}
@Test
@@ -452,7 +452,7 @@ class RelabelApplicationServiceTest {
ReturnOrder returnOrder = new ReturnOrder();
returnOrder.setId(41L); returnOrder.setCustomerId("10001");
returnOrder.setRelabelApplicationId(application.getId()); returnOrder.setRelabelStatus("PROCESSING");
when(returnOrders.selectOne(any())).thenReturn(returnOrder);
when(returnOrders.selectList(any())).thenReturn(List.of(returnOrder));
service.changeStatus("10001", "FBR-REVIEW", "operator-2",
new RelabelRequests.ChangeStatus(RelabelApplicationService.CUSTOMER_CONFIRM_PENDING, "submitted"));
@@ -16,6 +16,27 @@ import static org.mockito.ArgumentMatchers.*;
import static org.junit.jupiter.api.Assertions.*;
class RelabelBindingGuardTest {
@Test void partialReturnSkuMustBeIsolatedAndLooseFullCartonOnlyRequiresPalletForTruck() {
var guard = new RelabelBindingGuard();
var items = mock(RelabelItemMapper.class); var histories = mock(PdaRelabelHistoryMapper.class);
var lines = mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper.class);
var cartons = mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper.class);
ReflectionTestUtils.setField(guard, "items", items); ReflectionTestUtils.setField(guard, "histories", histories);
ReflectionTestUtils.setField(guard, "returnLines", lines); ReflectionTestUtils.setField(guard, "returnCartons", cartons);
ReflectionTestUtils.setField(guard, "objectMapper", new ObjectMapper());
var app = new RelabelApplication(); app.setId(1L); app.setCustomerId("2");
var item = new RelabelItem(); item.setReturnCartonId(3L); item.setCartonMark("PARCEL");
item.setOperationType("SKU_LABEL"); item.setNotifiedPackageCount(1);
item.setSkuRelabelItemsJson("[{\"skuCode\":\"A\",\"newSkuCode\":\"NEW-A\",\"quantity\":3}]");
when(items.selectList(any())).thenReturn(List.of(item)); when(histories.selectList(any())).thenReturn(List.of());
var stock = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem(); stock.setSkuCode("A"); stock.setQuantity(10L);
when(lines.lockCarton(3L)).thenReturn(List.of(stock));
assertThrows(ResponseStatusException.class, () -> guard.requireBound(app));
stock.setQuantity(3L);
assertDoesNotThrow(() -> guard.requireBound(app));
app.setDeliveryMethod("EXPRESS"); assertDoesNotThrow(() -> guard.requireBound(app));
app.setDeliveryMethod("TRUCK"); assertThrows(ResponseStatusException.class, () -> guard.requireBound(app));
}
@Test void physicalBindingMustMatchLatestTargetAndCannotSurviveRework() {
var guard = new RelabelBindingGuard();
var items = mock(RelabelItemMapper.class);
@@ -36,9 +36,9 @@ class RelabelReturnCartonCandidateTest {
ReturnInventoryCartonItem cartonItem = new ReturnInventoryCartonItem();
cartonItem.setCartonId(9001L); cartonItem.setSkuCode("SKU-RETURN");
cartonItem.setProductNameZh("退货商品"); cartonItem.setQuantity(12L);
when(cartons.findAvailable("10001", "WH-1")).thenReturn(List.of(carton));
when(cartons.findRelabelCandidates("10001", "WH-1")).thenReturn(List.of(carton));
when(cartonItems.listCarton(9001L)).thenReturn(List.of(cartonItem));
when(cartons.relabelledQuantity(9001L, "SKU-RETURN")).thenReturn(1L);
when(cartons.totalRelabelledQuantity(9001L)).thenReturn(1L);
List<RelabelResponses.InventoryCandidate> result = service.candidates("10001", "WH-1", "RC-WH");
@@ -46,8 +46,14 @@ class RelabelReturnCartonCandidateTest {
assertEquals("RETURN_CARTON", result.get(0).sourceType());
assertEquals(9001L, result.get(0).returnCartonId());
assertEquals("RC-WH-0001", result.get(0).cartonMark());
assertEquals(11, result.get(0).availablePackages());
assertEquals(12, result.get(0).availablePackages());
assertEquals("SKU-RETURN", result.get(0).skuItems().get(0).skuCode());
assertEquals(11, result.get(0).skuItems().get(0).quantity());
assertEquals(12, result.get(0).skuItems().get(0).quantity());
assertEquals(1, result.get(0).relabelledQuantity());
assertEquals("PARTIALLY_COMPLETED", result.get(0).relabelStatus());
cartonItem.setPackingReservedQuantity(4L);
result = service.candidates("10001", "WH-1", "RC-WH");
assertEquals(8, result.get(0).availablePackages());
assertEquals(1, result.get(0).relabelledQuantity(), "合箱预占不能冒充实际换标完成量");
}
}
@@ -0,0 +1,107 @@
package com.baiyue.lastleg.relabel.service;
import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity;
import com.baiyue.lastleg.relabel.entity.*;
import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper;
import com.baiyue.lastleg.returnreceipt.dto.*;
import com.baiyue.lastleg.returnreceipt.entity.*;
import com.baiyue.lastleg.returnreceipt.mapper.*;
import com.baiyue.lastleg.returnreceipt.service.ReturnInventoryService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.math.BigDecimal;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class ReturnRelabelPackingServiceTest {
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(booleans = {false, true})
void skuPackingMovesCompletionToIndependentCartonAndKeepsOriginalSourceMarks(boolean inheritedCompletion) {
var service = new ReturnRelabelPackingService();
var cartons = mock(ReturnInventoryCartonMapper.class);
var items = mock(RelabelItemMapper.class);
var inventory = mock(ReturnInventoryService.class);
ReflectionTestUtils.setField(service, "cartons", cartons);
ReflectionTestUtils.setField(service, "items", items);
ReflectionTestUtils.setField(service, "inventory", inventory);
ReflectionTestUtils.setField(service, "json", new ObjectMapper());
var app = new RelabelApplication(); app.setId(1L); app.setCustomerId("2"); app.setRelabelNo("RL1");
var first = item(10L, 20L, "A"); var second = item(11L, 21L, "B");
var a = carton(20L, "A"); var b = carton(21L, "B"); var target = carton(22L, "NEW");
var history = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper.class);
ReflectionTestUtils.setField(service, "histories", history);
if (inheritedCompletion) {
var prior = item(9L, 20L, "A"); prior.setApplicationId(99L);
prior.setSkuRelabelItemsJson("[{\"skuCode\":\"SKU\",\"quantity\":6}]");
when(items.priorCompletedReturnItems("2", 20L, 1L)).thenReturn(List.of(prior));
var facts = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelUnitFactMapper.class);
var fact = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelUnitFact(); fact.setItemId(9L); fact.setQuantity(1);
when(facts.selectList(any())).thenReturn(List.of(fact));
ReflectionTestUtils.setField(service, "facts", facts);
var physical = mock(ReturnInventoryCartonItemMapper.class);
var line = new ReturnInventoryCartonItem(); line.setSkuCode("SKU"); line.setQuantity(6L);
when(physical.lockCarton(20L)).thenReturn(List.of(line));
ReflectionTestUtils.setField(service, "cartonItems", physical);
ReflectionTestUtils.setField(service, "completionLedger", mock(ReturnRelabelCompletionLedgerMapper.class));
var tasks = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper.class);
var task = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask(); task.setId(100L);
when(tasks.find("2", 1L)).thenReturn(task); ReflectionTestUtils.setField(service, "tasks", tasks);
}
when(cartons.lockMark("A")).thenReturn(a); when(cartons.lockMark("B")).thenReturn(b);
when(cartons.lockMark("NEW")).thenReturn(target);
when(inventory.mergeBoxes(any(), any())).thenAnswer(call -> {
var request = (ReturnInventoryRequests.BoxMerge)call.getArgument(1);
assertEquals(List.of("A", "B"), request.sourceCartonMarks());
assertNull(request.targetCartonMark());
assertEquals(List.of(new ReturnInventoryRequests.SourceDeduction("A", "SKU", 3),
new ReturnInventoryRequests.SourceDeduction("B", "SKU", 3)), request.sourceDeductions());
return new ReturnInventoryResponses.MergeResult("NEW", "SKU", 6, null);
});
var result = service.pack(new PdaWarehouseIdentity("op", "picker", 42L), app, List.of(first, second),
new ReturnInventoryRequests.BoxMerge(List.of("A", "B"), null, "key", null,
BigDecimal.TEN, BigDecimal.TEN, BigDecimal.TEN, BigDecimal.ONE));
assertEquals("NEW", result.cartonMark());
assertEquals(22L, first.getReturnCartonId()); assertEquals(22L, second.getReturnCartonId());
assertEquals("A", first.getSourceCartonMark()); assertEquals("B", second.getSourceCartonMark());
assertEquals("RELABEL_PENDING", target.getStatus());
verify(cartons).clearRelabel(20L); verify(cartons).clearRelabel(21L);
verify(items).updateById(first); verify(items).updateById(second);
if (inheritedCompletion) {
var saved = org.mockito.ArgumentCaptor.forClass(com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory.class);
verify(history).insert(saved.capture());
assertEquals("RETURN_COMPLETION_TRANSFER", saved.getValue().getAction());
assertTrue(saved.getValue().getDetailJson().contains("\"sourceItemIds\":[\"9\"]"));
assertTrue(saved.getValue().getDetailJson().contains("\"quantity\":3"));
assertTrue(saved.getValue().getDetailJson().contains("\"targetCartonMark\":\"NEW\""));
} else verifyNoInteractions(history);
}
@Test void wrongSourceAndDifferentDestinationCannotEnterInventoryMutation() {
var service = new ReturnRelabelPackingService();
var inventory = mock(ReturnInventoryService.class);
ReflectionTestUtils.setField(service, "inventory", inventory);
var app = new RelabelApplication();
var a = item(10L, 20L, "A"); var b = item(11L, 21L, "B"); b.setDestinationFbaWarehouseCode("OTHER");
assertThrows(ResponseStatusException.class, () -> service.pack(new PdaWarehouseIdentity("op", "name", 42L), app,
List.of(a), new ReturnInventoryRequests.BoxMerge(List.of("OTHER"), null, "wrong")));
assertThrows(ResponseStatusException.class, () -> service.pack(new PdaWarehouseIdentity("op", "name", 42L), app,
List.of(a, b), new ReturnInventoryRequests.BoxMerge(List.of("A", "B"), null, "dest")));
verifyNoInteractions(inventory);
}
private RelabelItem item(long id, long cartonId, String mark) {
var item = new RelabelItem(); item.setId(id); item.setReturnCartonId(cartonId);
item.setCartonMark(mark); item.setSourceCartonMark(mark); item.setOperationType("SKU_LABEL");
item.setNotifiedPackageCount(1); item.setDestinationFbaWarehouseCode("FBA");
item.setSkuRelabelItemsJson("[{\"skuCode\":\"SKU\",\"newSkuCode\":\"NEW-SKU\",\"quantity\":3}]");
return item;
}
private ReturnInventoryCarton carton(long id, String mark) {
var carton = new ReturnInventoryCarton(); carton.setId(id); carton.setCartonMark(mark);
carton.setCustomerId("2"); carton.setRelabelApplicationId(1L); carton.setStatus("RELABEL_PENDING");
return carton;
}
}
@@ -127,7 +127,7 @@ class ReturnInventoryServiceTest {
}
@Test
void pendingReceiptDetailsDoNotExposeProvisionalAveragesAndParcelsHaveNoBoxes() {
void pendingReceiptDetailsDoNotExposeProvisionalAveragesForPhysicalUnits() {
var receipt = new ReturnReceipt(); receipt.setId(21L); receipt.setCustomerId("C1"); receipt.setReceiptNo("RR-1"); receipt.setUnitType("PALLET");
var box = new ReturnInventoryCarton(); box.setCartonMark("RC#2-1"); box.setCountStatus("PENDING"); box.setTotalQuantity(5L);
when(cartons.listReceipt(21L)).thenReturn(List.of(box));
@@ -135,7 +135,7 @@ class ReturnInventoryServiceTest {
assertThat(detail.get(0).countStatus()).isEqualTo("PENDING");
assertThat(detail.get(0).items()).isEmpty();
receipt.setUnitType("PARCEL");
assertThat(service.receiptContents(receipt)).isEmpty();
assertThat(service.receiptContents(receipt)).hasSize(1);
verifyNoInteractions(cartonItems);
}
@@ -150,13 +150,14 @@ class ReturnInventoryServiceTest {
}
@Test
void parcelSigningStoresLooseSkuSourceWithoutGeneratingCartonNumberOrIncreasingBoxCount() {
void parcelSigningCreatesOneLabelledPhysicalUnitWithoutIncreasingBoxCount() {
var receipt = new ReturnReceipt(); receipt.setId(21L); receipt.setUnitType("PARCEL"); receipt.setUnitCode("PK-1");
var source = service.createReceiptCarton(completedOrder(), receipt, null,
List.of(new ReturnInventoryService.ReceiptLine("A", "商品A", 3)));
assertThat(source.getCartonMark()).startsWith("SP");
assertThat(source.getCartonMark()).startsWith("RP");
assertThat(source.getSourceUnitType()).isEqualTo("PARCEL");
verifyNoInteractions(numbers, operations);
verifyNoInteractions(numbers);
assertThat(source.getLabelStatus()).isEqualTo("WAIT_LABEL");
verify(balances).adjust(anyLong(), anyString(), anyString(), eq("A"), eq("商品A"), eq(3L), eq(3L), eq(0L));
}
@@ -227,7 +228,7 @@ class ReturnInventoryServiceTest {
void cartonPageCountsPhysicalCartonsAndKeepsTheAuthenticatedCustomerScope() {
var query = new ReturnInventoryRequests.Query(2, 1, "42", "SKU", null, "AVAILABLE", "RT-1");
ReturnInventoryCarton carton = new ReturnInventoryCarton();
carton.setCartonMark("RC-SECOND");
carton.setId(31L); carton.setCartonMark("RC-SECOND");
carton.setSkuTypes(2);
carton.setTotalQuantity(5L);
when(cartons.merchantCount("10001", query)).thenReturn(2L);
@@ -369,6 +370,32 @@ class ReturnInventoryServiceTest {
verify(balances).adjustCartonQuantity("10001", "42", "SKU-A", 1L);
}
@Test void completedLabelQuantityCannotBeGuessedWhenSplittingAStoredCarton() {
prepareConcurrentSource();
when(cartonItems.lockCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 2L)));
when(cartons.relabelledQuantity(31L, "SKU-A")).thenReturn(1L);
var error = assertThrows(ResponseStatusException.class, () -> service.merge(
new PdaWarehouseIdentity("operator-2", "name", 42L), mergeRequest(1)));
assertThat(error.getReason()).contains("不能自动分配来源", "独立新箱");
verify(cartonItems, never()).updateById(any(ReturnInventoryCartonItem.class));
verify(cartons, never()).insert(any(ReturnInventoryCarton.class));
}
@Test void wholeCartonMergeCarriesItsCompletedRelabelFactsToTheTarget() {
var source = prepareConcurrentSource();
when(cartonItems.lockCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 2L)));
when(cartons.relabelledQuantity(31L, "SKU-A")).thenReturn(2L);
when(numbers.next(anyString(), anyString(), anyLong())).thenReturn("RC-TARGET");
when(balances.adjustCartonQuantity(anyString(), anyString(), anyString(), anyLong())).thenReturn(1);
var relabel = org.mockito.Mockito.mock(com.baiyue.lastleg.relabel.mapper.RelabelItemMapper.class);
ReflectionTestUtils.setField(service, "relabelItems", relabel);
service.merge(new PdaWarehouseIdentity("operator-2", "name", 42L), mergeRequest(2));
var target = ArgumentCaptor.forClass(ReturnInventoryCarton.class);
verify(cartons).insert(target.capture());
verify(relabel).moveCompletedReturnCarton("10001", 31L, target.getValue().getId(), "RC-TARGET");
assertThat(source.getTotalQuantity()).isZero();
}
@Test
void sameKeyInsertRaceReplaysCommittedResultAndLabelUsingOnlyCurrentReads() {
var request = mergeRequest(1);
@@ -468,11 +495,11 @@ class ReturnInventoryServiceTest {
}
@Test
void planCreationLocksExactSkuAndCancelReleasesOnlyItsReservation() {
void planCreationLocksPhysicalSourceAndCancelReleasesReservation() {
var source = new ReturnInventoryCarton(); source.setId(31L); source.setCartonMark("RC-SOURCE");
source.setCustomerId("10001"); source.setOverseasWarehouseId("42"); source.setSourceReturnNo("RT-1");
source.setStatus("AVAILABLE"); source.setSourceUnitType("PARCEL");
var line = item(41L, "SKU-A", 5); line.setPackingReservedQuantity(1L);
var line = item(41L, "SKU-A", 5); line.setPackingReservedQuantity(0L);
when(orders.lockNo("RT-1")).thenReturn(completedOrder());
when(cartons.packingAvailable("10001", "42")).thenReturn(List.of(source));
when(cartons.lockMark("RC-SOURCE")).thenReturn(source);
@@ -483,12 +510,14 @@ class ReturnInventoryServiceTest {
var request = new ReturnInventoryRequests.PackingPlanCreate(List.of("RT-1"), "SKU",
List.of(new ReturnInventoryRequests.PlanBox(List.of(new ReturnInventoryRequests.PlanItem("SKU-A", 2)), null, null, null, null)), null, "plan-key");
var created = service.createPackingPlan("platform", request);
assertThat(created.status()).isEqualTo("PENDING_REVIEW"); assertThat(line.getPackingReservedQuantity()).isEqualTo(3);
assertThat(created.status()).isEqualTo("PENDING_REVIEW"); assertThat(line.getPackingReservedQuantity()).isEqualTo(2);
assertThat(source.getStatus()).isEqualTo("PACKING_RESERVED");
var captor = ArgumentCaptor.forClass(com.baiyue.lastleg.returnreceipt.entity.ReturnPackingPlan.class);
verify(packingPlans).insert(captor.capture());
when(packingPlans.lockNo("PLAN-1")).thenReturn(captor.getValue());
assertThat(service.cancelPackingPlan("PLAN-1").status()).isEqualTo("CANCELLED");
assertThat(line.getPackingReservedQuantity()).isEqualTo(1);
assertThat(line.getPackingReservedQuantity()).isEqualTo(0);
assertThat(source.getStatus()).isEqualTo("AVAILABLE");
verify(balances).adjust(anyLong(), org.mockito.ArgumentMatchers.eq("10001"), org.mockito.ArgumentMatchers.eq("42"), org.mockito.ArgumentMatchers.eq("SKU-A"), org.mockito.ArgumentMatchers.isNull(), org.mockito.ArgumentMatchers.eq(0L), org.mockito.ArgumentMatchers.eq(2L), org.mockito.ArgumentMatchers.eq(0L));
}
@@ -502,6 +531,35 @@ class ReturnInventoryServiceTest {
verifyNoInteractions(cartonItems, balances);
}
@Test
void partialPhysicalBoxCannotBeMergedIntoAnExistingTarget() {
var source = new ReturnInventoryCarton(); source.setId(31L); source.setCartonMark("RC-SOURCE");
source.setCustomerId("10001"); source.setOverseasWarehouseId("42"); source.setSourceReturnNo("RT-1");
source.setStatus("AVAILABLE"); source.setSourceUnitType("BOX");
when(cartons.lockMark("RC-SOURCE")).thenReturn(source);
when(cartonItems.lockCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 5)));
var request = new ReturnInventoryRequests.BoxMerge(List.of("RC-SOURCE"), "RC-TARGET", "partial-existing",
List.of(new ReturnInventoryRequests.SourceDeduction("RC-SOURCE", "SKU-A", 1)), null, null, null, null);
var error = assertThrows(ResponseStatusException.class, () -> service.mergeBoxes(new PdaWarehouseIdentity("operator-1", "作业员", 42L), request));
assertThat(error.getReason()).contains("独立新箱");
verify(cartonItems, never()).updateById(any(ReturnInventoryCartonItem.class));
verify(cartons, never()).insert(any(ReturnInventoryCarton.class));
}
@Test
void confirmingPhysicalLabelIsIdempotentAndBoundToWarehouse() {
var source = new ReturnInventoryCarton(); source.setId(31L); source.setCartonMark("RC-SOURCE");
source.setCustomerId("10001"); source.setOverseasWarehouseId("42"); source.setSourceReturnNo("RT-1");
source.setStatus("AVAILABLE"); source.setLabelStatus("WAIT_LABEL");
when(cartons.lockMark("RC-SOURCE")).thenReturn(source);
when(cartonItems.listCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 2)));
var identity = new PdaWarehouseIdentity("operator-1", "作业员", 42L);
service.confirmLabel(identity, "RC-SOURCE"); service.confirmLabel(identity, "RC-SOURCE");
assertThat(source.getLabelStatus()).isEqualTo("CONFIRMED");
verify(cartons, org.mockito.Mockito.times(1)).updateById(source);
assertThrows(ResponseStatusException.class, () -> service.confirmLabel(new PdaWarehouseIdentity("operator-2", "其他仓", 43L), "RC-SOURCE"));
}
private ReturnInventoryCarton prepareConcurrentSource() {
ReturnReceipt receipt = new ReturnReceipt(); receipt.setId(21L);
ReturnInventoryCarton snapshot = new ReturnInventoryCarton();
@@ -227,10 +227,13 @@ class ReturnReceiptServiceTest {
when(numbers.next(eq("RELABEL"), eq("10"), anyLong())).thenReturn("RL-MULTI");
when(lastlegOrders.selectById(99L)).thenReturn(tail);
when(cartons.findAvailable("customer-1", "10")).thenReturn(List.of(first, second));
when(inventory.matchesReturnSource(any(), eq("RT-MULTI"))).thenCallRealMethod();
when(cartons.lockId(101L)).thenReturn(first);
when(cartons.lockId(102L)).thenReturn(second);
when(cartonItems.listCarton(101L)).thenReturn(List.of(returnCartonItem("SKU-A", 50)));
when(cartonItems.listCarton(102L)).thenReturn(List.of(returnCartonItem("SKU-B", 40)));
when(cartonItems.lockCarton(101L)).thenReturn(List.of(returnCartonItem("SKU-A", 50)));
when(cartonItems.lockCarton(102L)).thenReturn(List.of(returnCartonItem("SKU-B", 40)));
var result = service.createRelabel("customer-1", "merchant-1", "RT-MULTI",
new ReturnOrderRequests.RelabelCreate(List.of(
@@ -263,7 +266,7 @@ class ReturnReceiptServiceTest {
}
@Test
void secondRelabelCannotAllocateAlreadyLabelledQuantity() {
void completedReturnCartonCanStartAnotherRelabelRound() {
ReturnOrder order = order("RT-MULTI", "10", "COMPLETED", 100, 100);
order.setLastlegOrderId(99L); order.setRelabelNo("RL-PREVIOUS");
LastlegOrder tail = new LastlegOrder(); tail.setId(99L);
@@ -275,16 +278,17 @@ class ReturnReceiptServiceTest {
when(numbers.next(eq("RELABEL"), eq("10"), anyLong())).thenReturn("RL-SECOND");
when(lastlegOrders.selectById(99L)).thenReturn(tail);
when(cartons.findAvailable("customer-1", "10")).thenReturn(List.of(carton));
when(inventory.matchesReturnSource(any(), eq("RT-MULTI"))).thenCallRealMethod();
when(cartonItems.listCarton(101L)).thenReturn(List.of(returnCartonItem("SKU-A", 100)));
when(cartons.relabelledQuantity(101L, "SKU-A")).thenReturn(100L);
var error = assertThrows(ResponseStatusException.class, () -> service.createRelabel(
when(cartons.lockId(101L)).thenReturn(carton);
when(cartonItems.lockCarton(101L)).thenReturn(List.of(returnCartonItem("SKU-A", 100)));
var result = service.createRelabel(
"customer-1", "merchant-1", "RT-MULTI", new ReturnOrderRequests.RelabelCreate(
List.of(new ReturnOrderRequests.RelabelItem("SKU-A", 1)),
"SKU_TOTAL", "200", "XLX6", "second-key", "second attempt")));
assertEquals(409, error.getStatusCode().value());
assertTrue(error.getReason().contains("可用实收数量不足"));
verify(relabelItems, never()).insert(any(RelabelItem.class));
verify(cartons, never()).updateById(any(ReturnInventoryCarton.class));
"SKU_TOTAL", "200", "XLX6", "second-key", "second attempt"));
assertEquals("RL-SECOND", result.relabelNo());
verify(relabelItems).insert(any(RelabelItem.class));
verify(cartons).updateById(carton);
}
@Test
@@ -444,7 +448,7 @@ class ReturnReceiptServiceTest {
}
@Test
void parcelWithoutTrackingGeneratesParcelCodeAndNeverReturnsBoxLabelsOnReplay() {
void parcelWithoutTrackingKeepsPhysicalMarkOnReplay() {
ReturnOrder order = order("RT-1", "10", ReturnReceiptService.PENDING_RECEIPT, 3, 0);
ReturnOrderItem line = item(11L, "SKU-A", 3, 0);
stubNewReceipt(order, List.of(line), "SP-SOURCE");
@@ -455,7 +459,7 @@ class ReturnReceiptServiceTest {
var capture = ArgumentCaptor.forClass(ReturnReceipt.class);
verify(receipts).insert(capture.capture());
assertThat(result.unitCode()).startsWith("PK");
assertThat(result.cartonMark()).isNull();
assertThat(result.cartonMark()).isEqualTo("SP-SOURCE");
assertThat(result.printableHtml()).isNull();
assertThat(result.labels()).isEmpty();
assertThat(capture.getValue().getTrackingNo()).isNull();
@@ -463,7 +467,7 @@ class ReturnReceiptServiceTest {
var replay = service.confirm(identity, request);
assertThat(replay.unitCode()).isEqualTo(result.unitCode());
assertThat(replay.labels()).isEmpty();
verify(inventory, never()).label(anyString(), anyString());
verify(inventory, times(2)).label("customer-1", "SP-SOURCE");
verify(inventory, times(1)).createReceiptCarton(any(), any(), any(), any());
}