diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java index bbc868a6f..e80392756 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java @@ -397,8 +397,6 @@ public class RelabelApplicationService implements WalletResultHandler { if (!carton.getCartonMark().equals(item.cartonMark().trim())) throw conflict("来源退货箱号与选择的库存不一致"); com.baiyue.lastleg.returnreceipt.service.ReturnCartonCountGuard.requireConfirmed(carton); - if ("PARCEL".equals(carton.getSourceUnitType()) && !"SKU_LABEL".equals(item.operationType())) - throw conflict("散件退货库存只能贴 SKU,请先合箱后申请箱唛或混贴"); final long sourceReturnCartonId = carton.getId(); Map availableSku = returnCartonItems.lockCarton(sourceReturnCartonId).stream() .collect(Collectors.toMap(line -> line.getSkuCode().toUpperCase(java.util.Locale.ROOT), @@ -409,6 +407,8 @@ public class RelabelApplicationService implements WalletResultHandler { if (quantity > availableSku.getOrDefault(skuItem.skuCode().toUpperCase(java.util.Locale.ROOT), 0L)) throw conflict("SKU " + skuItem.skuCode() + " 的换标数量超过来源箱可用数量"); } + if ("CARTON_AND_SKU".equals(operationType(item)) && !requestedSku.equals(availableSku)) + throw conflict("包裹/箱混贴必须包含实物全部 SKU 和件数;部分数量请先隔离为独立新箱"); if (returnCartonItems.lockCarton(carton.getId()).stream().anyMatch(line -> value(line.getPackingReservedQuantity()) > 0)) throw conflict("退货箱SKU已被合箱计划锁定"); if (Set.of("CARTON_LABEL", "CARTON_AND_SKU").contains(item.operationType()) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuard.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuard.java index e3de83b25..027baff4a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuard.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuard.java @@ -48,8 +48,18 @@ public class RelabelBindingGuard { if (item.getReturnCartonId() != null && !Set.of("RETURN_BULK_SKU_LABEL", "SKU_LABEL").contains(item.getOperationType())) { var carton = returnCartons.selectById(item.getReturnCartonId()); if (carton == null || !app.getCustomerId().equals(carton.getCustomerId())) throw conflict("退货箱来源缺失"); + if ("CARTON_AND_SKU".equals(item.getOperationType())) { + var expected = new HashMap(); + try { + for (var sku : objectMapper.readTree(item.getSkuRelabelItemsJson())) + expected.merge(sku.path("skuCode").asText().toUpperCase(Locale.ROOT), sku.path("quantity").asLong(), Long::sum); + } catch (Exception failure) { throw conflict("混贴 SKU 明细无法读取"); } + var actual = new HashMap(); + returnLines.lockCarton(item.getReturnCartonId()).forEach(line -> actual.merge(line.getSkuCode().toUpperCase(Locale.ROOT), line.getQuantity(), Long::sum)); + if (!expected.equals(actual)) throw conflict("包裹/箱混贴必须包含实物全部 SKU 和件数;部分数量请先隔离为独立新箱"); + } if (!"TRUCK".equals(app.getDeliveryMethod())) continue; - if (!bindings.getOrDefault(item.getCartonMark(), Set.of()).contains(carton.getPalletCode())) + if (carton.getPalletCode() == null || !bindings.getOrDefault(item.getCartonMark(), Set.of()).contains(carton.getPalletCode())) throw conflict("退货箱尚未完成排车托盘绑定"); continue; } @@ -68,14 +78,14 @@ public class RelabelBindingGuard { if (!selected.equals(actual)) throw conflict("部分换标货物必须先合箱生成独立新箱,再提交完工"); if (!"TRUCK".equals(app.getDeliveryMethod())) continue; var carton = item.getReturnCartonId() == null ? null : returnCartons.selectById(item.getReturnCartonId()); - if (carton == null || !bindings.getOrDefault(item.getCartonMark(), Set.of()).contains(carton.getPalletCode()) + if (carton == null || carton.getPalletCode() == null || !bindings.getOrDefault(item.getCartonMark(), Set.of()).contains(carton.getPalletCode()) || !"PROCESSING".equals(carton.getRelabelStatus())) throw conflict("退货箱 " + item.getCartonMark() + " 尚未绑定带目的仓的新托盘,不能完成换标"); continue; } var position = item.getSourceInventoryPositionId() == null ? null : positions.findTenantPosition(app.getCustomerId(), item.getSourceInventoryPositionId()); - if (position == null || !bindings.getOrDefault(item.getCartonMark(), Set.of()).contains(position.getPalletNo()) + if (position == null || position.getPalletNo() == null || !bindings.getOrDefault(item.getCartonMark(), Set.of()).contains(position.getPalletNo()) || position.getHoldPackages() == null || position.getHoldPackages() < item.getNotifiedPackageCount()) throw conflict("箱唛 " + item.getCartonMark() + " 尚未完成托盘绑定,请先在 PDA 确认组托,不能完成或排车出库"); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java index 94d7adf43..5b45ee4b0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java @@ -502,7 +502,8 @@ public class ReturnInventoryService { carton.setSourceReturnNo(order.getReturnNo()); carton.setCartonMark(cartonMark); carton.setPalletCode(text(receipt.getPalletCode())); carton.setSourceUnitType(receipt.getUnitType()); - carton.setLabelStatus("PALLET".equals(receipt.getUnitType()) ? null : "WAIT_LABEL"); + // PDA signing includes applying the warehouse identification label. + carton.setLabelStatus("PALLET".equals(receipt.getUnitType()) ? null : "CONFIRMED"); carton.setCountStatus("PALLET".equals(receipt.getUnitType()) ? "PENDING" : "CONFIRMED"); carton.setSourceUnitCode(normalizedUnitCode); // Pallet measurements describe the carrier, never each child carton. diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index e8d1e12a8..578855e18 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -3122,7 +3122,7 @@ components: availablePackages: { type: integer, format: int64 } version: { type: integer, format: int64 } positions: { type: array, items: { $ref: '#/components/schemas/RelabelInventoryPosition' } } - sourceType: { type: string, enum: [INBOUND, RETURN_CARTON, RETURN_PARCEL] } + sourceType: { type: string, enum: [INBOUND, RETURN_CARTON, RETURN_PARCEL], description: RETURN_PARCEL与RETURN_CARTON均支持整实体箱唛、SKU或混贴,实物单位计1;SKU按件,部分数量先隔离为独立新箱 } returnCartonId: { type: string, nullable: true, description: 退货合箱库存主键;sourceType=RETURN_CARTON 时提供 } cartonMark: { type: string, nullable: true, description: 仓库合箱后打印并贴箱的仓库唛头 } skuItems: diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java index 74de56c1d..4d441a482 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java @@ -250,12 +250,23 @@ class PdaRelabelServiceTest { } @org.junit.jupiter.params.ParameterizedTest - @org.junit.jupiter.params.provider.ValueSource(strings = {"SKU_LABEL", "CARTON_AND_SKU"}) + @org.junit.jupiter.params.provider.ValueSource(strings = {"SKU_LABEL", "CARTON_AND_SKU", "RETURN_SKU_LABEL", "RETURN_CARTON_AND_SKU"}) void oldSkuOnlyScanPersistsEachRowAndCompletesBoxOnlyWhenRequiredStagesFinish(String type) { + boolean returnParcel = type.startsWith("RETURN_"); + type = type.replace("RETURN_", ""); application.setStatus("PROCESSING"); item.setOperationType(type); item.setCartonMark("BOX-1"); item.setFbaCartonMark("FBA-1"); item.setNotifiedPackageCount(1); item.setSkuRelabelItemsJson("[{\"skuCode\":\"OLD-A\",\"newSkuCode\":\"NEW-A\",\"quantity\":3},{\"skuCode\":\"OLD-B\",\"newSkuCode\":\"NEW-B\",\"quantity\":2}]"); + if (returnParcel) { + item.setReturnCartonId(20L); + var cartons = org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper.class); + ReflectionTestUtils.setField(service, "returnCartons", cartons); + var parcel = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton(); + parcel.setId(20L); parcel.setCustomerId("100"); parcel.setSourceUnitType("PARCEL"); + parcel.setCountStatus("CONFIRMED"); parcel.setTotalQuantity(5L); + when(cartons.selectById(20L)).thenReturn(parcel); + } var task = task("PROCESSING", 0); var saved = new ArrayList(); when(histories.selectList(any())).thenAnswer(call -> List.copyOf(saved)); @@ -264,12 +275,12 @@ class PdaRelabelServiceTest { lenient().when(applications.findAnyByRelabelNo("FBR-1")).thenReturn(application); when(tasks.lock("100", 1L)).thenReturn(task); when(items.selectOne(any())).thenReturn(item); - var first = service.scanSku("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.SkuScan(10L, 0, "OLD-A", 0, "sku-a")); + var first = service.scanSku("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.SkuScan(10L, 0, "OLD-A", 0, "sku-a", "BOX-1", "NEW-A", 3)); assertEquals(0, first.relabelledPackages()); assertEquals(1, first.version()); assertTrue(saved.stream().anyMatch(entry -> "SKU_SCAN".equals(entry.getAction()) && entry.getDetailJson().contains("NEW-A"))); assertThrows(ResponseStatusException.class, () -> service.scanSku("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.SkuScan(10L, 0, "OLD-A", 1, "duplicate"))); assertThrows(ResponseStatusException.class, () -> service.scanSku("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.SkuScan(10L, 1, "WRONG", 1, "wrong"))); - var second = service.scanSku("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.SkuScan(10L, 1, "OLD-B", 1, "sku-b")); + var second = service.scanSku("7", "picker", 900L, "FBR-1", new PdaRelabelRequests.SkuScan(10L, 1, "OLD-B", 1, "sku-b", "BOX-1", "NEW-B", 2)); assertEquals(1, second.items().get(0).skuProcessedPackages()); if ("CARTON_AND_SKU".equals(type)) { assertEquals(0, second.relabelledPackages()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java index dbc85a796..d6f67a0ec 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java @@ -961,6 +961,52 @@ class RelabelApplicationServiceTest { verify(applications, never()).insert(any(RelabelApplication.class)); } + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings = {"CARTON_LABEL", "SKU_LABEL", "CARTON_AND_SKU"}) + void intactParcelSupportsAllLabelOperationsWithoutMergingIntoBox(String type) { + var cartons = org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper.class); + var lines = org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper.class); + ReflectionTestUtils.setField(service, "returnCartons", cartons); ReflectionTestUtils.setField(service, "returnCartonItems", lines); + var parcel = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton(); + parcel.setId(901L); parcel.setCustomerId("10001"); parcel.setOverseasWarehouseId("WH-1"); + parcel.setCartonMark("PARCEL-1"); parcel.setSourceUnitType("PARCEL"); parcel.setStatus("AVAILABLE"); + parcel.setCountStatus("CONFIRMED"); parcel.setTotalQuantity(5L); + var line = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem(); line.setSkuCode("SKU-A"); line.setQuantity(5L); + when(cartons.lockId(901L)).thenReturn(parcel); when(lines.lockCarton(901L)).thenReturn(List.of(line)); + when(cartons.markRelabelPending(anyLong(), anyLong(), anyString(), any())).thenReturn(1); + var app = application(11L, "RL-PARCEL", "10001", "parcel-key", null, "PENDING_REVIEW"); app.setLastlegOrderId(21L); + when(applications.findNo(eq("10001"), any())).thenReturn(app); + when(orders.selectById(21L)).thenReturn(order(21L, "10001", "RL-PARCEL-ORDER", "PENDING_REVIEW")); + var detail = new RelabelRequests.Item(null, null, null, "SKU-A", null, null, null, + "Return goods", 1, 1, "RT-1", null, "NEW-A", null, null, null, null, null, + "PARCEL-1", "FBA-PARCEL", "XLX1", type, + "CARTON_LABEL".equals(type) ? List.of() : List.of(new RelabelRequests.SkuItem("SKU-A", "NEW-A", 5, null)), 901L); + service.create("10001", "operator", new RelabelRequests.Create("RT-1", "WH-1", LocalDateTime.now().plusDays(1), + "Contact", "19900000000", null, null, "parcel-key", List.of(detail), "SKU_LABEL".equals(type) ? null : "EXPRESS", false)); + var stored = ArgumentCaptor.forClass(RelabelItem.class); verify(items).insert(stored.capture()); + assertEquals(type, stored.getValue().getOperationType()); assertEquals(901L, stored.getValue().getReturnCartonId()); + assertEquals(1, stored.getValue().getNotifiedPackageCount()); assertEquals("PARCEL", parcel.getSourceUnitType()); + verify(inventoryCommands, never()).hold(anyString(), anyString(), any()); + } + + @Test void partialParcelMixedLabelsRequireIndependentBoxBeforeApplication() { + var cartons = org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper.class); + var lines = org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper.class); + ReflectionTestUtils.setField(service, "returnCartons", cartons); ReflectionTestUtils.setField(service, "returnCartonItems", lines); + var parcel = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton(); + parcel.setId(901L); parcel.setCustomerId("10001"); parcel.setOverseasWarehouseId("WH-1"); + parcel.setCartonMark("PARCEL-1"); parcel.setSourceUnitType("PARCEL"); parcel.setStatus("AVAILABLE"); parcel.setTotalQuantity(5L); + var line = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem(); line.setSkuCode("SKU-A"); line.setQuantity(5L); + when(cartons.lockId(901L)).thenReturn(parcel); when(lines.lockCarton(901L)).thenReturn(List.of(line)); + var detail = new RelabelRequests.Item(null, null, null, "SKU-A", null, null, null, + "Return goods", 1, 1, "RT-1", null, "NEW-A", null, null, null, null, null, + "PARCEL-1", "FBA", "XLX1", "CARTON_AND_SKU", List.of(new RelabelRequests.SkuItem("SKU-A", "NEW-A", 2, null)), 901L); + var error = assertThrows(ResponseStatusException.class, () -> service.create("10001", "operator", + new RelabelRequests.Create("RT-1", "WH-1", LocalDateTime.now().plusDays(1), "Contact", "19900000000", null, + null, "partial-parcel", List.of(detail), "EXPRESS", false))); + assertTrue(error.getReason().contains("独立新箱")); verify(applications, never()).insert(any(RelabelApplication.class)); + } + @Test void returnSkuRelabelRejectsQuantityBeyondSelectedSourceBox() { var cartonMapper = org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper.class); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuardTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuardTest.java index 60c35f0ff..640ed515a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuardTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuardTest.java @@ -6,6 +6,7 @@ import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory; import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper; import com.baiyue.lastleg.inventory.entity.InventoryPosition; import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; @@ -16,6 +17,26 @@ import static org.mockito.ArgumentMatchers.*; import static org.junit.jupiter.api.Assertions.*; class RelabelBindingGuardTest { + @Test void parcelMixedCompletionRechecksAllContentsWithoutRequiringExpressPallet() { + var guard = new RelabelBindingGuard(); + var items = mock(RelabelItemMapper.class); var histories = mock(PdaRelabelHistoryMapper.class); + var cartons = mock(ReturnInventoryCartonMapper.class); + var lines = mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper.class); + ReflectionTestUtils.setField(guard, "items", items); ReflectionTestUtils.setField(guard, "histories", histories); + ReflectionTestUtils.setField(guard, "returnCartons", cartons); ReflectionTestUtils.setField(guard, "returnLines", lines); + ReflectionTestUtils.setField(guard, "objectMapper", new ObjectMapper()); + var app = new RelabelApplication(); app.setId(1L); app.setCustomerId("2"); app.setDeliveryMethod("EXPRESS"); + var item = new RelabelItem(); item.setReturnCartonId(3L); item.setOperationType("CARTON_AND_SKU"); + item.setCartonMark("PARCEL-1"); item.setSkuRelabelItemsJson("[{\"skuCode\":\"A\",\"quantity\":3}]"); + var parcel = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton(); parcel.setCustomerId("2"); parcel.setSourceUnitType("PARCEL"); + when(items.selectList(any())).thenReturn(List.of(item)); when(histories.selectList(any())).thenReturn(List.of()); + when(cartons.selectById(3L)).thenReturn(parcel); + var line = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCartonItem(); line.setSkuCode("A"); line.setQuantity(3L); + when(lines.lockCarton(3L)).thenReturn(List.of(line)); + assertDoesNotThrow(() -> guard.requireBound(app)); + line.setQuantity(4L); assertThrows(ResponseStatusException.class, () -> guard.requireBound(app)); + line.setQuantity(3L); app.setDeliveryMethod("TRUCK"); assertThrows(ResponseStatusException.class, () -> guard.requireBound(app)); + } @Test void partialReturnSkuMustBeIsolatedAndLooseFullCartonOnlyRequiresPalletForTruck() { var guard = new RelabelBindingGuard(); var items = mock(RelabelItemMapper.class); var histories = mock(PdaRelabelHistoryMapper.class); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java index a9ae1aab5..982cb4b7e 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java @@ -142,7 +142,8 @@ class ReturnInventoryServiceTest { @Test void boxSigningStoresActualContentsAsImmutableReceiptSnapshot() { var receipt = new ReturnReceipt(); receipt.setId(21L); receipt.setReceiptNo("RR-BOX"); receipt.setUnitType("BOX"); receipt.setUnitCode("RC-BOX"); receipt.setOperatorId("operator-1"); - service.createReceiptCarton(completedOrder(), receipt, "RC-BOX", List.of(new ReturnInventoryService.ReceiptLine("A", "商品A", 3), new ReturnInventoryService.ReceiptLine("B", "商品B", 7))); + var source = service.createReceiptCarton(completedOrder(), receipt, "RC-BOX", List.of(new ReturnInventoryService.ReceiptLine("A", "商品A", 3), new ReturnInventoryService.ReceiptLine("B", "商品B", 7))); + assertThat(source.getLabelStatus()).isEqualTo("CONFIRMED"); var capture = ArgumentCaptor.forClass(com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryOperation.class); verify(operations).insert(capture.capture()); assertThat(capture.getValue().getOperationType()).isEqualTo("SIGN_CONTENTS"); @@ -157,7 +158,7 @@ class ReturnInventoryServiceTest { assertThat(source.getCartonMark()).startsWith("RP"); assertThat(source.getSourceUnitType()).isEqualTo("PARCEL"); verifyNoInteractions(numbers); - assertThat(source.getLabelStatus()).isEqualTo("WAIT_LABEL"); + assertThat(source.getLabelStatus()).isEqualTo("CONFIRMED"); verify(balances).adjust(anyLong(), anyString(), anyString(), eq("A"), eq("商品A"), eq(3L), eq(3L), eq(0L)); }