fix(dropship): serialize outbound creation and pick exact carton subsets

This commit is contained in:
2026-09-15 11:42:19 +08:00
parent 88e2fb0eb1
commit 3ebe254c45
10 changed files with 155 additions and 16 deletions
@@ -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)
@@ -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;
@@ -15,6 +15,8 @@ public interface OutboundOrderMapper extends BaseMapper<OutboundOrder> {
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")
@@ -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<Long> 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<DeliveryResponses.OneClickCandidate> 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<DropshipCarton> cartons = StringUtils.hasText(item.getCartonMarks())
? Arrays.stream(item.getCartonMarks().split(",")).map(String::trim).filter(StringUtils::hasText)
List<String> submittedMarks = request.cartonMarks() == null ? List.of() : request.cartonMarks();
List<String> selectedMarks = submittedMarks.stream().map(String::trim).filter(StringUtils::hasText).distinct().sorted().toList();
Set<String> 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<DropshipCarton> 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<DeliveryResponses.PdaPickPosition> result = new ArrayList<>();
Set<String> 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<String> 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,
@@ -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)
@@ -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]
@@ -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]
@@ -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"));
}
@@ -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());
@@ -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)