diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 97a4ce48d..d79920bab 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -1517,6 +1517,7 @@ CREATE TABLE IF NOT EXISTS ll_outbound_order ( created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', outbound_type VARCHAR(32) NOT NULL DEFAULT 'DISPATCH_DELIVERY' COMMENT '出库类型:派送转出库、一件代发', lx_codes VARCHAR(1000) NULL COMMENT '关联一件代发领星入库单代码汇总', recipient_info VARCHAR(1000) NULL COMMENT '一件代发收件信息', + request_hash CHAR(64) NULL COMMENT '一件代发创建请求摘要,其他出库类型为空', PRIMARY KEY (id), UNIQUE KEY uk_ll_outbound_no (outbound_no), UNIQUE KEY uk_ll_outbound_idempotency (customer_id,idempotency_key), UNIQUE KEY uk_ll_outbound_dispatch (customer_id,dispatch_order_id), KEY idx_ll_outbound_list (customer_id,status,created_at) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundOrder.java index aa3b792a6..0c088baaf 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundOrder.java @@ -25,6 +25,7 @@ public class OutboundOrder { private Integer packageCount; private Integer palletCount; private String idempotencyKey; + private String requestHash; private String createdBy; private LocalDateTime createdAt; private LocalDateTime updatedAt; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundOrderMapper.java index 3509fe1c2..a71e718ce 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundOrderMapper.java @@ -15,6 +15,8 @@ public interface OutboundOrderMapper extends BaseMapper { OutboundOrder findGlobalByNo(@Param("outboundNo") String outboundNo); @Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1") OutboundOrder findByIdempotency(@Param("customerId") String customerId, @Param("key") String key); + @Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} AND idempotency_key=#{key} LIMIT 1 FOR UPDATE") + OutboundOrder lockByIdempotency(@Param("customerId") String customerId, @Param("key") String key); @Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} LIMIT 1") OutboundOrder findByDispatch(@Param("customerId") String customerId, @Param("dispatchOrderId") Long dispatchOrderId); @Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} LIMIT 1 FOR UPDATE") 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 94c7f9ff1..9cb5713d1 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 @@ -73,6 +73,11 @@ import java.time.format.DateTimeFormatter; import com.baiyue.lastleg.service.LastlegOrderNoGenerator; import java.math.BigDecimal; import java.util.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.core.JsonProcessingException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; @Service public class DeliveryTransferService { @@ -103,6 +108,7 @@ public class DeliveryTransferService { @Resource private RelabelItemMapper relabelItems; @Resource private RelabelApplicationService relabelService; @Resource private LastlegOrderNoGenerator orderNoGenerator; + @Resource private ObjectMapper objectMapper; @Transactional public DeliveryResponses.TransferResult platformTransfer(String dispatchNo, String operatorId, @@ -254,17 +260,33 @@ public class DeliveryTransferService { @Transactional public DeliveryResponses.PdaTask createOneClick(String customerId, String operatorId, DeliveryRequests.OneClickOutbound request) { + String requestHash = oneClickRequestHash(request); OutboundOrder existing = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim()); - if (existing != null) return taskResult(existing, pdaTasks.lockByOutbound(customerId, existing.getId())); + if (existing != null) return replayOneClick(existing, requestHash); LocalDateTime now = LocalDateTime.now(); - String selectedWarehouseId = validateOneClickItems(customerId, request.items()); + InventoryPosition firstPosition = inventoryPositions.selectById(request.items().get(0).inventoryPositionId()); + if (firstPosition == null || !customerId.equals(firstPosition.getCustomerId())) { + throw conflict("所选库存不是可用的一件代发库存,或已被其他作业占用"); + } + String selectedWarehouseId = required(firstPosition.getOverseasWarehouseId(), "库存缺少所属海外仓"); OutboundOrder outbound = new OutboundOrder(); outbound.setId(IdWorker.getId()); outbound.setCustomerId(customerId); outbound.setOutboundNo(orderNoGenerator.next("ONE_CLICK_DROPSHIP", selectedWarehouseId, outbound.getId())); outbound.setOutboundType("ONE_CLICK_DROPSHIP"); outbound.setStatus("WAIT_PICK"); outbound.setRecipientInfo(request.recipient().trim()); outbound.setPackageCount(request.items().stream().mapToInt(i -> i.packages().intValue()).sum()); outbound.setPalletCount(request.items().size()); outbound.setIdempotencyKey(request.idempotencyKey().trim()); outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now); - outboundOrders.insert(outbound); + outbound.setRequestHash(requestHash); + // Claim the unique command key before taking inventory locks. A racing insert + // waits for the first transaction; use a current read after it commits, not + // the REPEATABLE READ snapshot established by the initial lookup. + try { + outboundOrders.insert(outbound); + } catch (DuplicateKeyException duplicate) { + OutboundOrder concurrent = outboundOrders.lockByIdempotency(customerId, request.idempotencyKey().trim()); + if (concurrent == null) throw duplicate; + return replayOneClick(concurrent, requestHash); + } + selectedWarehouseId = validateOneClickItems(customerId, request.items()); Set physicalPalletIds = new HashSet<>(); for (DeliveryRequests.OneClickItem input : request.items()) { InventoryPosition position = inventoryPositions.selectById(input.inventoryPositionId()); @@ -286,6 +308,23 @@ public class DeliveryTransferService { return taskResult(outbound, task); } + private DeliveryResponses.PdaTask replayOneClick(OutboundOrder existing, String requestHash) { + if (!"ONE_CLICK_DROPSHIP".equals(existing.getOutboundType()) + || !requestHash.equals(existing.getRequestHash())) { + throw conflict("相同幂等键不能用于不同的一件代发出库请求"); + } + return taskResult(existing, pdaTasks.lockByOutbound(existing.getCustomerId(), existing.getId())); + } + + String oneClickRequestHash(DeliveryRequests.OneClickOutbound request) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(objectMapper.writeValueAsString(request).getBytes(StandardCharsets.UTF_8))); + } catch (JsonProcessingException | NoSuchAlgorithmException error) { + throw new IllegalStateException("无法生成一件代发出库请求摘要", error); + } + } + public List oneClickCandidates(String customerId, String warehouseId) { return dropshipProfiles.listOutboundCandidates(customerId, StringUtils.hasText(warehouseId) ? warehouseId.trim() : null).stream() @@ -491,8 +530,19 @@ public class DeliveryTransferService { } DropshipOrderProfile profile = dropshipProfiles.findPosition(customerId, request.inventoryPositionId()); if (profile == null) throw conflict("Scanned pallet is not one-click dropship inventory"); - List cartons = StringUtils.hasText(item.getCartonMarks()) - ? Arrays.stream(item.getCartonMarks().split(",")).map(String::trim).filter(StringUtils::hasText) + List submittedMarks = request.cartonMarks() == null ? List.of() : request.cartonMarks(); + List selectedMarks = submittedMarks.stream().map(String::trim).filter(StringUtils::hasText).distinct().sorted().toList(); + Set frozenMarks = new HashSet<>(Arrays.asList(Optional.ofNullable(item.getCartonMarks()).orElse("").split(","))); + if (!submittedMarks.isEmpty() && (selectedMarks.size() != submittedMarks.size() + || selectedMarks.size() != request.packages() + || StringUtils.hasText(item.getCartonMarks()) && !frozenMarks.containsAll(selectedMarks))) { + throw conflict("拣货箱唛必须唯一、数量一致且属于当前出库明细"); + } + if (StringUtils.hasText(item.getCartonMarks()) && selectedMarks.isEmpty()) { + throw conflict("请提交本次实际拣货的指定箱唛"); + } + List cartons = !selectedMarks.isEmpty() + ? selectedMarks.stream() .map(mark -> dropshipCartons.findByProfileAndMark(customerId, profile.getId(), mark)).filter(Objects::nonNull) .filter(carton -> "AVAILABLE".equals(carton.getStatus()) && item.getSkuCode().equals(carton.getSkuCode())).toList() : dropshipCartons.lockAvailable(customerId, profile.getId(), item.getSkuCode(), request.packages()); @@ -644,6 +694,8 @@ public class DeliveryTransferService { OutboundPalletItem item) { if (item.getWarehousePalletId() == null) return List.of(); List result = new ArrayList<>(); + Set frozenMarks = new HashSet<>(Arrays.asList( + Optional.ofNullable(item.getCartonMarks()).orElse("").split(","))); for (InventoryPosition position : inventoryPositions.listPallet(customerId, item.getWarehousePalletId())) { long available = value(position.getAvailablePackages()); if (available <= 0 || !warehouseId.equals(position.getOverseasWarehouseId())) continue; @@ -654,7 +706,9 @@ public class DeliveryTransferService { List marks = profile == null ? List.of() : dropshipCartons.listProfile(customerId, profile.getId()) .stream().filter(carton -> "AVAILABLE".equals(carton.getStatus())) .filter(carton -> Objects.equals(item.getSkuCode(), carton.getSkuCode())) + .filter(carton -> !StringUtils.hasText(item.getCartonMarks()) || frozenMarks.contains(carton.getCartonMark())) .map(DropshipCarton::getCartonMark).filter(StringUtils::hasText).distinct().limit(available).toList(); + if (profile != null && marks.isEmpty()) continue; if (!matchesOutboundItem(customerId, warehouseId, item, balance) && marks.isEmpty()) continue; if (marks.isEmpty()) { result.add(pdaPosition(position, item, balance, null, available, value(position.getPickedPackages()))); @@ -669,7 +723,7 @@ public class DeliveryTransferService { InventoryBalance balance, String cartonMark, long available, long picked) { return new DeliveryResponses.PdaPickPosition(position.getId(), position.getPalletNo(), cartonMark, - balance.getSkuCode(), balance.getInboundCode(), available, picked); + item.getSkuCode(), balance.getInboundCode(), available, picked); } private boolean matchesOutboundItem(String customerId, String warehouseId, OutboundPalletItem item, diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index ce97e00d2..c449c2ce2 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -1424,6 +1424,7 @@ CREATE TABLE IF NOT EXISTS ll_outbound_order ( created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', outbound_type VARCHAR(32) NOT NULL DEFAULT 'DISPATCH_DELIVERY' COMMENT '出库类型:派送转出库、一件代发', lx_codes VARCHAR(1000) NULL COMMENT '关联一件代发领星入库单代码汇总', recipient_info VARCHAR(1000) NULL COMMENT '一件代发收件信息', + request_hash CHAR(64) NULL COMMENT '一件代发创建请求摘要,其他出库类型为空', PRIMARY KEY (id), UNIQUE KEY uk_ll_outbound_no (outbound_no), UNIQUE KEY uk_ll_outbound_idempotency (customer_id,idempotency_key), UNIQUE KEY uk_ll_outbound_dispatch (customer_id,dispatch_order_id), KEY idx_ll_outbound_list (customer_id,status,created_at) 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 f1f99cc00..5ed12e06f 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -2266,6 +2266,7 @@ components: remark: { type: string, nullable: true, maxLength: 500 } OneClickOutboundCreate: type: object + description: 同一幂等键只重放相同请求;收件人、明细、箱唛或备注变化返回 A0409。 required: [recipient, items, idempotencyKey] properties: recipient: { type: string, maxLength: 1000 } @@ -2282,6 +2283,11 @@ components: inventoryPositionId: { type: string, pattern: '^[1-9][0-9]*$' } packages: { type: integer, format: int64, minimum: 1 } sku: { type: string, maxLength: 128 } + cartonMarks: + type: array + maxItems: 200 + description: 指定出库箱唛,提供时必须唯一且与箱数一致。 + items: { type: string, minLength: 1, maxLength: 128 } OneClickOutboundCandidate: type: object required: [inventoryPositionId, overseasWarehouseId, palletNo, skuCode, availablePackages] diff --git a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml index 37c10f2ab..cee57c3f1 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml @@ -587,6 +587,11 @@ components: inventoryPositionId: { type: string, pattern: '^[0-9]+$' } packages: { type: integer, format: int64, minimum: 1 } idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + cartonMarks: + type: array + maxItems: 500 + description: 本次实际扫描的箱唛。一件代发指定箱唛任务分批拣货时必须提交本次子集,数量与 packages 一致;换标任务仍须提交全部指定箱唛。 + items: { type: string, minLength: 1, maxLength: 255 } PdaOutboundTaskDetail: type: object required: [task, items] diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySchemaContractTest.java index 05895baa9..80f810b7e 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySchemaContractTest.java @@ -25,6 +25,7 @@ class DeliverySchemaContractTest { assertTrue(schema.contains("create table if not exists ll_outbound_order")); assertTrue(schema.contains("status varchar(32) not null default 'wait_outbound'")); assertTrue(schema.contains("unique key uk_ll_outbound_idempotency (customer_id,idempotency_key)")); + assertTrue(schema.contains("request_hash char(64) null")); assertTrue(schema.contains("unique key uk_ll_outbound_dispatch (customer_id,dispatch_order_id)")); assertTrue(schema.contains("create table if not exists ll_outbound_pallet_item")); assertTrue(schema.contains("unique key uk_ll_outbound_pallet (customer_id,outbound_order_id,warehouse_pallet_id,inbound_code,sku_code)")); @@ -66,6 +67,7 @@ class DeliverySchemaContractTest { StandardCharsets.UTF_8).toLowerCase(); assertTrue(overseas.contains("begin inline empty-database baseline: lastleg")); assertTrue(overseas.contains(createToken)); + assertTrue(overseas.contains("request_hash char(64) null")); assertTrue(overseas.indexOf(createToken) < overseas.indexOf("assert_phase5_lastleg_identifiers_migratable")); assertTrue(overseas.contains("alter table ll_delivery_order add column overseas_warehouse_name")); } 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 53dc27116..c20e27749 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 @@ -91,6 +91,7 @@ class DeliveryTransferServiceTest { @BeforeEach void setUp() { service = new DeliveryTransferService(); + ReflectionTestUtils.setField(service, "objectMapper", new com.fasterxml.jackson.databind.ObjectMapper()); ReflectionTestUtils.setField(service, "dispatchOrders", dispatchOrders); ReflectionTestUtils.setField(service, "dispatchItems", dispatchItems); ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders); @@ -440,6 +441,7 @@ class DeliveryTransferServiceTest { .thenReturn(List.of(new DropshipCarton())); InventoryPosition position = new InventoryPosition(); position.setId(900L + i); position.setCustomerId("TENANT-A"); position.setBalanceId(800L + i); + position.setOverseasWarehouseId("WH-1"); position.setPalletId(501L); position.setPalletNo("DP-SHARED"); InventoryBalance balance = new InventoryBalance(); balance.setCustomerId("TENANT-A"); balance.setInboundCode("IN-" + i); @@ -460,6 +462,10 @@ class DeliveryTransferServiceTest { @Test void oneClickOutboundRejectsSummarySkuWithoutAvailableDropshipCartons() { + var generator = org.mockito.Mockito.mock(LastlegOrderNoGenerator.class); + ReflectionTestUtils.setField(service, "orderNoGenerator", generator); + InventoryPosition position = new InventoryPosition(); position.setCustomerId("TENANT-A"); position.setOverseasWarehouseId("WH-1"); + when(inventoryPositions.selectById(901L)).thenReturn(position); DropshipOutboundCandidate candidate = dropshipCandidate(901L, 1001L, "WH-1", "PLT-501", "MIXED", 1L); when(dropshipProfiles.lockOutboundCandidate("TENANT-A", 901L, "MIXED")).thenReturn(candidate); when(dropshipCartons.lockAvailable("TENANT-A", 1001L, "MIXED", 1L)).thenReturn(List.of()); @@ -470,6 +476,45 @@ class DeliveryTransferServiceTest { assertEquals(409, exception.getStatusCode().value()); assertTrue(exception.getReason().contains("一件代发可用箱数不足")); + verify(outboundPallets, never()).insert(any(OutboundPalletItem.class)); + } + + @Test + void concurrentOneClickCreateReplaysCurrentCommittedRowBeforeTakingInventoryLocks() { + var generator = org.mockito.Mockito.mock(LastlegOrderNoGenerator.class); + ReflectionTestUtils.setField(service, "orderNoGenerator", generator); + var request = new DeliveryRequests.OneClickOutbound("recipient", List.of( + new DeliveryRequests.OneClickItem(901L, 2L, "SKU-1", List.of("A", "B"))), "remark", "race-key"); + OutboundOrder winner = outbound(301L, null, null, "OUT-WINNER", "race-key"); + winner.setOutboundType("ONE_CLICK_DROPSHIP"); winner.setRequestHash(service.oneClickRequestHash(request)); + OutboundPdaTask task = new OutboundPdaTask(); task.setId(701L); task.setTaskNo("PDA-WINNER"); task.setStatus("PENDING"); + InventoryPosition position = new InventoryPosition(); position.setCustomerId("TENANT-A"); position.setOverseasWarehouseId("WH-1"); + when(inventoryPositions.selectById(901L)).thenReturn(position); + when(outboundOrders.insert(any(OutboundOrder.class))).thenThrow(new org.springframework.dao.DuplicateKeyException("race")); + when(outboundOrders.lockByIdempotency("TENANT-A", "race-key")).thenReturn(winner); + when(pdaTasks.lockByOutbound("TENANT-A", 301L)).thenReturn(task); + assertEquals("PDA-WINNER", service.createOneClick("TENANT-A", "U-1", request).taskNo()); + verify(dropshipProfiles, never()).lockOutboundCandidate(any(), any(), any()); + verify(outboundPallets, never()).insert(any(OutboundPalletItem.class)); + verify(pdaTasks, never()).insert(any(OutboundPdaTask.class)); + } + + @Test + void oneClickReplayRejectsChangedRecipientItemsMarksAndRemark() { + var item = new DeliveryRequests.OneClickItem(901L, 2L, "SKU-1", List.of("A", "B")); + var request = new DeliveryRequests.OneClickOutbound("recipient", List.of(item), "remark", "key"); + OutboundOrder existing = outbound(301L, null, null, "OUT-1", "key"); + existing.setOutboundType("ONE_CLICK_DROPSHIP"); existing.setRequestHash(service.oneClickRequestHash(request)); + when(outboundOrders.findByIdempotency("TENANT-A", "key")).thenReturn(existing); + for (var changed : List.of( + new DeliveryRequests.OneClickOutbound("changed", List.of(item), "remark", "key"), + new DeliveryRequests.OneClickOutbound("recipient", List.of(item), "changed", "key"), + new DeliveryRequests.OneClickOutbound("recipient", List.of(new DeliveryRequests.OneClickItem(902L, 2L, "SKU-1", List.of("A", "B"))), "remark", "key"), + new DeliveryRequests.OneClickOutbound("recipient", List.of(new DeliveryRequests.OneClickItem(901L, 2L, "SKU-1", List.of("A", "C"))), "remark", "key"))) { + assertEquals(409, assertThrows(ResponseStatusException.class, + () -> service.createOneClick("TENANT-A", "U-1", changed)).getStatusCode().value()); + } + verify(inventoryPositions, never()).selectById(any()); verify(outboundOrders, never()).insert(any(OutboundOrder.class)); } @@ -493,7 +538,6 @@ class DeliveryTransferServiceTest { @Test void oneClickCreationRejectsOrdinaryInventoryBeforeAnyWrite() { - when(dropshipProfiles.lockOutboundCandidate("TENANT-A", 901L, "MIXED")).thenReturn(null); ResponseStatusException exception = assertThrows(ResponseStatusException.class, () -> service.createOneClick( "TENANT-A", "U-1", new DeliveryRequests.OneClickOutbound("recipient", List.of( @@ -619,6 +663,12 @@ class DeliveryTransferServiceTest { assertEquals("SKU-1", projected.skuCode()); assertEquals("IN-A", projected.inboundCode()); assertEquals(1L, projected.availablePackages()); + + item.setCartonMarks("CTN-B"); + assertEquals(List.of("CTN-B"), service.pdaTaskDetail("WH-1", "PDA-701").items().get(0).positions() + .stream().map(DeliveryResponses.PdaPickPosition::cartonMark).toList()); + second.setStatus("SHIPPED"); + assertTrue(service.pdaTaskDetail("WH-1", "PDA-701").items().get(0).positions().isEmpty()); } @Test @@ -647,6 +697,7 @@ class DeliveryTransferServiceTest { DeliveryResponses.PdaTaskDetail detail = service.pdaTaskDetail("WH-1", "PDA-701"); assertEquals("CTN-A", detail.items().get(0).positions().get(0).cartonMark()); + assertEquals("SKU-1", detail.items().get(0).positions().get(0).skuCode()); } @Test @@ -658,13 +709,15 @@ class DeliveryTransferServiceTest { OutboundPdaTask task = new OutboundPdaTask(); task.setId(701L); task.setCustomerId("TENANT-A"); task.setOutboundOrderId(301L); task.setTaskNo("PDA-701"); task.setStatus("PENDING"); OutboundPalletItem item = pallet(501L, "DP-1", "ONT8", "Ontario", "IN-A", 2); - item.setSkuCode("SKU-1"); + item.setSkuCode("SKU-1"); item.setCartonMarks("BOX-A,BOX-B"); InventoryPosition position = new InventoryPosition(); position.setId(901L); position.setCustomerId("TENANT-A"); position.setBalanceId(801L); position.setPalletId(501L); position.setOverseasWarehouseId("WH-1"); InventoryBalance balance = new InventoryBalance(); balance.setCustomerId("TENANT-A"); balance.setInboundCode("IN-A"); DropshipOrderProfile profile = new DropshipOrderProfile(); profile.setId(1001L); DropshipCarton first = new DropshipCarton(); first.setId(1101L); first.setProductQuantity(7L); DropshipCarton second = new DropshipCarton(); second.setId(1102L); second.setProductQuantity(13L); + first.setStatus("AVAILABLE"); first.setSkuCode("SKU-1"); first.setCartonMark("BOX-A"); + second.setStatus("AVAILABLE"); second.setSkuCode("SKU-1"); second.setCartonMark("BOX-B"); when(pdaTasks.lockByNoAndWarehouse("PDA-701", "WH-1")).thenReturn(task); when(outboundOrders.selectById(301L)).thenReturn(outbound); when(outboundOrders.lockByNo("TENANT-A", "OUT-301")).thenReturn(outbound); @@ -672,12 +725,18 @@ class DeliveryTransferServiceTest { when(inventoryPositions.selectById(901L)).thenReturn(position); when(inventoryBalances.selectById(801L)).thenReturn(balance); when(dropshipProfiles.findPosition("TENANT-A", 901L)).thenReturn(profile); - when(dropshipCartons.lockAvailable("TENANT-A", 1001L, "SKU-1", 1L)).thenReturn(List.of(first), List.of(second)); + when(dropshipCartons.findByProfileAndMark("TENANT-A", 1001L, "BOX-A")).thenReturn(first); + when(dropshipCartons.findByProfileAndMark("TENANT-A", 1001L, "BOX-B")).thenReturn(second); when(dropshipSkuInventory.deduct("TENANT-A", 1001L, "SKU-1", 1L, 7L)).thenReturn(1); when(dropshipSkuInventory.deduct("TENANT-A", 1001L, "SKU-1", 1L, 13L)).thenReturn(1); when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(item)); - service.pdaPick("WH-1", "U-1", "PDA-701", new DeliveryRequests.PdaPick(item.getId(), 901L, 1L, "pick-7")); - service.pdaPick("WH-1", "U-1", "PDA-701", new DeliveryRequests.PdaPick(item.getId(), 901L, 1L, "pick-13")); + assertThrows(ResponseStatusException.class, () -> service.pdaPick("WH-1", "U-1", "PDA-701", + new DeliveryRequests.PdaPick(item.getId(), 901L, 1L, "wrong-mark", List.of("WRONG-BOX")))); + service.pdaPick("WH-1", "U-1", "PDA-701", new DeliveryRequests.PdaPick(item.getId(), 901L, 1L, "pick-7", List.of("BOX-A"))); + assertEquals(1, item.getPickedPackageCount()); assertEquals("AVAILABLE", second.getStatus()); + assertThrows(ResponseStatusException.class, () -> service.pdaPick("WH-1", "U-1", "PDA-701", + new DeliveryRequests.PdaPick(item.getId(), 901L, 1L, "repeat-box-new-key", List.of("BOX-A")))); + service.pdaPick("WH-1", "U-1", "PDA-701", new DeliveryRequests.PdaPick(item.getId(), 901L, 1L, "pick-13", List.of("BOX-B"))); verify(dropshipSkuInventory).deduct("TENANT-A", 1001L, "SKU-1", 1L, 7L); verify(dropshipSkuInventory).deduct("TENANT-A", 1001L, "SKU-1", 1L, 13L); assertEquals("SHIPPED", first.getStatus()); assertEquals("SHIPPED", second.getStatus()); diff --git a/scripts/admin-test-env-modules/dropship-outbound-edge-cases.mjs b/scripts/admin-test-env-modules/dropship-outbound-edge-cases.mjs index 195eca670..f4eb8d493 100644 --- a/scripts/admin-test-env-modules/dropship-outbound-edge-cases.mjs +++ b/scripts/admin-test-env-modules/dropship-outbound-edge-cases.mjs @@ -65,11 +65,19 @@ export async function outboundCreationGuards(edge) { export async function outboundTaskGuards(edge) { const before = await taskSnapshot(edge) - const changed = await request(edge, 'POST', `${BASE}/one-click-dropship?customerId=${edge.customerId}`, - { ...edge.body, recipient: `${edge.body.recipient}-changed` }) - record(edge, '出库创建同键异载荷返回A0409', changed, changed.body?.code === 'A0409') - edge.check(`${edge.label}同键异载荷后出库事实零漂移`, null, - JSON.stringify(before) === JSON.stringify(await taskSnapshot(edge))) + const first = edge.body.items[0] + const changedBodies = [ + ['收件人', { ...edge.body, recipient: `${edge.body.recipient}-changed` }], + ['备注', { ...edge.body, remark: `${edge.body.remark || ''}-changed` }], + ['明细', { ...edge.body, items: [{ ...first, packages: first.packages + 1 }, ...edge.body.items.slice(1)] }], + ['箱唛', { ...edge.body, items: [{ ...first, cartonMarks: [`CHANGED-${edge.context.marker}`, ...first.cartonMarks.slice(1)] }, ...edge.body.items.slice(1)] }], + ] + for (const [field, body] of changedBodies) { + const changed = await request(edge, 'POST', `${BASE}/one-click-dropship?customerId=${edge.customerId}`, body) + record(edge, `出库创建同键异载荷返回A0409:${field}`, changed, changed.body?.code === 'A0409') + edge.check(`${edge.label}同键修改${field}后出库事实零漂移`, null, + JSON.stringify(before) === JSON.stringify(await taskSnapshot(edge))) + } const path = `/api/pda/v1/last-leg/outbound-tasks/${edge.task.taskNo}` const incomplete = await request(edge, 'POST', `${path}/complete`, { idempotencyKey: `${edge.body.idempotencyKey}-incomplete` }, true)