贴标申请,仓储转运
This commit is contained in:
+10
-1
@@ -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<String> 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<String> 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,
|
||||
|
||||
+23
-6
@@ -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<String, DeliveryRequests.ReturnSkuOutboundItem> 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<String> 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<ReturnInventoryCarton> 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<String, Long> 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);
|
||||
|
||||
@@ -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 }
|
||||
|
||||
+29
@@ -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);
|
||||
|
||||
+5
-4
@@ -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() {
|
||||
|
||||
+24
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user