test(return): cover isolation and concurrency; fix receipt and packing races
This commit is contained in:
+3
-1
@@ -18,5 +18,7 @@ public final class PdaReturnReceiptRequests {
|
||||
@Size(max = 128) String cartonMark,
|
||||
@NotEmpty @Size(max = 500) List<@Valid Item> items,
|
||||
@NotBlank @Size(max = 255) String idempotencyKey) { }
|
||||
public record Item(@NotBlank @Size(max = 128) String skuCode, @Positive long receivedQuantity) { }
|
||||
public record Item(@NotBlank @Size(max = 128) String skuCode,
|
||||
@com.fasterxml.jackson.databind.annotation.JsonDeserialize(using = ReturnQuantityDeserializer.class)
|
||||
@Positive long receivedQuantity) { }
|
||||
}
|
||||
|
||||
+3
-1
@@ -41,5 +41,7 @@ public final class ReturnInventoryRequests {
|
||||
@NotNull @Positive BigDecimal weight,
|
||||
@NotBlank @Size(max = 255) String idempotencyKey) { }
|
||||
|
||||
public record ValidItem(@NotBlank @Size(max = 128) String skuCode, @Positive long quantity) { }
|
||||
public record ValidItem(@NotBlank @Size(max = 128) String skuCode,
|
||||
@com.fasterxml.jackson.databind.annotation.JsonDeserialize(using = ReturnQuantityDeserializer.class)
|
||||
@Positive long quantity) { }
|
||||
}
|
||||
|
||||
+1
@@ -22,6 +22,7 @@ public final class ReturnOrderRequests {
|
||||
@NotEmpty @Size(max = 500) List<@Valid Item> items) { }
|
||||
public record Item(@NotBlank @Size(max = 128) String skuCode,
|
||||
@Size(max = 255) String productNameZh,
|
||||
@com.fasterxml.jackson.databind.annotation.JsonDeserialize(using = ReturnQuantityDeserializer.class)
|
||||
@Positive long expectedQuantity) { }
|
||||
public record Query(@Min(1) long page, @Min(1) @Max(200) long size, String returnNo,
|
||||
String trackingNo, String skuCode, String status) {
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package com.baiyue.lastleg.returnreceipt.dto;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.core.JsonToken;
|
||||
import com.fasterxml.jackson.databind.DeserializationContext;
|
||||
import com.fasterxml.jackson.databind.JsonDeserializer;
|
||||
import com.fasterxml.jackson.databind.JsonMappingException;
|
||||
import java.io.IOException;
|
||||
|
||||
/** Return quantities are whole units; never truncate fractional JSON input. */
|
||||
public final class ReturnQuantityDeserializer extends JsonDeserializer<Long> {
|
||||
@Override
|
||||
public Long deserialize(JsonParser parser, DeserializationContext context) throws IOException {
|
||||
if (!parser.hasToken(JsonToken.VALUE_NUMBER_INT)) {
|
||||
return context.reportInputMismatch(Long.class, "退货数量必须为整数");
|
||||
}
|
||||
return parser.getLongValue();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Long getNullValue(DeserializationContext context) throws JsonMappingException {
|
||||
return context.reportInputMismatch(Long.class, "退货数量不能为空");
|
||||
}
|
||||
}
|
||||
+2
@@ -12,4 +12,6 @@ import java.util.List;
|
||||
public interface ReturnInventoryCartonItemMapper extends BaseMapper<ReturnInventoryCartonItem> {
|
||||
@Select("SELECT * FROM ll_return_inventory_carton_item WHERE carton_id=#{cartonId} ORDER BY sku_code")
|
||||
List<ReturnInventoryCartonItem> listCarton(@Param("cartonId") long cartonId);
|
||||
@Select("SELECT * FROM ll_return_inventory_carton_item WHERE carton_id=#{cartonId} ORDER BY sku_code FOR UPDATE")
|
||||
List<ReturnInventoryCartonItem> lockCarton(@Param("cartonId") long cartonId);
|
||||
}
|
||||
|
||||
+2
@@ -12,6 +12,8 @@ import java.util.List;
|
||||
public interface ReturnReceiptMapper extends BaseMapper<ReturnReceipt> {
|
||||
@Select("SELECT * FROM ll_return_receipt WHERE operator_id=#{operatorId} AND idempotency_key=#{key} LIMIT 1")
|
||||
ReturnReceipt findKey(@Param("operatorId") String operatorId, @Param("key") String key);
|
||||
@Select("SELECT * FROM ll_return_receipt WHERE operator_id=#{operatorId} AND idempotency_key=#{key} LIMIT 1 FOR UPDATE")
|
||||
ReturnReceipt lockKey(@Param("operatorId") String operatorId, @Param("key") String key);
|
||||
@Select("SELECT * FROM ll_return_receipt WHERE return_order_id=#{orderId} ORDER BY signed_at DESC,id DESC")
|
||||
List<ReturnReceipt> listOrder(@Param("orderId") Long orderId);
|
||||
}
|
||||
|
||||
+12
-3
@@ -237,11 +237,19 @@ public class ReturnInventoryService {
|
||||
if (selectedOrders.stream().anyMatch(order -> !customerId.equals(order.getCustomerId()))) throw conflict("不同客户的退货单禁止合箱");
|
||||
List<ReturnInventoryCarton> sources = new ArrayList<>();
|
||||
Map<Long, ReturnOrder> sourceOwners = new LinkedHashMap<>();
|
||||
Map<String, ReturnOrder> candidateOwners = new java.util.TreeMap<>();
|
||||
for (ReturnOrder order : selectedOrders) for (ReturnReceipt receipt : receiptMapper.listOrder(order.getId())) {
|
||||
ReturnInventoryCarton carton = cartons.findReceipt(receipt.getId());
|
||||
if (carton != null && AVAILABLE.equals(carton.getStatus())) {
|
||||
ReturnInventoryCarton locked = cartons.lockMark(carton.getCartonMark());
|
||||
sources.add(locked); sourceOwners.put(locked.getId(), order);
|
||||
candidateOwners.put(carton.getCartonMark(), order);
|
||||
}
|
||||
}
|
||||
// All requests acquire source locks in the same order. The earlier
|
||||
// candidate query is only a snapshot: availability must be checked again.
|
||||
for (Map.Entry<String, ReturnOrder> candidate : candidateOwners.entrySet()) {
|
||||
ReturnInventoryCarton locked = cartons.lockMark(candidate.getKey());
|
||||
if (locked != null && AVAILABLE.equals(locked.getStatus())) {
|
||||
sources.add(locked); sourceOwners.put(locked.getId(), candidate.getValue());
|
||||
}
|
||||
}
|
||||
if (sources.isEmpty()) throw conflict("所选退货单没有可合箱的库存箱");
|
||||
@@ -258,7 +266,8 @@ public class ReturnInventoryService {
|
||||
Map<Long, Map<String, Long>> attachedByOrder = new LinkedHashMap<>();
|
||||
long total = 0;
|
||||
for (ReturnInventoryCarton source : sources) {
|
||||
List<ReturnInventoryCartonItem> sourceItems = cartonItems.listCarton(source.getId());
|
||||
// Read committed quantities under the source lock, never a stale MVCC snapshot.
|
||||
List<ReturnInventoryCartonItem> sourceItems = cartonItems.lockCarton(source.getId());
|
||||
long sourceTotal = 0; int sourceTypes = 0;
|
||||
for (ReturnInventoryCartonItem item : sourceItems) {
|
||||
String sku = item.getSkuCode().toUpperCase(java.util.Locale.ROOT);
|
||||
|
||||
+8
-1
@@ -268,6 +268,13 @@ public class ReturnReceiptService {
|
||||
if (order == null || !String.valueOf(identity.overseasWarehouseId()).equals(order.getOverseasWarehouseId())) {
|
||||
throw notFound("当前绑定仓没有该退货单");
|
||||
}
|
||||
// Waiting for the order lock may allow the same request to commit. Use a
|
||||
// current read, not the repeatable-read snapshot established by findKey.
|
||||
ReturnReceipt concurrentReplay = receipts.lockKey(identity.operatorKey(), key);
|
||||
if (concurrentReplay != null) {
|
||||
if (!requestHash.equals(concurrentReplay.getRequestHash())) throw conflict("幂等键已用于不同的签收请求");
|
||||
return confirmed(order, concurrentReplay, COMPLETED.equals(order.getStatus()));
|
||||
}
|
||||
if (!Set.of(PENDING_RECEIPT, PARTIALLY_RECEIVED).contains(order.getStatus())) {
|
||||
throw conflict("仅待签收或签收中的退货单允许 PDA 签收");
|
||||
}
|
||||
@@ -287,7 +294,7 @@ public class ReturnReceiptService {
|
||||
receipt.setSignedAt(now); receipt.setCreatedAt(now);
|
||||
try { receipts.insert(receipt); }
|
||||
catch (DuplicateKeyException concurrent) {
|
||||
ReturnReceipt existing = receipts.findKey(identity.operatorKey(), key);
|
||||
ReturnReceipt existing = receipts.lockKey(identity.operatorKey(), key);
|
||||
if (existing != null) return replayReceipt(existing, requestHash);
|
||||
throw concurrent;
|
||||
}
|
||||
|
||||
+2
-1
@@ -64,7 +64,8 @@ class RelabelDocumentServiceTest {
|
||||
service.downloadFile("101","RL-1","TOTAL",null,99L);
|
||||
verify(files).download("101","OVERSEAS_RELABEL_TOTAL_DOCUMENT","11",99L);
|
||||
assertThatThrownBy(()->service.downloadFile("101","RL-1","TOTAL",null,98L)).hasMessageContaining("未绑定");
|
||||
assertThatThrownBy(()->service.downloadFile("101","RL-1","other",null,99L)).hasMessageContaining("资料范围");
|
||||
assertThatThrownBy(()->service.downloadFile("101","RL-1","other",null,99L))
|
||||
.isInstanceOfSatisfying(ResponseStatusException.class, error -> assertThat(error.getStatusCode().value()).isEqualTo(400));
|
||||
assertThatThrownBy(()->service.downloadFile("101","RL-1","ITEM",null,99L)).hasMessageContaining("指定明细");
|
||||
verifyNoMoreInteractions(files);
|
||||
}
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package com.baiyue.lastleg.returnreceipt.dto;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class ReturnQuantityDeserializerTest {
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
|
||||
@Test
|
||||
void allThreeReturnQuantityInputsRejectFractionalOrCoercedValues() {
|
||||
for (String value : new String[] { "1.5", "0.5", "1.0", "1e1", "null", "\"1\"", "true", "9223372036854775808" }) {
|
||||
assertThrows(JsonProcessingException.class, () -> mapper.readValue(
|
||||
"{\"skuCode\":\"A\",\"expectedQuantity\":" + value + "}", ReturnOrderRequests.Item.class), value);
|
||||
assertThrows(JsonProcessingException.class, () -> mapper.readValue(
|
||||
"{\"skuCode\":\"A\",\"receivedQuantity\":" + value + "}", PdaReturnReceiptRequests.Item.class), value);
|
||||
assertThrows(JsonProcessingException.class, () -> mapper.readValue(
|
||||
"{\"skuCode\":\"A\",\"quantity\":" + value + "}", ReturnInventoryRequests.ValidItem.class), value);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void integerQuantitiesRemainExact() throws Exception {
|
||||
assertEquals(2L, mapper.readValue("{\"skuCode\":\"A\",\"expectedQuantity\":2}", ReturnOrderRequests.Item.class).expectedQuantity());
|
||||
assertEquals(2L, mapper.readValue("{\"skuCode\":\"A\",\"receivedQuantity\":2}", PdaReturnReceiptRequests.Item.class).receivedQuantity());
|
||||
assertEquals(Long.MAX_VALUE, mapper.readValue("{\"skuCode\":\"A\",\"quantity\":9223372036854775807}", ReturnInventoryRequests.ValidItem.class).quantity());
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package com.baiyue.lastleg.returnreceipt.service;
|
||||
|
||||
import com.baiyue.lastleg.returnreceipt.mapper.ReturnReceiptMapper;
|
||||
import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class ReturnCurrentReadContractTest {
|
||||
@Test
|
||||
void concurrentReplayAndInventoryReadBypassRepeatableReadSnapshots() throws Exception {
|
||||
String receiptSql = String.join(" ", ReturnReceiptMapper.class.getMethod("lockKey", String.class, String.class).getAnnotation(Select.class).value());
|
||||
String itemSql = String.join(" ", ReturnInventoryCartonItemMapper.class.getMethod("lockCarton", long.class).getAnnotation(Select.class).value());
|
||||
assertTrue(receiptSql.endsWith("FOR UPDATE"));
|
||||
assertTrue(itemSql.endsWith("FOR UPDATE"));
|
||||
assertTrue(itemSql.contains("ORDER BY sku_code"));
|
||||
}
|
||||
}
|
||||
+63
-1
@@ -31,6 +31,10 @@ import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ReturnInventoryServiceTest {
|
||||
@@ -132,7 +136,7 @@ class ReturnInventoryServiceTest {
|
||||
when(receipts.listOrder(11L)).thenReturn(List.of(receipt));
|
||||
when(cartons.findReceipt(21L)).thenReturn(source);
|
||||
when(cartons.lockMark("RC-SOURCE")).thenReturn(source);
|
||||
when(cartonItems.listCarton(31L)).thenReturn(List.of(skuA, skuB));
|
||||
when(cartonItems.lockCarton(31L)).thenReturn(List.of(skuA, skuB));
|
||||
when(numbers.next(anyString(), anyString(), anyLong())).thenReturn("RC-MERGED");
|
||||
when(balances.adjustCartonQuantity(anyString(), anyString(), anyString(), anyLong())).thenReturn(1);
|
||||
|
||||
@@ -152,6 +156,64 @@ class ReturnInventoryServiceTest {
|
||||
assertThat(target.getValue().getSourceReturnNo()).isEqualTo("RT-1");
|
||||
}
|
||||
|
||||
@Test
|
||||
void sourceEmptiedByAnotherMergeCannotBeConsumedAgainAfterLockWait() {
|
||||
ReturnInventoryCarton locked = prepareConcurrentSource();
|
||||
locked.setStatus(ReturnInventoryService.MERGED); locked.setTotalQuantity(0L);
|
||||
var error = assertThrows(ResponseStatusException.class, () -> service.merge(
|
||||
new PdaWarehouseIdentity("operator-2", "操作员2", 42L), mergeRequest(1)));
|
||||
assertThat(error.getStatusCode().value()).isEqualTo(409);
|
||||
verify(cartonItems, never()).lockCarton(anyLong());
|
||||
verify(cartons, never()).insert(any(ReturnInventoryCarton.class));
|
||||
verify(balances, never()).adjustCartonQuantity(anyString(), anyString(), anyString(), anyLong());
|
||||
}
|
||||
|
||||
@Test
|
||||
void partiallyConsumedSourceUsesCurrentLockedSkuQuantityNotEarlierSnapshot() {
|
||||
ReturnInventoryCarton locked = prepareConcurrentSource();
|
||||
locked.setTotalQuantity(1L);
|
||||
when(cartonItems.lockCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 1L)));
|
||||
var error = assertThrows(ResponseStatusException.class, () -> service.merge(
|
||||
new PdaWarehouseIdentity("operator-2", "操作员2", 42L), mergeRequest(2)));
|
||||
assertThat(error.getStatusCode().value()).isEqualTo(409);
|
||||
verify(cartonItems).lockCarton(31L);
|
||||
verify(cartonItems, never()).listCarton(31L);
|
||||
verify(cartons, never()).insert(any(ReturnInventoryCarton.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fullyConsumedSourceIsMergedAndSkuCartonCountRemainsOne() {
|
||||
ReturnInventoryCarton locked = prepareConcurrentSource();
|
||||
when(cartonItems.lockCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 2L)));
|
||||
when(numbers.next(anyString(), anyString(), anyLong())).thenReturn("RC-TARGET");
|
||||
when(balances.adjustCartonQuantity(anyString(), anyString(), anyString(), anyLong())).thenReturn(1);
|
||||
service.merge(new PdaWarehouseIdentity("operator-2", "操作员2", 42L), mergeRequest(2));
|
||||
assertThat(locked.getTotalQuantity()).isZero();
|
||||
assertThat(locked.getSkuTypes()).isZero();
|
||||
assertThat(locked.getStatus()).isEqualTo(ReturnInventoryService.MERGED);
|
||||
verify(cartonItems).deleteById(41L);
|
||||
verify(balances).adjustCartonQuantity("10001", "42", "SKU-A", 0L);
|
||||
}
|
||||
|
||||
private ReturnInventoryCarton prepareConcurrentSource() {
|
||||
ReturnReceipt receipt = new ReturnReceipt(); receipt.setId(21L);
|
||||
ReturnInventoryCarton snapshot = new ReturnInventoryCarton();
|
||||
snapshot.setId(31L); snapshot.setCartonMark("RC-SOURCE"); snapshot.setStatus(ReturnInventoryService.AVAILABLE);
|
||||
ReturnInventoryCarton locked = new ReturnInventoryCarton();
|
||||
locked.setId(31L); locked.setCartonMark("RC-SOURCE"); locked.setStatus(ReturnInventoryService.AVAILABLE);
|
||||
locked.setCustomerId("10001"); locked.setOverseasWarehouseId("42"); locked.setSourceBatchNo("RB-1"); locked.setSourceReturnNo("RT-1");
|
||||
when(orders.findByNos("42", List.of("RT-1"))).thenReturn(List.of(completedOrder()));
|
||||
when(receipts.listOrder(11L)).thenReturn(List.of(receipt));
|
||||
when(cartons.findReceipt(21L)).thenReturn(snapshot);
|
||||
when(cartons.lockMark("RC-SOURCE")).thenReturn(locked);
|
||||
return locked;
|
||||
}
|
||||
|
||||
private ReturnInventoryRequests.Merge mergeRequest(long quantity) {
|
||||
return new ReturnInventoryRequests.Merge(List.of("RT-1"), List.of(new ReturnInventoryRequests.ValidItem("SKU-A", quantity)),
|
||||
BigDecimal.ONE, BigDecimal.ONE, BigDecimal.ONE, BigDecimal.ONE, "concurrent-key");
|
||||
}
|
||||
|
||||
private ReturnOrder completedOrder() {
|
||||
ReturnOrder order = new ReturnOrder();
|
||||
order.setId(11L);
|
||||
|
||||
+32
@@ -244,6 +244,38 @@ class ReturnReceiptServiceTest {
|
||||
when(inventory.createReceiptCarton(eq(order), any(ReturnReceipt.class), any(), any())).thenReturn(carton);
|
||||
}
|
||||
|
||||
@Test
|
||||
void sameKeyCommittedWhileWaitingForOrderLockReplaysCompletedReceipt() throws Exception {
|
||||
var request = confirm("RETURN_NO", null, null, "concurrent-key", List.of(new PdaReturnReceiptRequests.Item("SKU-A", 1)));
|
||||
ReturnOrder completed = order("RT-1", "10", ReturnReceiptService.COMPLETED, 1, 1);
|
||||
ReturnReceipt committed = new ReturnReceipt();
|
||||
committed.setReceiptNo("RR-COMMITTED"); committed.setReceiptBatchNo("RB-COMMITTED"); committed.setCartonMark("RC-COMMITTED");
|
||||
committed.setRequestHash(java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256")
|
||||
.digest(new ObjectMapper().writeValueAsBytes(request))));
|
||||
when(orders.lockNo("RT-1")).thenReturn(completed);
|
||||
when(receipts.lockKey("operator-1", "concurrent-key")).thenReturn(committed);
|
||||
|
||||
var result = service.confirm(identity, request);
|
||||
|
||||
assertEquals("RR-COMMITTED", result.receiptNo());
|
||||
assertEquals("RB-COMMITTED", result.receiptBatchNo());
|
||||
assertEquals(1, result.receivedQuantity());
|
||||
assertTrue(result.fullyMatched());
|
||||
verify(receipts, never()).insert(any(ReturnReceipt.class));
|
||||
verifyNoInteractions(inventory, batches, orderItems);
|
||||
}
|
||||
|
||||
@Test
|
||||
void concurrentSameKeyWithDifferentPayloadStillConflicts() {
|
||||
when(orders.lockNo("RT-1")).thenReturn(order("RT-1", "10", ReturnReceiptService.COMPLETED, 1, 1));
|
||||
ReturnReceipt committed = new ReturnReceipt(); committed.setRequestHash("different-content");
|
||||
when(receipts.lockKey("operator-1", "concurrent-key")).thenReturn(committed);
|
||||
var error = assertThrows(ResponseStatusException.class, () -> service.confirm(identity,
|
||||
confirm("RETURN_NO", null, null, "concurrent-key", List.of(new PdaReturnReceiptRequests.Item("SKU-A", 1)))));
|
||||
assertEquals(409, error.getStatusCode().value());
|
||||
verifyNoInteractions(inventory, batches, orderItems);
|
||||
}
|
||||
|
||||
private static ReturnOrder order(String returnNo, String warehouseId, String status, long expected, long received) {
|
||||
ReturnOrder order = new ReturnOrder();
|
||||
order.setId(1L);
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
|
||||
退货专项只运行 `--modules phase4-overseas-return-receipt`,自动准备本轮客户;覆盖非法/重复预报、退货单号和 SKU 查询、非法/未知 SKU 实收、超收及失败不落账、分批签收完成、部分签收禁止合箱、合箱非法箱规/数量/来源、幂等重放前后库存快照一致、逐箱分页无重复遗漏、客户 SKU 数量守恒及标签。通过显式测试环境文件运行总入口,JSON 路径由 `--output` 指定;会创建隔离测试数据,finally 停用临时 PDA,不删除业务记录。失败保留报告,先核对记录再决定是否新批次重跑。
|
||||
|
||||
`return-boundary-scenarios.mjs` 是退货专项自动调用的内部扩展,不单独注册入口:本轮新建第二隔离客户,复用两个有效海外仓,覆盖跨客户单据/箱/标签/列表隔离、跨仓 PDA 隔离、两人争抢最后一件的签收及合箱、同键并发签收与成功后重试、多单同 SKU 全量合箱和实际来源追溯、耗尽箱清零、多单 SKU 候选和输入边界。异常写入逐次前后回查订单、签收批次、箱及 SKU;同键重试只模拟响应丢失后的客户端行为,不代表网络故障注入。每组并发严格为两个请求,不启动批量 worker;缺少第二仓或操作员属于失败,不能跳过后宣称通过。finally 停用新增 PDA,保留测试客户和业务记录;不触发部署、SQL、换标、出库或二次合箱。真实运行须明确授权目标环境及此小规模并发边界。`merchant-qualification-fixture.mjs` 的内部参数 `publishEnvironment: false` 只将第二客户凭据留在独立内存上下文,不覆盖主客户环境凭据,默认主 fixture 行为不变。
|
||||
|
||||
扩展离线检查:`node --test scripts/admin-test-env-modules/return-boundary-scenarios-contract.test.mjs scripts/admin-test-env-modules/phase4-overseas-return-receipt-contract.test.mjs scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs scripts/script-documentation-contract.test.mjs`。这些校验验证脚本编排和断言,不代替真实 HTTP 验收;运行报告中的每个失败均保留阻断。
|
||||
|
||||
`phase4-overseas-return-followups.mjs` 是独立可选后续模块,只在显式 `--modules phase4-overseas-return-followups` 时依赖并运行退货前置,再关联换标单及执行退货出库;不计入退货专项通过结论。仅复用同次运行、同 marker 的退货资源,凭据只在内存中,finally 停用其独立临时 PDA。合同检查复用 `phase4-overseas-return-receipt-contract.test.mjs`;HTTP 必须有目标环境写入授权。
|
||||
|
||||
退货专项复跑命令(在后端根目录执行,每次使用新的报告名;不要与其他环境混用):
|
||||
|
||||
@@ -26,7 +26,7 @@ async function waitForDimensionInitialization(context, tenantId) {
|
||||
if (status !== 'READY') throw new Error(`客户默认尺寸初始化未就绪: ${status}`)
|
||||
}
|
||||
|
||||
export async function run(context) {
|
||||
export async function run(context, { publishEnvironment = true } = {}) {
|
||||
const suffix = context.marker.replace(/[^0-9A-Z]/gi, '').slice(-10).padStart(10, '0')
|
||||
const customerName = String(context.options?.customerName || '').trim()
|
||||
const tenantName = customerName || `全量验收下单客户-${suffix}`
|
||||
@@ -102,8 +102,10 @@ export async function run(context) {
|
||||
`approved=${[...approvedTypes].join(',') || 'missing'}`, { deploymentBlocker: true })
|
||||
if (!eligible) throw new Error('隔离下单客户必填资质未全部审核通过')
|
||||
|
||||
process.env.MERCHANT_TEST_USERNAME = username
|
||||
process.env.MERCHANT_TEST_PASSWORD = password
|
||||
if (publishEnvironment) {
|
||||
process.env.MERCHANT_TEST_USERNAME = username
|
||||
process.env.MERCHANT_TEST_PASSWORD = password
|
||||
}
|
||||
context.context.merchantQualificationFixture = {
|
||||
tenantId, tenantName, username, password, marker: context.marker,
|
||||
expectedProcurementPrefix: customerName ? null : 'QLYSXDKH',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createPicker, disablePicker, resolvePickerAccess } from './phase4-overseas-pda.mjs'
|
||||
import { runReturnBoundaries, verifyNoMutation } from './return-boundary-scenarios.mjs'
|
||||
|
||||
const MODULE = 'phase4-overseas-return-receipt'
|
||||
const SCENARIO = 'phase4-overseas-return-receipt-and-billing'
|
||||
@@ -133,22 +134,45 @@ export async function run(context) {
|
||||
if (!returnNo) return
|
||||
context.createdResources.phase4OverseasReturnReceipt.returnNo = returnNo
|
||||
|
||||
async function snapshot() {
|
||||
const reads = [
|
||||
['订单列表', query(RETURN_ORDERS, { page: 1, size: 200 })],
|
||||
['订单及签收批次', `${RETURN_ORDERS}/${encodeURIComponent(returnNo)}`],
|
||||
['全部状态箱', query(`${RETURN_INVENTORY}/cartons`, { page: 1, size: 200, returnNo })],
|
||||
['SKU汇总', query(`${RETURN_INVENTORY}/summary`, { page: 1, size: 200, overseasWarehouseId: warehouse.id })],
|
||||
]
|
||||
const state = {}
|
||||
for (const [name, path] of reads) {
|
||||
const result = await overseas(context, merchantToken, 'GET', `失败操作逐项回查${name}`, path)
|
||||
if (!success(result.response)) throw new Error(`无法读取${name},不能证明失败未落账`)
|
||||
state[name] = result.data
|
||||
}
|
||||
state.items = []
|
||||
for (const row of records(state['全部状态箱'])) {
|
||||
const result = await overseas(context, merchantToken, 'GET', '失败操作逐箱回查明细', `${RETURN_INVENTORY}/cartons/${encodeURIComponent(row.cartonMark)}`)
|
||||
if (!success(result.response)) throw new Error('无法读取箱明细,不能证明失败未落账')
|
||||
state.items.push(result.data)
|
||||
}
|
||||
return state
|
||||
}
|
||||
const reject = (name, expected, action) => verifyNoMutation({ snapshot, action, name, expected,
|
||||
check: (label, response, passed) => add(context, label, response, passed) })
|
||||
|
||||
const createReplay = await overseas(context, merchantToken, 'POST', '退货预报同键同载荷重放', RETURN_ORDERS, createBody)
|
||||
context.assert(MODULE, '预报幂等重放复用同一退货单', createReplay.response,
|
||||
id(createReplay.data?.order?.returnNo) === returnNo, trace(createReplay.response), OPTIONS)
|
||||
const createConflict = await context.requestOverseas('POST', RETURN_ORDERS,
|
||||
{ ...createBody, remark: `${context.marker}-CHANGED` }, {}, merchantToken)
|
||||
add(context, '退货预报同键异载荷返回冲突', createConflict, conflict(createConflict))
|
||||
await reject('退货预报同键异载荷返回冲突', conflict, () => context.requestOverseas('POST', RETURN_ORDERS,
|
||||
{ ...createBody, remark: `${context.marker}-CHANGED` }, {}, merchantToken))
|
||||
|
||||
for (const [name, items] of [
|
||||
['空SKU明细', []],
|
||||
['零预报数量', [{ skuCode: skuA, expectedQuantity: 0 }]],
|
||||
['负预报数量', [{ skuCode: skuA, expectedQuantity: -1 }]],
|
||||
['小数预报数量', [{ skuCode: skuA, expectedQuantity: 1.5 }]],
|
||||
['重复预报SKU', [createBody.items[0], createBody.items[0]]],
|
||||
]) {
|
||||
const response = await context.requestOverseas('POST', RETURN_ORDERS,
|
||||
{ ...createBody, items, idempotencyKey: key(context, `INVALID-CREATE-${name}`) }, {}, merchantToken)
|
||||
add(context, `拒绝${name}`, response, validationFailure(response))
|
||||
await reject(`拒绝${name}`, validationFailure, () => context.requestOverseas('POST', RETURN_ORDERS,
|
||||
{ ...createBody, items, idempotencyKey: key(context, `INVALID-CREATE-${encodeURIComponent(name)}`) }, {}, merchantToken))
|
||||
}
|
||||
const onlyOrder = await overseas(context, merchantToken, 'GET', '非法预报后回查本轮订单数量',
|
||||
query(RETURN_ORDERS, { page: 1, size: 20, skuCode: skuA }))
|
||||
@@ -156,9 +180,8 @@ export async function run(context) {
|
||||
Number(onlyOrder.data?.total) === 1 && records(onlyOrder.data)[0]?.returnNo === returnNo
|
||||
&& records(onlyOrder.data)[0]?.status === 'PENDING_RECEIPT', trace(onlyOrder.response), OPTIONS)
|
||||
|
||||
const missingOrder = await context.requestOverseas('POST', `${PDA_RECEIPTS}/start`,
|
||||
{ returnNo: `MISSING-${suffix}` }, {}, picker.token)
|
||||
add(context, '不存在退货单返回可诊断未找到', missingOrder, notFound(missingOrder))
|
||||
await reject('不存在退货单返回可诊断未找到', notFound, () => context.requestOverseas('POST', `${PDA_RECEIPTS}/start`,
|
||||
{ returnNo: `MISSING-${suffix}` }, {}, picker.token))
|
||||
|
||||
const emptySku = await overseas(context, picker.token, 'POST', 'PDA查询不存在SKU返回正常空候选',
|
||||
`${PDA_RECEIPTS}/sku-candidates`, { skuCode: `MISSING-${suffix}` })
|
||||
@@ -198,16 +221,14 @@ export async function run(context) {
|
||||
&& id(firstReplay.data?.receiptBatchNo) === firstBatchNo
|
||||
&& id(firstReplay.data?.cartonMark) === cartonA,
|
||||
trace(firstReplay.response), OPTIONS)
|
||||
const firstConflict = await context.requestOverseas('POST', `${PDA_RECEIPTS}/confirm`, {
|
||||
await reject('签收同键异载荷返回冲突', conflict, () => context.requestOverseas('POST', `${PDA_RECEIPTS}/confirm`, {
|
||||
...firstBody, trackingNo: `TRACK-${suffix}-CHANGED`,
|
||||
}, {}, picker.token)
|
||||
add(context, '签收同键异载荷返回冲突', firstConflict, conflict(firstConflict))
|
||||
}, {}, picker.token))
|
||||
|
||||
const overReceipt = await context.requestOverseas('POST', `${PDA_RECEIPTS}/confirm`, {
|
||||
await reject('超收被拒绝且不得生成费用批次', conflict, () => context.requestOverseas('POST', `${PDA_RECEIPTS}/confirm`, {
|
||||
returnNo, startedBy: 'SKU', items: [{ skuCode: skuA, receivedQuantity: 2 }],
|
||||
idempotencyKey: key(context, 'OVER-RECEIPT'),
|
||||
}, {}, picker.token)
|
||||
add(context, '超收被拒绝且不得生成费用批次', overReceipt, conflict(overReceipt))
|
||||
}, {}, picker.token))
|
||||
|
||||
for (const [name, changes, expected] of [
|
||||
['零实收数量', { items: [{ skuCode: skuA, receivedQuantity: 0 }] }, validationFailure],
|
||||
@@ -216,15 +237,13 @@ export async function run(context) {
|
||||
['非法签收方式', { startedBy: 'INVALID' }, validationFailure],
|
||||
['未预报SKU', { items: [{ skuCode: `UNKNOWN-${suffix}`, receivedQuantity: 1 }] }, conflict],
|
||||
]) {
|
||||
const response = await context.requestOverseas('POST', `${PDA_RECEIPTS}/confirm`,
|
||||
{ ...firstBody, ...changes, idempotencyKey: key(context, `INVALID-RECEIPT-${name}`) }, {}, picker.token)
|
||||
add(context, `拒绝${name}`, response, expected(response))
|
||||
await reject(`拒绝${name}`, expected, () => context.requestOverseas('POST', `${PDA_RECEIPTS}/confirm`,
|
||||
{ ...firstBody, ...changes, idempotencyKey: key(context, `INVALID-RECEIPT-${encodeURIComponent(name)}`) }, {}, picker.token))
|
||||
}
|
||||
const earlyMerge = await context.requestOverseas('POST', '/api/pda/v1/last-leg/return-inventory/cartons/merge', {
|
||||
await reject('部分签收退货单禁止合箱', conflict, () => context.requestOverseas('POST', '/api/pda/v1/last-leg/return-inventory/cartons/merge', {
|
||||
returnNos: [returnNo], items: [{ skuCode: skuA, quantity: 1 }], length: 30, width: 20, height: 10,
|
||||
weight: 1, idempotencyKey: key(context, 'EARLY-MERGE'),
|
||||
}, {}, picker.token)
|
||||
add(context, '部分签收退货单禁止合箱', earlyMerge, conflict(earlyMerge))
|
||||
}, {}, picker.token))
|
||||
|
||||
const afterPartial = await overseas(context, merchantToken, 'GET', '客户回查部分签收状态',
|
||||
`${RETURN_ORDERS}/${encodeURIComponent(returnNo)}`)
|
||||
@@ -254,8 +273,8 @@ export async function run(context) {
|
||||
context.createdResources.phase4OverseasReturnReceipt.receiptBatchNos.push(firstBatchNo, secondBatchNo)
|
||||
context.createdResources.phase4OverseasReturnReceipt.cartonMarks.push(cartonA, cartonB)
|
||||
|
||||
const completedStart = await context.requestOverseas('POST', `${PDA_RECEIPTS}/start`, { returnNo }, {}, picker.token)
|
||||
add(context, '已完成退货单不再进入待签收流程', completedStart, notFound(completedStart))
|
||||
await reject('已完成退货单不再进入待签收流程', notFound,
|
||||
() => context.requestOverseas('POST', `${PDA_RECEIPTS}/start`, { returnNo }, {}, picker.token))
|
||||
const completedCandidates = await overseas(context, picker.token, 'POST', '完成后SKU候选自动清空',
|
||||
`${PDA_RECEIPTS}/sku-candidates`, { skuCode: skuB })
|
||||
context.assert(MODULE, '完成单不再出现在SKU候选', completedCandidates.response,
|
||||
@@ -371,10 +390,9 @@ export async function run(context) {
|
||||
context.assert(MODULE, '合箱重放复用同一新箱且不重复扣减', mergeReplay.response,
|
||||
success(mergeReplay.response) && nonBlank(merged.data?.cartonMark)
|
||||
&& id(mergeReplay.data?.cartonMark) === id(merged.data?.cartonMark), trace(mergeReplay.response), OPTIONS)
|
||||
const mergeConflict = await context.requestOverseas('POST',
|
||||
await reject('合箱同键异载荷返回冲突', conflict, () => context.requestOverseas('POST',
|
||||
'/api/pda/v1/last-leg/return-inventory/cartons/merge',
|
||||
{ ...mergeBody, weight: 3 }, {}, picker.token)
|
||||
add(context, '合箱同键异载荷返回冲突', mergeConflict, conflict(mergeConflict))
|
||||
{ ...mergeBody, weight: 3 }, {}, picker.token))
|
||||
|
||||
const beforeInvalid = await overseas(context, merchantToken, 'GET', '冻结合箱异常检查前库存快照',
|
||||
query(`${RETURN_INVENTORY}/cartons`, { page: 1, size: 200, returnNo }))
|
||||
@@ -386,9 +404,8 @@ export async function run(context) {
|
||||
['超可用库存', { items: [{ skuCode: skuA, quantity: 999 }] }, conflict],
|
||||
['不存在来源单', { returnNos: [`MISSING-${suffix}`] }, notFound],
|
||||
]) {
|
||||
const response = await context.requestOverseas('POST', '/api/pda/v1/last-leg/return-inventory/cartons/merge',
|
||||
{ ...mergeBody, ...changes, idempotencyKey: key(context, `INVALID-MERGE-${name}`) }, {}, picker.token)
|
||||
add(context, `合箱拒绝${name}`, response, expected(response))
|
||||
await reject(`合箱拒绝${name}`, expected, () => context.requestOverseas('POST', '/api/pda/v1/last-leg/return-inventory/cartons/merge',
|
||||
{ ...mergeBody, ...changes, idempotencyKey: key(context, `INVALID-MERGE-${encodeURIComponent(name)}`) }, {}, picker.token))
|
||||
}
|
||||
await overseas(context, picker.token, 'POST', '异常检查后再重放原合箱请求',
|
||||
'/api/pda/v1/last-leg/return-inventory/cartons/merge', mergeBody)
|
||||
@@ -419,6 +436,7 @@ export async function run(context) {
|
||||
trace(merchantSummary.response), OPTIONS)
|
||||
}
|
||||
|
||||
await runReturnBoundaries(context, { merchantToken, warehouse, picker, access })
|
||||
} finally {
|
||||
await disablePicker(context, picker, 91)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { rejected, classifyRace, stableState, verifyNoMutation } from './return-boundary-scenarios.mjs'
|
||||
import { run as createMerchant } from './merchant-qualification-fixture.mjs'
|
||||
|
||||
const response = (code = '200', http = 200) => ({ http, body: { code }, requestId: 'request', traceId: 'trace' })
|
||||
|
||||
test('拒绝断言只接受明确业务错误,不把网络故障、越权、成功当成参数拒绝', () => {
|
||||
for (const code of ['A0400', 'A0422']) assert.equal(rejected(response(code), [400, 422]), true)
|
||||
for (const code of ['200', 'A0403', 'A0409', 'S0500', 'S0503']) assert.equal(rejected(response(code), [400, 422]), false)
|
||||
for (const http of [0, 401, 403, 500, 502, 503]) assert.equal(rejected(response('error', http), [400, 422]), false)
|
||||
assert.equal(rejected({ ...response('A0409'), traceId: '' }, [409]), false)
|
||||
})
|
||||
|
||||
test('并发判定区分双成功、双失败、服务故障与唯一成功', () => {
|
||||
assert.deepEqual(classifyRace([response(), response('A0409')]), { successes: 1, conflicts: 1, unexpected: 0 })
|
||||
assert.deepEqual(classifyRace([response(), response()]), { successes: 2, conflicts: 0, unexpected: 0 })
|
||||
assert.deepEqual(classifyRace([response('A0409'), response('A0409')]), { successes: 0, conflicts: 2, unexpected: 0 })
|
||||
assert.deepEqual(classifyRace([response(), response('S0500', 500)]), { successes: 1, conflicts: 0, unexpected: 1 })
|
||||
})
|
||||
|
||||
test('库存快照保留订单、批次、SKU、来源与状态变化,仅忽略排序及展示时间', () => {
|
||||
const baseline = [{ returnNo: 'R1', receiptBatchNo: 'B1', quantity: 2, status: 'AVAILABLE', skuTypes: 1, items: [{ skuCode: 'A', quantity: 2 }], updatedAt: 'old' }]
|
||||
assert.deepEqual(stableState(baseline), stableState([{ ...baseline[0], updatedAt: 'new' }]))
|
||||
for (const change of [{ returnNo: 'R2' }, { receiptBatchNo: 'B2' }, { quantity: 1 }, { status: 'MERGED' }, { skuTypes: 0 }, { items: [] }]) {
|
||||
assert.notDeepEqual(stableState(baseline), stableState([{ ...baseline[0], ...change }]))
|
||||
}
|
||||
})
|
||||
|
||||
test('每个失败请求都立即前后回查,即使接口错误类型不符也检查落账', async () => {
|
||||
for (const code of ['A0409', '200', 'S0500']) {
|
||||
let quantity = 2
|
||||
const events = [], checks = []
|
||||
await verifyNoMutation({ name: '拒绝操作', snapshot: async () => { events.push('snapshot'); return { quantity } },
|
||||
action: async () => { events.push('action'); quantity -= 1; return response(code) },
|
||||
expected: result => rejected(result, [409]), check: (name, result, passed) => checks.push(passed) })
|
||||
assert.deepEqual(events, ['snapshot', 'action', 'snapshot'])
|
||||
assert.deepEqual(checks, [code === 'A0409', false])
|
||||
}
|
||||
})
|
||||
|
||||
test('缺少快照不得执行危险动作或伪造未落账通过', async () => {
|
||||
let called = false
|
||||
await assert.rejects(verifyNoMutation({ snapshot: async () => { throw new Error('snapshot unavailable') },
|
||||
action: async () => { called = true }, check: () => {}, expected: () => true }), /snapshot unavailable/)
|
||||
assert.equal(called, false)
|
||||
})
|
||||
|
||||
test('扩展由退货主模块调用且不进入换标出库,保留各并发响应诊断及清理', async () => {
|
||||
const [source, main] = await Promise.all(['return-boundary-scenarios.mjs', 'phase4-overseas-return-receipt.mjs']
|
||||
.map(file => readFile(new URL(file, import.meta.url), 'utf8')))
|
||||
assert.match(main, /await runReturnBoundaries\(context, \{ merchantToken, warehouse, picker, access \}\)/)
|
||||
for (const name of ['跨客户隔离', '跨仓隔离', '并发签收', '并发合箱', '多单全量合箱与来源追溯', '多候选与输入边界']) assert.ok(source.includes(`scenario('${name}'`))
|
||||
assert.match(source, /recordRace\('并发签收'/)
|
||||
assert.match(source, /recordRace\('并发合箱'/)
|
||||
assert.match(source, /recordRace\('并发部分合箱'/)
|
||||
assert.match(source, /concurrencyRounds: 3/)
|
||||
assert.match(source, /publishEnvironment: false/)
|
||||
assert.match(source, /finally[\s\S]*disablePicker/)
|
||||
assert.match(source, /executed: false, passed: false/)
|
||||
assert.doesNotMatch(source, /\bfetch\s*\(|process\.env|relabel-applications|outbound-orders|outbound-tasks|\b(?:INSERT|UPDATE|SELECT)\s/i)
|
||||
})
|
||||
|
||||
test('第二客户准备运行真实fixture编排但不替换主客户内存与环境凭据', async () => {
|
||||
const names = ['MERCHANT_TEST_USERNAME', 'MERCHANT_TEST_PASSWORD']
|
||||
const saved = names.map(name => process.env[name])
|
||||
try {
|
||||
process.env.MERCHANT_TEST_USERNAME = 'primary-user'
|
||||
process.env.MERCHANT_TEST_PASSWORD = 'primary-placeholder'
|
||||
const primary = { context: { merchantQualificationFixture: { tenantId: 'A' } }, createdResources: {} }
|
||||
const secondary = Object.create(primary)
|
||||
Object.assign(secondary, { marker: 'OFFLINE-20260915-B', options: {}, context: { ...primary.context }, createdResources: {},
|
||||
findAvailablePhone: async () => '00000000000',
|
||||
assert: (module, name, result, condition) => assert.ok(condition, name),
|
||||
apiSuccess: (module, name, result) => { assert.equal(result.body.code, '200', name); return result.body.data },
|
||||
requestWithBody: async () => ({ body: { code: '200', data: {} } }),
|
||||
request: async (method, path) => {
|
||||
let data
|
||||
if (path.endsWith('/iam/tenants')) data = { id: 'B', administratorUsername: 'secondary-user', temporaryPassword: 'secondary-placeholder' }
|
||||
else if (path.endsWith('/submit-review')) data = { status: 'PENDING' }
|
||||
else if (path.endsWith('/iam/reviews')) data = [{ id: 'review-B', tenantId: 'B' }]
|
||||
else if (path.endsWith('/dimension-initialization')) data = { status: 'READY' }
|
||||
else if (path.endsWith('/approve')) data = {}
|
||||
else if (path.endsWith('/qualifications')) data = ['BUSINESS_LICENSE', 'LEGAL_PERSON_ID'].map(qualificationType => ({ qualificationType, status: 'APPROVED' }))
|
||||
else throw new Error(`Unexpected fixture request: ${method} ${path}`)
|
||||
return { http: 200, body: { code: '200', data } }
|
||||
} })
|
||||
await createMerchant(secondary, { publishEnvironment: false })
|
||||
assert.equal(primary.context.merchantQualificationFixture.tenantId, 'A')
|
||||
assert.equal(secondary.context.merchantQualificationFixture.tenantId, 'B')
|
||||
assert.equal(process.env.MERCHANT_TEST_USERNAME, 'primary-user')
|
||||
assert.equal(process.env.MERCHANT_TEST_PASSWORD, 'primary-placeholder')
|
||||
assert.ok(!JSON.stringify(secondary.createdResources).includes('secondary-placeholder'))
|
||||
await createMerchant(secondary)
|
||||
assert.equal(process.env.MERCHANT_TEST_USERNAME, 'secondary-user')
|
||||
} finally {
|
||||
names.forEach((name, index) => { if (saved[index] === undefined) delete process.env[name]; else process.env[name] = saved[index] })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,280 @@
|
||||
import { run as createMerchant } from './merchant-qualification-fixture.mjs'
|
||||
import { createPicker, disablePicker } from './phase4-overseas-pda.mjs'
|
||||
|
||||
const MODULE = 'phase4-overseas-return-receipt'
|
||||
const OPTIONS = { scenario: 'return-isolation-concurrency-boundaries', deploymentBlocker: true }
|
||||
const ORDERS = '/api/merchant/v1/last-leg/return-orders'
|
||||
const INVENTORY = '/api/merchant/v1/last-leg/return-inventory'
|
||||
const RECEIPTS = '/api/pda/v1/last-leg/return-receipts'
|
||||
const MERGE = '/api/pda/v1/last-leg/return-inventory/cartons/merge'
|
||||
const rows = data => Array.isArray(data?.records) ? data.records : Array.isArray(data) ? data : []
|
||||
const ok = response => response?.http === 200 && String(response?.body?.code) === '200'
|
||||
export function rejected(response, statuses) {
|
||||
const code = String(response?.body?.code || '')
|
||||
return Boolean(response?.requestId && response?.traceId)
|
||||
&& statuses.some(status => response?.http === status
|
||||
|| (response?.http === 200 && [String(status), `A0${status}`].includes(code)))
|
||||
}
|
||||
export function classifyRace(responses) {
|
||||
return { successes: responses.filter(ok).length,
|
||||
conflicts: responses.filter(response => rejected(response, [409])).length,
|
||||
unexpected: responses.filter(response => !ok(response) && !rejected(response, [409])).length }
|
||||
}
|
||||
export function stableState(value) {
|
||||
if (Array.isArray(value)) return value.map(stableState).sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)))
|
||||
if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort()
|
||||
.filter(key => !['createdAt', 'updatedAt', 'receivedAt', 'generatedAt'].includes(key))
|
||||
.map(key => [key, stableState(value[key])]))
|
||||
return value
|
||||
}
|
||||
|
||||
export async function verifyNoMutation({ snapshot, action, check, name, expected }) {
|
||||
const before = await snapshot()
|
||||
const response = await action()
|
||||
check(name, response, expected(response))
|
||||
const after = await snapshot()
|
||||
check(`${name}后立即核对订单批次箱及SKU均不变`, response,
|
||||
JSON.stringify(stableState(before)) === JSON.stringify(stableState(after)))
|
||||
return response
|
||||
}
|
||||
|
||||
export async function runReturnBoundaries(context, { merchantToken, warehouse, picker, access }) {
|
||||
let sequence = 0
|
||||
const prefix = `RBX-${context.marker.replace(/[^A-Z0-9]/gi, '').slice(-18)}`
|
||||
const key = name => `${prefix}-${name}-${++sequence}`
|
||||
const extraPickers = []
|
||||
let failures = 0
|
||||
const resources = context.createdResources.returnBoundaryScenarios = { returnNos: [], cartonMarks: [], cases: [], concurrencyRounds: 3 }
|
||||
function check(name, response, passed, evidence = '') {
|
||||
if (!passed) failures += 1
|
||||
context.assert(MODULE, name, response, Boolean(passed), evidence, OPTIONS)
|
||||
}
|
||||
const request = (token, method, path, body) => context.requestOverseas(method, path, body, {}, token)
|
||||
async function good(token, method, name, path, body) {
|
||||
const response = await request(token, method, path, body)
|
||||
check(name, response, ok(response))
|
||||
if (!ok(response)) throw new Error(`${name}: HTTP=${response?.http}, code=${response?.body?.code}`)
|
||||
return response.body.data
|
||||
}
|
||||
const url = (path, values) => `${path}?${new URLSearchParams(values)}`
|
||||
async function newOrder(name, sku, quantity, token = merchantToken, warehouseId = warehouse.id) {
|
||||
const data = await good(token, 'POST', `准备${name}退货单`, ORDERS, {
|
||||
overseasWarehouseId: warehouseId, warehouseContactName: '退货边界验收', warehouseContactPhone: '00000000000',
|
||||
customerReferenceNo: `${prefix}-${name}`, needRelabel: false, idempotencyKey: key('ORDER'),
|
||||
items: [{ skuCode: sku, productNameZh: '边界验收商品', expectedQuantity: quantity }],
|
||||
})
|
||||
resources.returnNos.push(data.order.returnNo)
|
||||
return { returnNo: data.order.returnNo, sku, quantity, token, warehouseId }
|
||||
}
|
||||
function receiptBody(order, quantity = order.quantity, idempotencyKey = key('RECEIPT')) {
|
||||
return { returnNo: order.returnNo, startedBy: 'RETURN_NO', items: [{ skuCode: order.sku, receivedQuantity: quantity }], idempotencyKey }
|
||||
}
|
||||
async function receive(order, operator = picker) {
|
||||
const data = await good(operator.token, 'POST', '准备边界场景实收库存', `${RECEIPTS}/confirm`, receiptBody(order))
|
||||
order.cartonMark = data.cartonMark
|
||||
order.batch = data.receiptBatchNo
|
||||
resources.cartonMarks.push(data.cartonMark)
|
||||
return data
|
||||
}
|
||||
function mergeBody(orders, sku, quantity, idempotencyKey = key('MERGE')) {
|
||||
return { returnNos: orders.map(order => order.returnNo), items: [{ skuCode: sku, quantity }],
|
||||
length: 30, width: 20, height: 10, weight: 1.2, idempotencyKey }
|
||||
}
|
||||
async function snapshot(orders) {
|
||||
const result = []
|
||||
for (const order of orders) {
|
||||
const detail = await good(order.token, 'GET', '读取订单数量与签收批次快照', `${ORDERS}/${order.returnNo}`)
|
||||
const cartons = await good(order.token, 'GET', '读取全部状态箱快照', url(`${INVENTORY}/cartons`, { returnNo: order.returnNo, page: 1, size: 200 }))
|
||||
const items = []
|
||||
for (const carton of rows(cartons)) items.push(await good(order.token, 'GET', '读取箱内SKU快照', `${INVENTORY}/cartons/${carton.cartonMark}`))
|
||||
const summary = await good(order.token, 'GET', '读取SKU数量及所在箱数快照', url(`${INVENTORY}/summary`, { skuCode: order.sku, overseasWarehouseId: order.warehouseId, page: 1, size: 200 }))
|
||||
result.push({ detail, cartons, items, summary })
|
||||
}
|
||||
return result
|
||||
}
|
||||
async function unchanged(name, orders, action, statuses) {
|
||||
return verifyNoMutation({ snapshot: () => snapshot(orders), action, check, name,
|
||||
expected: response => rejected(response, statuses) })
|
||||
}
|
||||
async function scenario(name, fn) {
|
||||
const before = failures
|
||||
try { await fn(); resources.cases.push({ name, executed: true, passed: failures === before }) }
|
||||
catch (error) { check(`${name}场景未完成`, null, false, error.message); resources.cases.push({ name, executed: false, passed: false }) }
|
||||
}
|
||||
function recordRace(name, responses) {
|
||||
for (const [index, response] of responses.entries()) {
|
||||
check(`${name}请求${index + 1}仅成功或业务冲突`, response, ok(response) || rejected(response, [409]))
|
||||
}
|
||||
return classifyRace(responses)
|
||||
}
|
||||
try {
|
||||
// The second customer owns its own context and never replaces primary credentials.
|
||||
const secondary = Object.create(context)
|
||||
secondary.marker = `${context.marker}-B`
|
||||
secondary.options = { ...context.options, customerName: '' }
|
||||
secondary.context = { ...context.context }
|
||||
secondary.createdResources = {}
|
||||
await createMerchant(secondary, { publishEnvironment: false })
|
||||
const fixtureB = secondary.context.merchantQualificationFixture
|
||||
resources.secondaryCustomer = secondary.createdResources.merchantQualificationFixture
|
||||
const loginB = await context.request('POST', '/api/auth/login', { loginId: fixtureB.username, password: fixtureB.password }, '')
|
||||
check('第二隔离客户登录取得海外令牌', loginB, ok(loginB) && Boolean(loginB.body?.data?.overseasAccessToken))
|
||||
if (!ok(loginB) || !loginB.body?.data?.overseasAccessToken) return
|
||||
const tokenB = loginB.body.data.overseasAccessToken
|
||||
const warehouseResponse = await context.request('GET', '/api/platform/v1/warehouse-admin?tenantId=0&page=1&size=200&capabilityCode=RECEIVING&warehouseType=OVERSEAS&status=ACTIVE')
|
||||
const otherWarehouse = rows(warehouseResponse?.body?.data).find(row => String(row.id) !== String(warehouse.id))
|
||||
check('跨仓场景具备第二个真实有效海外仓', warehouseResponse, ok(warehouseResponse) && Boolean(otherWarehouse))
|
||||
const sameWarehousePicker = await createPicker(context, 93, warehouse.id, access)
|
||||
if (sameWarehousePicker?.membershipId) extraPickers.push([sameWarehousePicker, 93])
|
||||
check('并发场景具备第二个同仓操作员', null, Boolean(sameWarehousePicker?.token))
|
||||
|
||||
await scenario('跨客户隔离', async () => {
|
||||
const a = await newOrder('客户A', `${prefix}-ISO-A`, 1)
|
||||
const b = await newOrder('客户B', `${prefix}-ISO-B`, 1, tokenB)
|
||||
await receive(a); await receive(b)
|
||||
for (const [name, path] of [
|
||||
['退货单详情', `${ORDERS}/${b.returnNo}`],
|
||||
['箱明细', `${INVENTORY}/cartons/${b.cartonMark}`],
|
||||
['箱标签', `${INVENTORY}/cartons/${b.cartonMark}/label`],
|
||||
]) await unchanged(`客户A禁止读取客户B${name}`, [a, b], () => request(merchantToken, 'GET', path), [403, 404])
|
||||
for (const [name, path, filter] of [
|
||||
['退货单列表', ORDERS, { returnNo: b.returnNo }],
|
||||
['箱列表', `${INVENTORY}/cartons`, { returnNo: b.returnNo }],
|
||||
['SKU汇总', `${INVENTORY}/summary`, { skuCode: b.sku }],
|
||||
]) {
|
||||
const data = await good(merchantToken, 'GET', `客户A查询客户B${name}`, url(path, { ...filter, customerId: fixtureB.tenantId, page: 1, size: 20 }))
|
||||
check(`伪造客户筛选不能泄露${name}`, null, Number(data.total) === 0 && rows(data).length === 0)
|
||||
}
|
||||
await unchanged('不同客户完成单禁止合箱', [a, b], () => request(picker.token, 'POST', MERGE, mergeBody([a, b], a.sku, 1)), [409])
|
||||
})
|
||||
|
||||
await scenario('跨仓隔离', async () => {
|
||||
if (!otherWarehouse) throw new Error('缺少第二仓,不能跳过跨仓验证')
|
||||
const foreignPicker = await createPicker(context, 94, String(otherWarehouse.id), access)
|
||||
if (foreignPicker?.membershipId) extraPickers.push([foreignPicker, 94])
|
||||
if (!foreignPicker?.token) throw new Error('第二仓操作员未就绪')
|
||||
const order = await newOrder('异仓', `${prefix}-WAREHOUSE`, 1, merchantToken, String(otherWarehouse.id))
|
||||
await unchanged('非绑定仓禁止开始签收', [order], () => request(picker.token, 'POST', `${RECEIPTS}/start`, { returnNo: order.returnNo }), [404])
|
||||
await unchanged('非绑定仓禁止确认签收', [order], () => request(picker.token, 'POST', `${RECEIPTS}/confirm`, receiptBody(order)), [404])
|
||||
const candidates = await good(picker.token, 'POST', '本仓查询异仓SKU候选', `${RECEIPTS}/sku-candidates`, { skuCode: order.sku })
|
||||
check('SKU候选不串仓', null, Array.isArray(candidates.candidates) && candidates.candidates.length === 0)
|
||||
await receive(order, foreignPicker)
|
||||
await unchanged('非绑定仓禁止读取箱标签', [order], () => request(picker.token, 'GET', `/api/pda/v1/last-leg/return-inventory/cartons/${order.cartonMark}/label`), [404])
|
||||
await unchanged('非绑定仓禁止合箱', [order], () => request(picker.token, 'POST', MERGE, mergeBody([order], order.sku, 1)), [404])
|
||||
})
|
||||
|
||||
await scenario('并发签收', async () => {
|
||||
if (!sameWarehousePicker?.token) throw new Error('第二同仓操作员未就绪')
|
||||
for (let round = 1; round <= resources.concurrencyRounds; round += 1) {
|
||||
const order = await newOrder(`并发签收${round}`, `${prefix}-RACE-RECEIPT-${round}`, 1)
|
||||
const responses = await Promise.all([picker, sameWarehousePicker].map(operator => request(operator.token, 'POST', `${RECEIPTS}/confirm`, receiptBody(order))))
|
||||
const outcome = recordRace('并发签收', responses)
|
||||
check('两个操作员抢收最后一件仅一次成功', responses[0], outcome.successes === 1 && outcome.conflicts === 1 && outcome.unexpected === 0, JSON.stringify(outcome))
|
||||
const [state] = await snapshot([order])
|
||||
check('并发签收仅一批一箱一件', null, state.detail.receipts.length === 1 && Number(state.detail.order.receivedQuantity) === 1
|
||||
&& Number(state.cartons.total) === 1 && Number(rows(state.summary)[0]?.availableQuantity) === 1)
|
||||
const replayOrder = await newOrder(`并发同键${round}`, `${prefix}-SAME-KEY-${round}`, 1)
|
||||
const body = receiptBody(replayOrder)
|
||||
const sameKeyResponses = await Promise.all([1, 2].map(() => request(picker.token, 'POST', `${RECEIPTS}/confirm`, body)))
|
||||
for (const [index, response] of sameKeyResponses.entries()) check(`同键并发请求${index + 1}成功`, response, ok(response))
|
||||
check('同键并发签收返回同一成功结果', sameKeyResponses[0], sameKeyResponses.every(ok)
|
||||
&& sameKeyResponses[0].body.data.receiptNo === sameKeyResponses[1].body.data.receiptNo)
|
||||
const beforeRetry = await snapshot([replayOrder])
|
||||
check('同键并发仅生成一批一箱一件', null, beforeRetry[0].detail.receipts.length === 1
|
||||
&& Number(beforeRetry[0].detail.order.receivedQuantity) === 1 && Number(beforeRetry[0].cartons.total) === 1
|
||||
&& rows(beforeRetry[0].summary).some(row => row.skuCode === replayOrder.sku && Number(row.availableQuantity) === 1))
|
||||
const retry = await request(picker.token, 'POST', `${RECEIPTS}/confirm`, body)
|
||||
const accepted = sameKeyResponses.find(ok)?.body.data
|
||||
check('模拟成功响应丢失后原键重试复用原签收', retry, ok(retry) && accepted
|
||||
&& ['receiptNo', 'receiptBatchNo', 'cartonMark'].every(field => retry.body.data[field] === accepted[field]))
|
||||
check('重试后签收和库存不重复', retry, JSON.stringify(stableState(beforeRetry)) === JSON.stringify(stableState(await snapshot([replayOrder]))))
|
||||
}
|
||||
})
|
||||
|
||||
await scenario('并发合箱', async () => {
|
||||
if (!sameWarehousePicker?.token) throw new Error('第二同仓操作员未就绪')
|
||||
for (let round = 1; round <= resources.concurrencyRounds; round += 1) {
|
||||
const order = await newOrder(`并发合箱${round}`, `${prefix}-RACE-MERGE-${round}`, 1)
|
||||
await receive(order)
|
||||
const responses = await Promise.all([picker, sameWarehousePicker].map(operator => request(operator.token, 'POST', MERGE, mergeBody([order], order.sku, 1))))
|
||||
const outcome = recordRace('并发合箱', responses)
|
||||
check('两个操作员抢合最后一件仅一次成功', responses[0], outcome.successes === 1 && outcome.conflicts === 1 && outcome.unexpected === 0, JSON.stringify(outcome))
|
||||
const state = await snapshot([order])
|
||||
check('并发合箱无负数且SKU数量箱数均为一', null, rows(state[0].summary).some(row => row.skuCode === order.sku && Number(row.availableQuantity) === 1 && Number(row.cartonQuantity) === 1)
|
||||
&& Number(state[0].cartons.total) === 2
|
||||
&& rows(state[0].cartons).filter(row => row.status === 'AVAILABLE' && Number(row.totalQuantity) === 1).length === 1
|
||||
&& rows(state[0].cartons).filter(row => row.status === 'MERGED' && Number(row.totalQuantity) === 0).length === 1)
|
||||
const partial = await newOrder(`并发部分合箱${round}`, `${prefix}-RACE-PARTIAL-${round}`, 3)
|
||||
await receive(partial)
|
||||
const partialResponses = await Promise.all([picker, sameWarehousePicker].map(operator => request(operator.token, 'POST', MERGE, mergeBody([partial], partial.sku, 2))))
|
||||
const partialOutcome = recordRace('并发部分合箱', partialResponses)
|
||||
check('三件库存同时各取两件仅一次成功', partialResponses[0], partialOutcome.successes === 1
|
||||
&& partialOutcome.conflicts === 1 && partialOutcome.unexpected === 0, JSON.stringify(partialOutcome))
|
||||
const [partialState] = await snapshot([partial])
|
||||
check('并发部分合箱保持原箱一件新箱两件总量三件', null, Number(partialState.cartons.total) === 2
|
||||
&& rows(partialState.cartons).every(row => row.status === 'AVAILABLE')
|
||||
&& rows(partialState.cartons).some(row => row.cartonMark === partial.cartonMark && Number(row.totalQuantity) === 1)
|
||||
&& rows(partialState.cartons).some(row => row.cartonMark !== partial.cartonMark && Number(row.totalQuantity) === 2)
|
||||
&& rows(partialState.summary).some(row => row.skuCode === partial.sku && Number(row.availableQuantity) === 3 && Number(row.cartonQuantity) === 2))
|
||||
}
|
||||
})
|
||||
|
||||
await scenario('多单全量合箱与来源追溯', async () => {
|
||||
const sku = `${prefix}-MULTI`
|
||||
const a = await newOrder('多单A', sku, 2)
|
||||
const b = await newOrder('多单B', sku, 3)
|
||||
const unused = await newOrder('未取货来源', `${prefix}-UNUSED`, 1)
|
||||
await receive(a); await receive(b); await receive(unused)
|
||||
const result = await good(picker.token, 'POST', '同客户多单全量合箱', MERGE, mergeBody([a, b, unused], sku, 5))
|
||||
resources.cartonMarks.push(result.cartonMark)
|
||||
const target = await good(merchantToken, 'GET', '回查多单目标箱', `${INVENTORY}/cartons/${result.cartonMark}`)
|
||||
check('多单同SKU合并为一行五件', null, Number(target.totalQuantity) === 5 && target.items.length === 1 && Number(target.items[0].quantity) === 5)
|
||||
check('来源单和批次仅含实际消耗且去重', null,
|
||||
JSON.stringify(String(target.sourceReturnNo).split(',').sort()) === JSON.stringify([a.returnNo, b.returnNo].sort())
|
||||
&& JSON.stringify(String(target.sourceBatchNo).split(',').sort()) === JSON.stringify([a.batch, b.batch].sort()))
|
||||
for (const source of [a, b]) {
|
||||
const carton = await good(merchantToken, 'GET', '回查已耗尽来源箱', `${INVENTORY}/cartons/${source.cartonMark}`)
|
||||
check('耗尽来源箱为MERGED且件数SKU数清零', null, carton.status === 'MERGED' && Number(carton.totalQuantity) === 0 && Number(carton.skuTypes) === 0 && carton.items.length === 0)
|
||||
}
|
||||
const untouched = await good(merchantToken, 'GET', '回查未取货来源箱', `${INVENTORY}/cartons/${unused.cartonMark}`)
|
||||
check('选中但未取货来源保持可用一件', null, untouched.status === 'AVAILABLE' && Number(untouched.totalQuantity) === 1)
|
||||
const summary = await good(merchantToken, 'GET', '回查全量合箱SKU汇总', url(`${INVENTORY}/summary`, { skuCode: sku, page: 1, size: 20 }))
|
||||
check('全量合箱后仍五件但仅一个可用箱', null, rows(summary).some(row => row.skuCode === sku && Number(row.availableQuantity) === 5 && Number(row.cartonQuantity) === 1))
|
||||
})
|
||||
|
||||
await scenario('多候选与输入边界', async () => {
|
||||
const sku = `${prefix}-CANDIDATE`
|
||||
const a = await newOrder('候选A', sku, 1)
|
||||
const b = await newOrder('候选B', sku, 2)
|
||||
const found = await good(picker.token, 'POST', '同SKU查询两张待签收单', `${RECEIPTS}/sku-candidates`, { skuCode: sku })
|
||||
check('同SKU两候选各自保留预报数量', null, found.candidates.length === 2
|
||||
&& found.candidates.some(row => row.returnNo === a.returnNo && Number(row.skuExpectedQuantity) === 1 && Number(row.skuReceivedQuantity) === 0)
|
||||
&& found.candidates.some(row => row.returnNo === b.returnNo && Number(row.skuExpectedQuantity) === 2 && Number(row.skuReceivedQuantity) === 0))
|
||||
await receive(a)
|
||||
const remaining = await good(picker.token, 'POST', '一单完成后回查同SKU候选', `${RECEIPTS}/sku-candidates`, { skuCode: sku })
|
||||
check('完成单移除而未收单数量不变', null, remaining.candidates.length === 1
|
||||
&& remaining.candidates[0].returnNo === b.returnNo && Number(remaining.candidates[0].skuExpectedQuantity) === 2
|
||||
&& Number(remaining.candidates[0].skuReceivedQuantity) === 0)
|
||||
for (const [name, patch] of [
|
||||
['已存在箱号', { cartonMark: a.cartonMark }],
|
||||
['缺少幂等键', { idempotencyKey: '' }],
|
||||
['超长快递单号', { trackingNo: 'X'.repeat(129) }],
|
||||
]) await unchanged(`拒绝${name}`, [a, b], () => request(picker.token, 'POST', `${RECEIPTS}/confirm`, { ...receiptBody(b, 1), ...patch }), name === '已存在箱号' ? [409] : [400, 422])
|
||||
await receive(b)
|
||||
await unchanged('拒绝合箱重复SKU', [a, b], () => request(picker.token, 'POST', MERGE,
|
||||
{ ...mergeBody([a, b], sku, 1), items: [{ skuCode: sku, quantity: 1 }, { skuCode: sku, quantity: 1 }] }), [400, 422])
|
||||
await unchanged('拒绝小数合箱数量', [a, b], () => request(picker.token, 'POST', MERGE,
|
||||
mergeBody([a, b], sku, 1.5)), [400, 422])
|
||||
// Isolate fractional receipt from later fixtures: a server defect must not
|
||||
// consume another case's pending quantity and hide its verification.
|
||||
const fractional = await newOrder('小数实收', `${prefix}-FRACTIONAL`, 2)
|
||||
await unchanged('拒绝小数实收', [fractional], () => request(picker.token, 'POST', `${RECEIPTS}/confirm`,
|
||||
receiptBody(fractional, 1.5)), [400, 422])
|
||||
})
|
||||
} finally {
|
||||
for (const [operator, index] of extraPickers.reverse()) {
|
||||
try { await disablePicker(context, operator, index) }
|
||||
catch (error) { check(`停用临时PDA${index}失败`, null, false, error.message) }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user