From c6471d06ff4c5a617c0efd5c90a32f92c609039f Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Sat, 10 Oct 2026 16:58:33 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B4=B4=E6=A0=87=E7=94=B3=E8=AF=B7=EF=BC=8C?= =?UTF-8?q?=E4=BB=93=E5=82=A8=E8=BD=AC=E8=BF=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../delivery/dto/DeliveryRequests.java | 11 ++++++- .../service/DeliveryTransferService.java | 29 +++++++++++++++---- .../openapi/overseas-business-v1.yaml | 16 ++++++++-- .../service/DeliveryTransferServiceTest.java | 29 +++++++++++++++++++ .../service/ReturnOutboundClosureTest.java | 9 +++--- .../ReturnOutboundPdaPersistenceTest.java | 24 +++++++++++++++ 6 files changed, 105 insertions(+), 13 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java index 9ec4b7071..e4943f8a5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java @@ -82,7 +82,12 @@ public final class DeliveryRequests { @Size(max=128) String skuCode, @jakarta.validation.constraints.Pattern(regexp = "BOX|PALLET") String unitType, @com.fasterxml.jackson.databind.annotation.JsonDeserialize(using = WholePieceQuantityDeserializer.class) - @jakarta.validation.constraints.Positive Long quantity) { + @jakarta.validation.constraints.Positive Long quantity, + @Size(min=1, max=200) List<@NotNull @Valid ReturnSkuOutboundItem> items) { + public ReturnOutbound(String recipient, String overseasWarehouseId, String returnNo, + List cartonMarks, String remark, String idempotencyKey, String skuCode, String unitType, Long quantity) { + this(recipient, overseasWarehouseId, returnNo, cartonMarks, remark, idempotencyKey, skuCode, unitType, quantity, null); + } public ReturnOutbound(String recipient, String overseasWarehouseId, String returnNo, List cartonMarks, String remark, String idempotencyKey, String skuCode, String unitType) { this(recipient, overseasWarehouseId, returnNo, cartonMarks, remark, idempotencyKey, skuCode, unitType, null); @@ -96,6 +101,10 @@ public final class DeliveryRequests { this(recipient, overseasWarehouseId, returnNo, cartonMarks, remark, idempotencyKey, skuCode, null); } } + public record ReturnSkuOutboundItem(@NotBlank @Size(max=128) String cartonMark, + @NotBlank @Size(max=128) String skuCode, + @NotNull @jakarta.validation.constraints.Positive + @com.fasterxml.jackson.databind.annotation.JsonDeserialize(using = WholePieceQuantityDeserializer.class) Long quantity) { } public record WarehouseTransferOutbound( @NotBlank @Size(max=64) String transferNo, @NotNull @Size(min=1, max=100) List<@NotNull Long> palletIds, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java index aac663016..c037053ad 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java @@ -499,10 +499,23 @@ public class DeliveryTransferService { OutboundOrder existing = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim()); if (existing != null) return replayReturnOutbound(customerId, existing, requestHash); String warehouseId = request.overseasWarehouseId().trim(); + boolean itemMode = request.items() != null; + boolean skuMode = itemMode || StringUtils.hasText(request.skuCode()); + Map requestedItems = new LinkedHashMap<>(); + if (itemMode) { + if (request.items().isEmpty() || StringUtils.hasText(request.skuCode()) || request.quantity() != null || "PALLET".equals(request.unitType())) + throw conflict("散件明细出库不可同时提交统一SKU、数量或整托方式"); + for (var line : request.items()) { + if (line == null || !StringUtils.hasText(line.cartonMark()) || !StringUtils.hasText(line.skuCode()) || line.quantity() == null || line.quantity() <= 0 + || requestedItems.putIfAbsent(line.cartonMark().trim(), line) != null) throw conflict("散件出库明细不能为空、数量必须大于0且来源不可重复"); + } + } if (request.quantity() != null && (!StringUtils.hasText(request.skuCode()) || request.quantity() <= 0 || "PALLET".equals(request.unitType()))) throw conflict("按件数出库必须选择单SKU,不能同时整托出库"); List marks = request.cartonMarks().stream().map(String::trim).filter(StringUtils::hasText).distinct().toList(); if (marks.size() != request.cartonMarks().size()) throw conflict("箱唛不能为空且不可重复"); + if (itemMode && (marks.size() != requestedItems.size() || !requestedItems.keySet().containsAll(marks))) + throw conflict("散件出库明细与所选来源不一致"); LocalDateTime now = LocalDateTime.now(); List locked = new ArrayList<>(); for (String mark : marks.stream().sorted().toList()) { @@ -516,23 +529,27 @@ public class DeliveryTransferService { existing = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim()); if (existing != null) return replayReturnOutbound(customerId, existing, requestHash); for (ReturnInventoryCarton carton : locked) { - if (StringUtils.hasText(request.skuCode()) && !"PARCEL".equals(carton.getSourceUnitType())) + if (skuMode && !"PARCEL".equals(carton.getSourceUnitType())) throw conflict("单SKU出库只允许散件库存;箱内库存请按箱或托盘出库"); - if ("PARCEL".equals(carton.getSourceUnitType()) && !StringUtils.hasText(request.skuCode())) + if ("PARCEL".equals(carton.getSourceUnitType()) && !skuMode) throw conflict("包裹散件只能按 SKU 出库,不能按箱号或托盘出库"); com.baiyue.lastleg.returnreceipt.service.ReturnCartonCountGuard.requireConfirmed(carton); if (!"AVAILABLE".equals(carton.getStatus())) throw conflict("退货箱已被其他作业占用:" + carton.getCartonMark()); var contents = returnCartonItems.lockCarton(carton.getId()); if (contents.isEmpty() || contents.stream().anyMatch(line -> value(line.getPackingReservedQuantity()) > 0)) throw conflict("退货箱缺少明细或已被合箱计划锁定:" + carton.getCartonMark()); - if (StringUtils.hasText(request.skuCode()) && (contents.size() != 1 - || !request.skuCode().trim().equals(contents.get(0).getSkuCode()))) + String selectedSku = itemMode ? requestedItems.get(carton.getCartonMark()).skuCode().trim() : trim(request.skuCode()); + if (skuMode && (contents.size() != 1 + || !selectedSku.equals(contents.get(0).getSkuCode()))) throw conflict("所选散件来源必须仅包含指定SKU"); + if (itemMode && requestedItems.get(carton.getCartonMark()).quantity() > value(contents.get(0).getQuantity())) + throw conflict("出库件数超过所选散件来源可用数量:" + carton.getCartonMark()); if ("PALLET".equals(request.unitType()) && !StringUtils.hasText(carton.getPalletCode())) throw conflict("整托出库必须存在托盘绑定"); } Map skuQuantities = new LinkedHashMap<>(); - if (StringUtils.hasText(request.skuCode())) { + if (itemMode) requestedItems.forEach((mark, line) -> skuQuantities.put(mark, line.quantity())); + else if (StringUtils.hasText(request.skuCode())) { long available = locked.stream().mapToLong(c -> value(c.getTotalQuantity())).sum(); long remaining = request.quantity() == null ? available : request.quantity(); if (remaining > available) throw conflict("出库件数超过所选来源库存数量"); @@ -564,7 +581,7 @@ public class DeliveryTransferService { item.setOutboundOrderId(outbound.getId()); item.setPackageCount(1); item.setPickedPackageCount(0); item.setStatus("WAIT_PICK"); item.setCartonMarks(carton.getCartonMark()); item.setInboundCode(carton.getSourceReturnNo()); // Packed stock is still picked by its physical carton mark; loose parcels use SKU scanning. - item.setSkuCode("PARCEL".equals(carton.getSourceUnitType()) ? trim(request.skuCode()) : null); + item.setSkuCode("PARCEL".equals(carton.getSourceUnitType()) ? itemMode ? requestedItems.get(carton.getCartonMark()).skuCode().trim() : trim(request.skuCode()) : null); item.setReturnSkuQuantity(skuQuantities.get(carton.getCartonMark())); item.setPalletNo("PALLET".equals(request.unitType()) ? carton.getPalletCode() : null); item.setCreatedAt(now); outboundPallets.insert(item); 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 42d611a1c..277f26400 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -824,8 +824,8 @@ paths: responses: { '200': { description: 出库单分页 } } /api/platform/v1/last-leg/outbound-orders/return: post: - summary: 创建退货出库单,支持单SKU全部或部分件数 - description: 权限 platform:overseas-warehouse:outbound:create;单SKU来源仅允许PARCEL散件可用库存,不包含箱内库存;实体箱按箱或托盘出库。按散件来源排序分配件数,未拣货前占用整个使用来源;拣货后来源余数恢复可用。相同幂等键改数量拒绝。 + summary: 创建退货出库单,支持多个散件SKU分别填写出库数量 + description: 权限 platform:overseas-warehouse:outbound:create;散件SKU来源仅允许PARCEL可用库存,不包含箱内库存;实体箱按箱或托盘出库。items逐来源指定SKU及数量,与cartonMarks一一对应,不可同时提交顶层skuCode或quantity,不支持PALLET方式。多个SKU生成同一张出库单和派送单,PDA逐SKU按件数拣货。旧版skuCode与quantity继续支持单SKU来源排序分配。未拣货前占用整个使用来源,拣货后来源余数恢复可用。相同幂等键改数量拒绝。 security: [{ bearerAuth: [] }] parameters: [{ name: customerId, in: query, required: true, schema: { type: string } }] requestBody: @@ -842,6 +842,18 @@ paths: cartonMarks: { type: array, minItems: 1, maxItems: 200, uniqueItems: true, items: { type: string, maxLength: 128 } } skuCode: { type: string, maxLength: 128, nullable: true } quantity: { type: integer, format: int64, minimum: 1, nullable: true, description: 单SKU计划件数,不得超过所选来源总数;省略出全部,只允许skuCode已填且非PALLET维度,不接受小数 } + items: + type: array + nullable: true + minItems: 1 + maxItems: 200 + items: + type: object + required: [cartonMark, skuCode, quantity] + properties: + cartonMark: { type: string, maxLength: 128, description: 散件库存来源标识,非实体箱号 } + skuCode: { type: string, maxLength: 128 } + quantity: { type: integer, format: int64, minimum: 1, description: 本来源本次出库件数,不接受小数,不得超过可用数量 } unitType: { type: string, enum: [BOX, PALLET], nullable: true } remark: { type: string, maxLength: 1000, nullable: true } idempotencyKey: { type: string, maxLength: 255 } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java index c4b186925..8c05d0096 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java @@ -265,6 +265,35 @@ class DeliveryTransferServiceTest { 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); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundClosureTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundClosureTest.java index 992de081d..a227a7df6 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundClosureTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundClosureTest.java @@ -71,10 +71,11 @@ class ReturnOutboundClosureTest { verify(outbounds, times(1)).insert(any(OutboundOrder.class)); } - @Test void singleSkuPackedBoxKeepsCartonScanAndPieceSnapshot() { - service.createReturnOutbound("C1", "USER", command("SKU1")); - verify(items).insert(argThat((OutboundPalletItem i) -> i.getSkuCode() == null && Long.valueOf(6L).equals(i.getReturnSkuQuantity()))); - assertThat(carton.getStatus()).isEqualTo("OUTBOUND_PENDING"); + @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() { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundPdaPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundPdaPersistenceTest.java index f7427fde9..dc82ab31a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundPdaPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundPdaPersistenceTest.java @@ -122,6 +122,30 @@ class ReturnOutboundPdaPersistenceTest { assertEquals(1, after.items().stream().mapToInt(item -> item.pickedPackageCount()).sum()); 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(