fix(dropship): serialize outbound creation and pick exact carton subsets
This commit is contained in:
@@ -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;
|
||||
|
||||
+2
@@ -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")
|
||||
|
||||
+60
-6
@@ -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]
|
||||
|
||||
+2
@@ -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"));
|
||||
}
|
||||
|
||||
+64
-5
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user