This commit is contained in:
2026-09-15 18:17:06 +08:00
parent 45018987ef
commit 4b671bb0ab
13 changed files with 174 additions and 15 deletions
@@ -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<PrintSkuRow> 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<GroupMemberCandidate> 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
@@ -142,7 +142,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
<script>
SELECT current_group.id groupId,
CASE WHEN current_group.id IS NULL THEN eligible.accountId END accountId,
CASE WHEN current_group.id IS NULL THEN eligible.inboundId END inboundId,
MIN(CASE WHEN current_group.id IS NULL THEN eligible.inboundId END) inboundId,
CASE WHEN current_group.id IS NULL THEN eligible.palletId END palletId,
MIN(eligible.expectedArrivalAt) sortArrivalAt,MIN(eligible.inboundId) sortInboundId
FROM (
@@ -202,7 +202,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
WHERE eligible.candidateStatus&lt;&gt;'WAIT_SPLIT' OR eligible.availablePackageCount&gt;0
GROUP BY current_group.id,
CASE WHEN current_group.id IS NULL THEN eligible.accountId END,
CASE WHEN current_group.id IS NULL THEN eligible.inboundId END,
CASE WHEN current_group.id IS NULL AND eligible.palletId IS NULL THEN eligible.inboundId END,
CASE WHEN current_group.id IS NULL THEN eligible.palletId END
HAVING (current_group.id IS NULL OR (
COUNT(DISTINCT eligible.accountId,eligible.inboundId)=(SELECT COUNT(*) FROM fl_coload_group_member group_member_count
@@ -230,7 +230,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ba
<if test="query.createdEnd != null">AND MIN(eligible.createdAt)&lt;DATE_ADD(#{query.createdEnd},INTERVAL 1 DAY)</if>
<if test="query.expectedArrivalStart != null">AND MIN(eligible.expectedArrivalAt)&gt;=#{query.expectedArrivalStart}</if>
<if test="query.expectedArrivalEnd != null">AND MIN(eligible.expectedArrivalAt)&lt;DATE_ADD(#{query.expectedArrivalEnd},INTERVAL 1 DAY)</if>
ORDER BY sortArrivalAt IS NULL,sortArrivalAt,sortInboundId
ORDER BY sortArrivalAt IS NULL,sortArrivalAt,sortInboundId,palletId
</script>
""")
Page<CandidateUnit> globalCandidateUnits(Page<CandidateUnit> page,
@@ -103,7 +103,7 @@ public class SmartLoadingService {
Map<Long, List<Candidate>> 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<InboundCandidate> 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<InboundCandidate> uniqueCandidateUnits(List<InboundCandidate> candidates) {
Map<String, InboundCandidate> 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<InboundCandidate> members,
@@ -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]
@@ -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);
@@ -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(
@@ -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()));
@@ -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: 幂等键冲突 } }
@@ -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);
@@ -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<String, String> 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}"));
}
}
+2
View File
@@ -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 行为不变。
@@ -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跨客户跨仓隔离', '身份入口权限', '签收流水与余额核对']) {
@@ -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')