From 4b671bb0ab1c56793bfd8fc11c8a83798bfbae29 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 15 Sep 2026 18:17:06 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mapper/DropshipPalletizationMapper.java | 12 ++++ .../mapper/SmartLoadingCandidateMapper.java | 6 +- .../firstleg/service/SmartLoadingService.java | 16 ++++- .../main/resources/openapi/firstleg-v1.yaml | 5 +- ...artLoadingCandidateMapperContractTest.java | 19 ++++++ .../service/SmartLoadingServiceTest.java | 24 ++++++++ .../service/LastlegOrderNoGenerator.java | 26 ++++++-- .../openapi/overseas-business-v1.yaml | 2 +- .../service/ReturnCartonBarcodeTest.java | 2 +- .../service/ReturnBusinessNumberTest.java | 59 +++++++++++++++++++ scripts/admin-test-env-modules/README.md | 2 + ...eturn-boundary-scenarios-contract.test.mjs | 9 +++ .../return-boundary-scenarios.mjs | 7 ++- 13 files changed, 174 insertions(+), 15 deletions(-) create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/service/ReturnBusinessNumberTest.java diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java index 3376c9a59..d34a32e41 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java @@ -2,6 +2,7 @@ package com.baiyue.firstleg.mapper; import com.baiyue.firstleg.dto.DropshipPalletizationDtos.PendingTask; import com.baiyue.firstleg.dto.SmartLoadingDtos.PalletCandidate; +import com.baiyue.firstleg.dto.SmartLoadingDtos.GroupMemberCandidate; import lombok.Getter; import lombok.Setter; import org.apache.ibatis.annotations.*; @@ -254,6 +255,17 @@ public interface DropshipPalletizationMapper { """) List printSkuRows(@Param("palletId") long palletId); + @Select(""" + SELECT o.tenant_id accountId,o.id inboundId,o.order_id orderId,o.order_no orderNo, + o.inbound_code inboundCode,o.customer_name customerName,COUNT(DISTINCT c.id) availablePackageCount, + o.process_status orderStatusSnapshot,o.expected_arrival_at expectedArrivalAt + FROM fl_dropship_pallet_carton c + JOIN fl_inbound_order o ON o.id=c.inbound_id AND o.tenant_id=c.tenant_id + WHERE c.pallet_id=#{palletId} + GROUP BY o.tenant_id,o.id,o.order_id,o.order_no,o.inbound_code,o.customer_name,o.process_status,o.expected_arrival_at + ORDER BY o.inbound_code,o.id + """) List palletInboundMembers(@Param("palletId") long palletId); + @Select(""" SELECT p.id pallet_id,p.pallet_no,'SEALED_PALLET' unit_type,TRUE indivisible,COUNT(DISTINCT c.id) carton_count, (SELECT COUNT(DISTINCT sku.sku_code) FROM fl_dropship_pallet_carton pc diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java index a7aee84f2..fbf5a11c9 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/SmartLoadingCandidateMapper.java @@ -142,7 +142,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba """) Page globalCandidateUnits(Page page, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java index b9ccdcf8a..fd45e44ee 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -103,7 +103,7 @@ public class SmartLoadingService { Map> grouped = candidates(bookingId, warehouseId).stream() .collect(java.util.stream.Collectors.groupingBy(Candidate::inboundId, LinkedHashMap::new, java.util.stream.Collectors.toList())); - return grouped.values().stream().flatMap(details -> inboundCandidates(details).stream()).toList(); + return uniqueCandidateUnits(grouped.values().stream().flatMap(details -> inboundCandidates(details).stream()).toList()); } private List recommendationCandidates(ContainerBookingEntity booking, @@ -289,7 +289,7 @@ public class SmartLoadingService { first.unitNetWeightKg(), pallet.cartonCount()==null||pallet.cartonCount()==0?null:pallet.volumeCbm().divide(BigDecimal.valueOf(pallet.cartonCount()),6,RoundingMode.HALF_UP), pallet.volumeCbm(),"SEALED_PALLET",pallet.palletId(),pallet.palletNo(),true, first.coLoadGroupId(), first.groupNo(), first.memberCount(),first.groupTotalBoxes(), false, !StringUtils.hasText(palletBlock), palletBlock, - first.orderStatusSnapshot(),first.expectedArrivalAt(),List.of()); + first.orderStatusSnapshot(),first.expectedArrivalAt(),dropshipPallets.palletInboundMembers(pallet.palletId())); }).toList(); return List.of(new InboundCandidate(first.accountId(), first.inboundId(), first.orderId(), first.orderNo(), first.inboundCode(), first.warehouseId(), first.channelCode(), first.channelName(), @@ -367,7 +367,17 @@ public class SmartLoadingService { } private static String unitKey(Long groupId, Long accountId, Long inboundId, Long palletId) { - return groupId == null ? "I|" + accountId + '|' + inboundId + '|' + palletId : "G|" + groupId; + return groupId != null ? "G|" + groupId : palletId != null ? "P|" + palletId : "I|" + accountId + '|' + inboundId + '|' + palletId; + } + + private static List uniqueCandidateUnits(List candidates) { + Map units = new LinkedHashMap<>(); + for (InboundCandidate candidate : candidates) { + String key = candidate.palletId() != null ? "P|" + candidate.palletId() + : "I|" + candidate.accountId() + '|' + candidate.inboundId(); + units.putIfAbsent(key, candidate); + } + return List.copyOf(units.values()); } private static BigDecimal weightedAverage(List members, diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index a47fca32c..5b139db5c 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -571,7 +571,8 @@ paths: description: >- 需要 platform:firstleg:smart-loading:list 权限。查询不绑定柜次;渠道、仓库、客户、入仓码、 订单创建日期、预计到货时间和订单状态共同作用于全局候选。每个普通入库单返回一行;同柜不可拆组作为一个 - 分页单元返回,groupMembers 包含完整成员,不会跨页或因单个成员命中搜索条件而拆散。 + 分页单元返回;一件代发每个已封托托盘仅返回一行,合托不按入仓码重复分页。 + groupMembers 包含同柜组或托盘的完整成员,不会跨页或因单个成员命中搜索条件而拆散;托盘行箱数为整托箱数,成员箱数为本票在本托的实际箱数。 平均单箱毛重、净重按整票实测重量除以总箱数,平均单箱体积按剩余规格数量加权。 orderStatuses 可重复传参且最多四项;未传或空数组表示 PENDING_REVIEW、WAIT_MEASURE、WAIT_SPLIT、 LOADING 全部未完成状态,已完成装柜永不返回。状态映射为:Order SUBMITTED=PENDING_REVIEW; @@ -2443,7 +2444,7 @@ components: blockReason: {type: string, nullable: true} groupMembers: type: array - description: 同柜组聚合行的完整成员;普通候选为空数组 + description: 同柜组或封托托盘的完整入仓成员;SEALED_PALLET 行按托盘唯一分页,成员 availablePackageCount 为该入仓码在本托的箱数,行 availablePackageCount 为整托总箱数。搜索命中任一成员仍返回整托完整组成;普通箱件候选为空数组。 items: type: object required: [accountId, inboundId, orderId, orderNo, inboundCode, customerName, availablePackageCount] diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java index 1cf46c76f..d8e03ce3a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingCandidateMapperContractTest.java @@ -20,6 +20,21 @@ import java.time.LocalDateTime; import static org.assertj.core.api.Assertions.assertThat; class SmartLoadingCandidateMapperContractTest { + @Test + void sharedPalletPaginationGroupsByPalletRatherThanInbound() throws Exception { + String sql = String.join(" ", SmartLoadingCandidateMapper.class + .getDeclaredMethod("globalCandidateUnits", Page.class, InboundCandidateQuery.class, Set.class) + .getAnnotation(Select.class).value()); + assertThat(sql).contains("MIN(CASE WHEN current_group.id IS NULL THEN eligible.inboundId END) inboundId") + .contains("CASE WHEN current_group.id IS NULL AND eligible.palletId IS NULL THEN eligible.inboundId END") + .contains("sortInboundId,palletId"); + String members = String.join(" ", com.baiyue.firstleg.mapper.DropshipPalletizationMapper.class + .getDeclaredMethod("palletInboundMembers", long.class).getAnnotation(Select.class).value()); + assertThat(members).contains("COUNT(DISTINCT c.id) availablePackageCount") + .contains("WHERE c.pallet_id=#{palletId}") + .contains("GROUP BY o.tenant_id,o.id") + .doesNotContain("#{inboundId}"); + } @Test void candidateHasOnlyTheCanonicalMybatisConstructor() { assertThat(Candidate.class.getDeclaredConstructors()).singleElement() @@ -165,6 +180,10 @@ class SmartLoadingCandidateMapperContractTest { .contains("eligible.candidateStatus='WAIT_SPLIT'") .contains("MAX(CASE WHEN LOWER(eligible.inboundCode)") .contains("MAX(CASE WHEN eligible.accountId="); + assertThat(renderedUnits.indexOf("MAX(CASE WHEN LOWER(eligible.inboundCode)")) + .as("inbound search must match any member after grouping, without cutting pallet composition") + .isGreaterThan(renderedUnits.indexOf("HAVING")); + assertThat(renderedUnits).contains("LIKE CONCAT('%',LOWER("); Method details = SmartLoadingCandidateMapper.class.getDeclaredMethod( "globalCandidatesByUnits", List.class); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java index 7ed1f0e21..108e86c59 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -261,6 +261,30 @@ class SmartLoadingServiceTest { }); } + @Test + void sharedPalletIsOneUnitWithExactInboundComposition() { + Candidate dropship = withStatus(withChannel(candidate(9L, 10L, 20L, 100L, 20), "EXX_TRUCK", "EXX海卡"), "WAIT_SPLIT", false); + // Searching a non-representative member must still return the whole pallet. + InboundCandidateQuery query = new InboundCandidateQuery(List.of("EXX"), null, null, "I11", + null, null, null, null, List.of(CandidateOrderStatus.WAIT_SPLIT), 1, 1); + var unit = new CandidateUnit(null, 9L, 10L, 801L, null, 10L); + when(repository.globalCandidateUnits(eq(query), anySet())).thenReturn(new PageResult<>(List.of(unit), 1, 1, 1, 1)); + when(repository.globalCandidatesByUnits(List.of(unit))).thenReturn(List.of(dropship)); + when(dropshipPallets.palletCandidates(10L)).thenReturn(List.of( + new PalletCandidate(801L, "DP-0025", "SEALED_PALLET", true, 13, 1, new BigDecimal("130"), new BigDecimal("1.3")))); + var members = List.of( + new com.baiyue.firstleg.dto.SmartLoadingDtos.GroupMemberCandidate(9L, 10L, 100L, "O10", "I10", "客户", 3, "WAIT_SPLIT", null), + new com.baiyue.firstleg.dto.SmartLoadingDtos.GroupMemberCandidate(9L, 11L, 101L, "O11", "I11", "客户", 10, "WAIT_SPLIT", null)); + when(dropshipPallets.palletInboundMembers(801L)).thenReturn(members); + var result = service.inboundCandidates(query); + assertThat(result.total()).isEqualTo(1); + assertThat(result.records()).singleElement().satisfies(row -> { + assertThat(row.availablePackageCount()).isEqualTo(13); + assertThat(row.groupMembers()).containsExactlyElementsOf(members); + assertThat(row.unitGrossWeightKg()).isEqualByComparingTo("10"); + }); + } + @Test void combinesAllUnfinishedStatusesBeforeReturningTheRequestedPage() { InboundCandidateQuery query = new InboundCandidateQuery( diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java index 538016078..fb34ba751 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java @@ -11,6 +11,9 @@ import java.time.LocalDate; import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.Locale; +import java.util.UUID; +import java.math.BigInteger; +import java.nio.ByteBuffer; @Component public class LastlegOrderNoGenerator { @@ -32,12 +35,31 @@ public class LastlegOrderNoGenerator { } public String next(String businessType, String warehouse, long uniqueId) { + String code = businessType == null ? "" : businessType.trim().toUpperCase(Locale.ROOT); + String returnPrefix = switch (code) { + case "RETURN" -> "RT"; + case "RETURN_RECEIPT" -> "RR"; + case "RETURN_BATCH" -> "RB"; + case "RETURN_CARTON" -> "RC"; + case "RETURN_RELABEL" -> "RETU"; + default -> null; + }; + // Return numbers are independent business identifiers, not truncated database IDs. + // Keep every UUID bit; the longest result (RETU + 25 base-36 digits) fits VARCHAR(32). + if (returnPrefix != null) return returnPrefix + "-" + encodeReturnUuid(UUID.randomUUID()); String type = normalizeType(businessType); return type + "-" + warehouseCode(warehouse) + "-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + "-" + String.format(Locale.ROOT, "%06d", Math.floorMod(uniqueId, 1_000_000)); } + static String encodeReturnUuid(UUID uuid) { + byte[] bytes = ByteBuffer.allocate(16).putLong(uuid.getMostSignificantBits()) + .putLong(uuid.getLeastSignificantBits()).array(); + String encoded = new BigInteger(1, bytes).toString(36).toUpperCase(Locale.ROOT); + return "0".repeat(25 - encoded.length()) + encoded; + } + private String normalizeType(String value) { String code = value == null ? "LMO" : value.trim().toUpperCase(Locale.ROOT); return switch (code) { @@ -54,10 +76,6 @@ public class LastlegOrderNoGenerator { case "DISCARD" -> "CD"; case "PROCUREMENT" -> "PR"; case "INBOUND_PLAN" -> "IP"; - case "RETURN" -> "RT"; - case "RETURN_RECEIPT" -> "RR"; - case "RETURN_BATCH" -> "RB"; - case "RETURN_CARTON" -> "RC"; default -> { String normalized = code.replaceAll("[^A-Z0-9]", ""); yield normalized.isEmpty() ? "LMO" : normalized.substring(0, Math.min(4, normalized.length())); diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index b9101b597..16fea794d 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -1421,7 +1421,7 @@ paths: responses: { '200': { description: 当前客户退货订单分页 } } post: summary: 客户创建退货预报订单 - description: 客户只预报不重复的 SKU 和预计数量,不要求提前知道快递单号;仓库 PDA 实收数量才是实际入库事实。 + description: 客户只预报不重复的 SKU 和预计数量,不要求提前知道快递单号;仓库 PDA 实收数量才是实际入库事实。系统退货编号由业务前缀和25位大写Base36完整随机UUID编码组成,退货单RT、签收单RR、批次RB、箱唛RC及退货主单RETU均独立生成,不编码内部ID、仓库或日期。幂等重放返回已保存的编号,不重新生成。 security: [{ bearerAuth: [] }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ReturnOrderCreate' } } } } responses: { '200': { description: 创建后的退货订单详情 }, '409': { description: 幂等键冲突 } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonBarcodeTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonBarcodeTest.java index 6671baf2b..ea23d8d09 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonBarcodeTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonBarcodeTest.java @@ -11,7 +11,7 @@ import static org.assertj.core.api.Assertions.assertThat; class ReturnCartonBarcodeTest { @ParameterizedTest - @ValueSource(strings = {"RC-WH-20260912-480898", "RC-WH-20260912-274178", "RC-1", "RC-MERGED"}) + @ValueSource(strings = {"RC-0000000000000000000000000", "RC-F5LXX1ZZ5PNORYNQGLHZMSP33", "RC-1", "RC-MERGED"}) void decodesTheActualRenderedSvgBarsToTheExactCartonMark(String mark) throws Exception { String svg = ReturnCartonBarcode.svg(mark); var viewBox = Pattern.compile("viewBox=\"0 0 (\\d+) 50\"").matcher(svg); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/ReturnBusinessNumberTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/ReturnBusinessNumberTest.java new file mode 100644 index 000000000..098929efb --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/ReturnBusinessNumberTest.java @@ -0,0 +1,59 @@ +package com.baiyue.lastleg.service; + +import org.junit.jupiter.api.Test; +import java.math.BigInteger; +import java.util.HashSet; +import java.util.Map; +import java.util.UUID; +import java.util.stream.IntStream; +import static org.junit.jupiter.api.Assertions.*; + +class ReturnBusinessNumberTest { + private static final Map TYPES = Map.of("RETURN", "RT", "RETURN_RECEIPT", "RR", + "RETURN_BATCH", "RB", "RETURN_CARTON", "RC", "RETURN_RELABEL", "RETU"); + + @Test + void formerlyCollidingInternalIdsAndRepeatedIdsNoLongerDetermineBusinessNumbers() { + LastlegOrderNoGenerator generator = new LastlegOrderNoGenerator(); + long first = 2099800000000781889L, second = first + 1_000_000L; + assertEquals(Math.floorMod(first, 1_000_000), Math.floorMod(second, 1_000_000)); + TYPES.forEach((type, prefix) -> { + String a = generator.next(type, "warehouse-LAX-01", first); + String b = generator.next(type, "warehouse-LAX-01", second); + String c = generator.next(type, "warehouse-LAX-01", first); + assertEquals(3, new HashSet<>(java.util.List.of(a, b, c)).size()); + for (String value : java.util.List.of(a, b, c)) { + assertTrue(value.matches(prefix + "-[0-9A-Z]{25}"), value); + assertTrue(value.length() <= 32); + assertFalse(value.contains(Long.toString(first))); + } + }); + } + + @Test + void encodingPreservesAll128BitsIncludingLeadingZerosAndSignBits() { + for (UUID uuid : java.util.List.of(new UUID(0, 0), new UUID(-1, -1), new UUID(Long.MIN_VALUE, 1), + UUID.fromString("00000000-0000-4000-8000-000000000001"), + UUID.fromString("ffffffff-ffff-4fff-bfff-000000000001"))) { + String encoded = LastlegOrderNoGenerator.encodeReturnUuid(uuid); + assertEquals(25, encoded.length()); + assertEquals(new BigInteger(uuid.toString().replace("-", ""), 16), new BigInteger(encoded, 36)); + } + } + + @Test + void independentInstancesGenerateDistinctNumbersDuringConcurrentBurst() { + var values = IntStream.range(0, 20_000).parallel() + .mapToObj(i -> new LastlegOrderNoGenerator().next("RETURN_RECEIPT", "42", 123L)).toList(); + assertEquals(values.size(), new HashSet<>(values).size()); + } + + @Test + void nonReturnRulesRemainUnchangedAndReturnNormalizationIsLocaleIndependent() { + LastlegOrderNoGenerator generator = new LastlegOrderNoGenerator(); + for (String type : java.util.List.of("OUTBOUND", "RELABEL", "PROCUREMENT", "DISPATCH")) { + assertEquals(generator.next(type, "LAX", 123), generator.next(type, "LAX", 1_000_123)); + } + assertTrue(generator.next(" return_receipt ", "42", 1).matches("RR-[0-9A-Z]{25}")); + } +} diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 95ef9ae91..d36586287 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -4,6 +4,8 @@ 总入口是 `../admin-test-env-acceptance.mjs`。每个业务模块使用独立的同名 `.mjs` 文件,避免多个对话同时修改总入口。 +退货专项同时核验新建退货单、退货主单、签收单、批次和自动箱唛的完整业务编号:`RT/RR/RB/RC/RETU-` 加 25 位大写 Base36 UUID 编码;不再截取内部 ID 六位尾号。幂等重放仍核对同一已保存编号,手工指定箱唛不受系统生成格式约束。编号生成器另有完整 128 位编码往返、原截断碰撞和本地并行生成单测;本地生成压测不增加 HTTP 并发。 + 退货专项只运行 `--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 行为不变。 diff --git a/scripts/admin-test-env-modules/return-boundary-scenarios-contract.test.mjs b/scripts/admin-test-env-modules/return-boundary-scenarios-contract.test.mjs index 9c3e28e02..18f427499 100644 --- a/scripts/admin-test-env-modules/return-boundary-scenarios-contract.test.mjs +++ b/scripts/admin-test-env-modules/return-boundary-scenarios-contract.test.mjs @@ -112,6 +112,15 @@ test('流水断言逐行核对归属和业务引用,不能仅凭合计相等 assert.equal(exactReceiptLedger(undefined, []), false) }) +test('真实退货验收检查五类完整编号且保留同键重放检查', async () => { + const source = await readFile(new URL('return-boundary-scenarios.mjs', import.meta.url), 'utf8') + for (const prefix of ['RT', 'RETU', 'RR', 'RB', 'RC']) { + assert.ok(source.includes(`/^${prefix}-[0-9A-Z]{25}$/`), prefix) + } + assert.ok(source.includes('同键并发合箱后重试仍返回原箱')) + assert.ok(source.includes('签收重放不重复产生流水')) +}) + test('高价值扩展覆盖混合回滚、同键合箱、同码隔离、权限和流水且仍逐次回查', async () => { const source = await readFile(new URL('return-boundary-scenarios.mjs', import.meta.url), 'utf8') for (const name of ['混合明细整笔回滚', '同键并发合箱', '相同SKU跨客户跨仓隔离', '身份入口权限', '签收流水与余额核对']) { diff --git a/scripts/admin-test-env-modules/return-boundary-scenarios.mjs b/scripts/admin-test-env-modules/return-boundary-scenarios.mjs index 286fa4166..8b935b53f 100644 --- a/scripts/admin-test-env-modules/return-boundary-scenarios.mjs +++ b/scripts/admin-test-env-modules/return-boundary-scenarios.mjs @@ -75,6 +75,8 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p items, }) resources.returnNos.push(data.order.returnNo) + check('新退货单及退货主单采用独立完整编号', null, + /^RT-[0-9A-Z]{25}$/.test(data.order.returnNo) && /^RETU-[0-9A-Z]{25}$/.test(data.order.lastlegOrderNo)) return { returnNo: data.order.returnNo, sku, skus: items.map(item => item.skuCode), quantity, token, warehouseId } } function receiptBody(order, quantity = order.quantity, idempotencyKey = key('RECEIPT')) { @@ -82,6 +84,9 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p } async function receive(order, operator = picker) { const data = await good(operator.token, 'POST', '准备边界场景实收库存', `${RECEIPTS}/confirm`, receiptBody(order)) + check('签收单批次及自动箱唛采用独立完整编号', null, + /^RR-[0-9A-Z]{25}$/.test(data.receiptNo) && /^RB-[0-9A-Z]{25}$/.test(data.receiptBatchNo) + && /^RC-[0-9A-Z]{25}$/.test(data.cartonMark)) order.cartonMark = data.cartonMark order.batch = data.receiptBatchNo resources.cartonMarks.push(data.cartonMark) @@ -284,7 +289,7 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p const responses = await Promise.all([1, 2].map(() => request(picker.token, 'POST', MERGE, body))) for (const [index, response] of responses.entries()) check(`同键并发合箱请求${index + 1}成功`, response, ok(response)) check('同键并发合箱复用同一目标箱', responses[0], responses.every(ok) - && Boolean(responses[0].body.data?.cartonMark) && responses[0].body.data.cartonMark === responses[1].body.data.cartonMark) + && /^RC-[0-9A-Z]{25}$/.test(responses[0].body.data?.cartonMark) && responses[0].body.data.cartonMark === responses[1].body.data.cartonMark) const [state] = await snapshot([order]) check('同键并发合箱只扣一次且原箱新箱各一件', null, Number(state.cartons.total) === 2 && rows(state.cartons).every(row => Number(row.totalQuantity) === 1 && row.status === 'AVAILABLE')