From 2c21d27f61202e726d92b67522a6f0d6bf19f74a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 4 Sep 2026 15:15:12 +0800 Subject: [PATCH 1/7] fix: keep location guidance non-blocking --- .../service/InboundMeasurementService.java | 20 +++++-- .../MeasurementLocationOccupationService.java | 11 +++- .../service/MeasurementSubmissionService.java | 10 +++- .../InboundMeasurementServiceTest.java | 23 ++++++++ ...surementLocationOccupationServiceTest.java | 59 ++++++++++++++++++- .../MeasurementSubmissionServiceTest.java | 8 ++- 6 files changed, 122 insertions(+), 9 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java index 0f431473c..50e3a0a46 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java @@ -25,6 +25,8 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; import org.springframework.beans.factory.annotation.Value; import feign.FeignException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.math.BigDecimal; import java.math.RoundingMode; @@ -37,6 +39,7 @@ import java.util.Set; /** 平台端入库测量、OCR 修正、重量确认和提交审核应用服务。 */ @Service public class InboundMeasurementService { + private static final Logger LOGGER = LoggerFactory.getLogger(InboundMeasurementService.class); /** 测量批次已提交,等待测量审核。 */ private static final String STATUS_SUBMITTED = "SUBMITTED"; /** OCR 或人工修正的重量已确认,可参与计费。 */ @@ -298,8 +301,7 @@ public class InboundMeasurementService { request.adjustedWeightValue(), request.adjustedWeightUnit(), request.adjustmentReason(), operatorId, operatorName); } else if (WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus())) { - locationOccupation.ensureForConfirmedWeight( - tenantId, inboundId, operatorId, weight.getId(), weight.getVersion()); + ensureLocationWithoutBlocking(tenantId, inboundId, operatorId, weight.getId(), weight.getVersion()); return toWeight(weight); } BigDecimal value = effectiveValue(weight); @@ -329,11 +331,21 @@ public class InboundMeasurementService { insertRevision(weight, confirmedVersion, "CONFIRM", value, unit, value, unit, grossWeightKg, netWeightKg, null, operatorId, operatorName); recalculateConfirmedWeight(inbound, operatorId); - locationOccupation.ensureForConfirmedWeight( - tenantId, inboundId, operatorId, weight.getId(), confirmedVersion); + ensureLocationWithoutBlocking(tenantId, inboundId, operatorId, weight.getId(), confirmedVersion); return toWeight(weight); } + private void ensureLocationWithoutBlocking(long tenantId, long inboundId, long operatorId, + long weightResultId, int weightVersion) { + try { + locationOccupation.ensureForConfirmedWeight( + tenantId, inboundId, operatorId, weightResultId, weightVersion); + } catch (RuntimeException exception) { + LOGGER.warn("称重确认已完成,但库位指引暂未生成,不阻断测量主流程: tenantId={}, inboundId={}, weightResultId={}", + tenantId, inboundId, weightResultId, exception); + } + } + @Transactional public void deleteWeightResult(long tenantId, long inboundId, long resultId, int version, long operatorId) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementLocationOccupationService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementLocationOccupationService.java index 19ea22ea6..a6ac28632 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementLocationOccupationService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementLocationOccupationService.java @@ -94,7 +94,16 @@ public class MeasurementLocationOccupationService { } } - public void requireOccupied(long tenantId, long inboundId) { + public void ensureForSubmission(long tenantId, long inboundId, long operatorId) { + var weights = confirmedWeights(tenantId, inboundId); + InboundWeightResultEntity latest = weights.stream() + .max(java.util.Comparator.comparing(InboundWeightResultEntity::getConfirmedAt) + .thenComparing(InboundWeightResultEntity::getId)) + .orElseThrow(() -> new BizException(ResultCode.CONFLICT, + "至少需要一张已确认的称重图片才能提交测量")); + ensureForConfirmedWeight(tenantId, inboundId, operatorId, latest.getId(), + latest.getVersion() == null ? 0 : latest.getVersion()); + InboundOrderEntity order = requireOrder(tenantId, inboundId); long warehouseId = requireWarehouse(order); var confirmedWeights = confirmedWeights(tenantId, inboundId); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementSubmissionService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementSubmissionService.java index dea6bd541..eff050be1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementSubmissionService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementSubmissionService.java @@ -6,9 +6,12 @@ import com.baiyue.firstleg.dto.InboundMeasurementRequests; import com.baiyue.firstleg.dto.InboundMeasurementResponses; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; @Service public class MeasurementSubmissionService { + private static final Logger LOGGER = LoggerFactory.getLogger(MeasurementSubmissionService.class); @Resource private InboundMeasurementService measurements; @Resource @@ -19,7 +22,12 @@ public class MeasurementSubmissionService { long tenantId, long inboundId, long operatorId, InboundMeasurementRequests.SubmitRequest request) { var result = measurements.submit(tenantId, inboundId, operatorId, request); - locations.requireOccupied(tenantId, inboundId); + try { + locations.ensureForSubmission(tenantId, inboundId, operatorId); + } catch (RuntimeException exception) { + LOGGER.warn("测量已提交,但库位指引暂未补齐,不阻断提交审核主流程: tenantId={}, inboundId={}", + tenantId, inboundId, exception); + } return result; } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java index 947781388..ab0c01789 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java @@ -44,6 +44,7 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -579,6 +580,28 @@ class InboundMeasurementServiceTest { assertThat(result.slotStatus()).isEqualTo("BOUND"); } + @Test + void locationAllocationFailureDoesNotBlockWeightConfirmation() { + InboundOrderEntity inbound = inbound("WAIT_MEASURE"); + InboundWeightResultEntity weight = weight(20L, "30.000", "KG", "PENDING_CONFIRM", 0); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findWeight(1L, 10L, 20L)).thenReturn(weight); + when(repository.updateWeight(weight)).thenReturn(true); + when(repository.listWeights(1L, 10L)).thenReturn(List.of(weight)); + doThrow(new IllegalStateException("warehouse unavailable")) + .when(locationOccupation).ensureForConfirmedWeight(1L, 10L, 9L, 20L, 1); + + InboundMeasurementResponses.WeightResult result = service.confirmWeightResult( + 1L, 10L, 20L, 9L, "操作员", + new InboundMeasurementRequests.ConfirmWeightResultRequest(0, null, null, null)); + + assertThat(result.recognitionStatus()).isEqualTo("CONFIRMED"); + assertThat(result.grossWeightKg()).isEqualByComparingTo("30.000"); + assertThat(result.locationCode()).isNull(); + verify(repository).insertRevision(any(InboundWeightRevisionEntity.class)); + verify(repository).updateInboundWeightSummary(inbound); + } + @Test void confirmingFifthPalletSeparatelyAggregatesGrossAndCargoNetWeight() { InboundOrderEntity inbound = inbound("WAIT_MEASURE"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementLocationOccupationServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementLocationOccupationServiceTest.java index 7e14d9d89..e9932f854 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementLocationOccupationServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementLocationOccupationServiceTest.java @@ -320,7 +320,7 @@ class MeasurementLocationOccupationServiceTest { when(measurements.listWeights(BUSINESS_TENANT, INBOUND)).thenReturn(weights); when(allocations.list(BUSINESS_TENANT, INBOUND)).thenReturn(List.of(first, second)); - service.requireOccupied(BUSINESS_TENANT, INBOUND); + service.ensureForSubmission(BUSINESS_TENANT, INBOUND, OPERATOR); verify(warehouse, never()).reserveBlock(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any()); @@ -330,6 +330,51 @@ class MeasurementLocationOccupationServiceTest { org.mockito.ArgumentMatchers.any()); } + @Test + void submissionReconcilesMissingBlockFromLatestConfirmedWeight() { + InboundOrderEntity inbound = inbound(); + inbound.setActualVolumeCbm(new BigDecimal("2.5000")); + List weights = confirmedWeights().subList(0, 1); + when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound); + when(measurements.listWeights(BUSINESS_TENANT, INBOUND)).thenReturn(weights); + when(warehouse.reserveBlock(org.mockito.ArgumentMatchers.eq("internal-token"), + org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(block("RESERVED", 3))); + when(warehouse.confirmBlock(org.mockito.ArgumentMatchers.eq("internal-token"), + org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(block("OCCUPIED", 3))); + when(allocations.list(BUSINESS_TENANT, INBOUND)) + .thenReturn(List.of(), blockSlotsBoundTo(weights)); + + service.ensureForSubmission(BUSINESS_TENANT, INBOUND, OPERATOR); + + verify(warehouse).reserveBlock(org.mockito.ArgumentMatchers.eq("internal-token"), + org.mockito.ArgumentMatchers.argThat(request -> request.requiredSlotCount() == 3)); + verify(allocations).bindConfirmedWeights(BUSINESS_TENANT, INBOUND, weights); + } + + @Test + void submissionExpandsUndersizedBlockWhenMeasuredVolumeGrows() { + InboundOrderEntity inbound = inbound(); + inbound.setActualVolumeCbm(new BigDecimal("2.5000")); + List weights = confirmedWeights().subList(0, 1); + var current = slot(1); + current.setWeightResultId(weights.get(0).getId()); + current.setSlotStatus("BOUND"); + when(workflow.findOwned(BUSINESS_TENANT, INBOUND)).thenReturn(inbound); + when(measurements.listWeights(BUSINESS_TENANT, INBOUND)).thenReturn(weights); + when(allocations.list(BUSINESS_TENANT, INBOUND)) + .thenReturn(List.of(current), blockSlotsBoundTo(weights)); + when(warehouse.expandBlock(org.mockito.ArgumentMatchers.eq("internal-token"), + org.mockito.ArgumentMatchers.any())).thenReturn(ApiResponse.success(block("OCCUPIED", 3))); + + service.ensureForSubmission(BUSINESS_TENANT, INBOUND, OPERATOR); + + verify(warehouse).expandBlock(org.mockito.ArgumentMatchers.eq("internal-token"), + org.mockito.ArgumentMatchers.argThat(request -> request.blockId() == 500L + && request.requiredSlotCount() == 3)); + verify(allocations).replaceBlock(BUSINESS_TENANT, INBOUND, WAREHOUSE, + 20, new BigDecimal("2.5000"), block("OCCUPIED", 3)); + } + private static InboundProperties properties() { InboundProperties value = new InboundProperties(); value.setDefaultPalletCapacityCbm(new BigDecimal("1.200000")); @@ -377,6 +422,18 @@ class MeasurementLocationOccupationServiceTest { return value; } + private static List blockSlotsBoundTo( + List weights) { + return java.util.stream.IntStream.rangeClosed(1, 3).mapToObj(slotNo -> { + var slot = slot(slotNo); + if (slotNo <= weights.size()) { + slot.setWeightResultId(weights.get(slotNo - 1).getId()); + slot.setSlotStatus("BOUND"); + } + return slot; + }).toList(); + } + private static List confirmedWeights() { return java.util.stream.LongStream.rangeClosed(1, 3).mapToObj(index -> { InboundWeightResultEntity weight = new InboundWeightResultEntity(); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementSubmissionServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementSubmissionServiceTest.java index 65604b30f..d79d94af7 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementSubmissionServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MeasurementSubmissionServiceTest.java @@ -9,6 +9,7 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import org.springframework.transaction.annotation.Transactional; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -35,7 +36,7 @@ class MeasurementSubmissionServiceTest { assertThat(service.submit(10001L, 88L, 9L, request)).isSameAs(submitted); - verify(locations).requireOccupied(10001L, 88L); + verify(locations).ensureForSubmission(10001L, 88L, 9L); } @Test @@ -44,7 +45,10 @@ class MeasurementSubmissionServiceTest { var submitted = new InboundMeasurementResponses.MeasurementSubmitted( 30L, 3, "MEASURE_PENDING", "PENDING", "STANDARD", null, null, null); when(measurements.submit(10001L, 88L, 9L, request)).thenReturn(submitted); + doThrow(new IllegalStateException("warehouse unavailable")) + .when(locations).ensureForSubmission(10001L, 88L, 9L); + assertThat(service.submit(10001L, 88L, 9L, request)).isSameAs(submitted); - verify(locations).requireOccupied(10001L, 88L); + verify(locations).ensureForSubmission(10001L, 88L, 9L); } } From 4aed53a8af1568702754f6bd2a91b18494a2cd92 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 4 Sep 2026 15:19:45 +0800 Subject: [PATCH 2/7] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- firstleg/AGENTS.md | 4 +- scripts/AGENTS.md | 1 + .../fulfillment-list-field-contract.mjs | 8 ++- ...t-prototype-field-matrix-contract.test.mjs | 9 ++- .../loading-history-noop-contract.test.mjs | 15 ++-- ...nt-qualification-fixture-contract.test.mjs | 3 +- ...e-cross-service-recovery-contract.test.mjs | 7 +- ...se3-container-regression-contract.test.mjs | 5 +- .../phase3-http-fail-closed-contract.test.mjs | 5 +- .../phase3-loading-marker-contract.test.mjs | 12 ++-- .../phase3-order-splitting-contract.test.mjs | 3 +- .../phase3-producer-chain-contract.test.mjs | 71 ++++++++++--------- ...hase4-overseas-warehouse-contract.test.mjs | 27 ++++--- .../service-center-flow.mjs | 8 +-- .../suppliers-contract.test.mjs | 2 +- ...ainer-inbound-plan-batch-contract.test.mjs | 5 +- .../truck-appointments.mjs | 50 +++++++++++-- scripts/agents-index-contract.ps1 | 11 ++- 18 files changed, 162 insertions(+), 84 deletions(-) diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index d3d9f127c..bccc7a997 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -18,7 +18,7 @@ | 平台测量/OCR 与箱规计费快照 | `controller/PlatformInboundMeasurementController.java`、`service/InboundMeasurementService.java`、`CartonMeasurementService.java`、`CustomerDimensionWeightingService.java`;手工单箱测量保存实际尺寸、可选外箱修正、`chargeRuleId` 与 `coefficientMode`,计算结果以服务端返回值为准 | | 箱唛箱码生成、打印与测量绑定 | 审核建单生成钩子 `controller/InternalInboundOrderController.java`;生成/解析 `service/InboundCartonLabelService.java`、`repository/InboundCartonLabelRepository.java`;平台与客户打印 `controller/PlatformInboundOrderController.java`、`MerchantInboundCartonLabelController.java`、`service/InboundCartonLabelPrintService.java`;基线/升级 SQL 为 `src/main/resources/db/phase2-inbound-carton-label.sql`、`phase2-carton-label-versioning-migration.sql` | | 墨家设备测量上传 | `controller/OpenApiInboundMeasurementController.java`、`dto/MojiaMeasurementDtos.java`、`service/MojiaMeasurementOpenApiService.java`、`security/MeasurementRequestAuthenticator.java`;接口契约见 `src/main/resources/openapi/firstleg-v1.yaml` 与 `../../docs/墨家设备测量上传接口说明-V3.md` | -| 称重确认与整票库位块 | `service/MeasurementLocationOccupationService.java`、`InboundMeasurementService.java`、`MeasurementSubmissionService.java`、`client/WarehousePalletLocationClient.java`;Warehouse 块服务见 `../warehouse/src/main/java/com/baiyue/warehouse/service/LocationBlockService.java`,接口与上架引导见 `src/main/resources/openapi/firstleg-v1.yaml` | +| 称重确认与整票库位块 | `service/MeasurementLocationOccupationService.java` 的 `ensureForConfirmedWeight/ensureForSubmission`、`InboundMeasurementService.java`、`MeasurementSubmissionService.java`、`client/WarehousePalletLocationClient.java`;Warehouse 块服务见 `../warehouse/src/main/java/com/baiyue/warehouse/service/LocationBlockService.java`,接口与上架引导见 `src/main/resources/openapi/firstleg-v1.yaml` | | 审核与初始计价 | `controller/PlatformInboundMeasurementReviewController.java`、`service/MeasurementReviewService.java`、`service/InitialInboundPricingService.java`、`dto/MeasurementApprovalResponse.java` | | 客户四档箱规与默认算法 | `controller/PlatformCustomerDimensionConfigController.java`、`service/CustomerDimensionConfigService.java`、`repository/CustomerDimensionConfigRepository.java`;固定 `SMALL/MEDIUM/LARGE/EXTRA_LARGE`,默认算法只允许 `ADD/MULTIPLY` | | FBA 主数据/计费与客户规则 | `controller/PlatformFbaMasterDataController.java`、`PlatformFbaPricingController.java`、`service/FbaPricingService.java`、`repository/FbaPricingRepository.java`;客户规则可保存多个版本,但每个客户仅一条默认规则,计算请求可提交已确认的重量/体积选择值 | @@ -81,7 +81,7 @@ - 客户入库费用只在登录客户范围、测量审核通过、`feeStatus=SENT` 且存在已发布明细时可见;确认必须具备 `merchant:order:edit`,携带当前 `expectedFeeVersion` 与幂等键。成功后记录确认成员和时间并将该费用版本永久锁定,平台不得再调整;旧版本、未发送或重复异参确认均返回冲突,不做兼容覆盖。 - PDA 拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数或页面展示字段替代箱码。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。 - PDA 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。 -- 首张称重图片确认时按 `max(已确认称重图片数, ceil(有效体积 / 默认托盘容量))` 预留并确认整票库位块;有效体积严格依次取墨家/人工测量实测体积、测量批次冻结申报体积、Order 最新客户申报体积,全部无效才拒绝,不得把后两级回退冒充实测。后续确认绑定最小空槽,所需槽数增长时通过 Warehouse `/blocks/expand` 原子追加,既有 `blockId/slotNo/reservationId/locationId` 不得变化。测量提交只校验并复用已存在权威块,不得首次分配。Firstleg 必须显式保存 `weightResultId -> blockId/slotNo/reservationId/locationId/locationCode`,按 `confirmedAt + weightResultId` 稳定顺序绑定,禁止按列表下标推断;普通连续位不足的新增槽由 Warehouse 放入共享 A0,不阻断确认。 +- 首张称重图片确认时按 `max(已确认称重图片数, ceil(有效体积 / 默认托盘容量))` 尽力预留并确认整票库位块;有效体积先取入库单已落地的正数实测体积,否则对最新测量批次的正数 `totalVolumeCbm/originalVolumeCbm` 取较大值,再无有效值才查询 Order 最新客户申报体积,全部无效则拒绝。后续确认绑定最小空槽,所需槽数增长时通过 Warehouse `/blocks/expand` 原子追加,既有 `blockId/slotNo/reservationId/locationId/sharedOverflow` 不得变化。库位指引失败只记录警告,不阻断称重确认;测量提交后以最新已确认图片调用 `ensureForSubmission` 再次补齐和校验,失败同样不回滚已提交的测量主流程。Firstleg 必须显式保存 `weightResultId -> blockId/slotNo/reservationId/locationId/locationCode`,按 `confirmedAt + weightResultId` 稳定顺序绑定,禁止按列表下标推断;普通连续位不足的新增槽由 Warehouse 放入共享 A0,不阻断确认。 - 删除称重图片只解除图片和托盘快照绑定,将槽位恢复为 `RESERVED_EMPTY`;槽位和整票块继续归当前入仓码。解绑失败时不得调用文件删除,文件删除失败时本地解绑和重量结果删除必须回滚。 - 头程快照必须保存 Warehouse `blockId + slotNo + reservationId`;A0 可被多个整票块共享 `locationId`,业务资源身份始终使用 `reservationId`,不得把 `locationId` 当作唯一标识。 - PDA 部分拣货不得释放块内单槽;存在余量子单时通过 `/internal/warehouse/v1/pallet-locations/blocks/transfer` 将整段转移给子单,源单与余量子单可各自保留同一块槽及称重快照,因此 `fl_inbound_pallet_allocation` 的槽位与称重唯一键必须包含 `inbound_id`。整票全部拣完时只通过 `/internal/warehouse/v1/pallet-locations/blocks/release` 一次性释放全部普通位和 A0 预约,禁止恢复逐槽 `settlements/release-selected` 协议。 diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index 0c7aa987d..58c314e1f 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -15,6 +15,7 @@ | 待审核订单造数 | `create-pending-review-orders.mjs` | 使用已配置客户创建并提交订单,停在待审核;不审核、不推进后续履约 | | 批量待分柜造数 | `it-test-wait-split-bulk.mjs` | 固定“IT测试”和“义乌仓”,随机客户可选真实线路并尽力推进到 `WAIT_SPLIT`;失败记录后继续,不自动删除成功数据;只复用 `TP-001`/`YW-TP-002`,不新增托盘或库位,普通位不足落 `A0` | | Admin HTTP 内部模块 | `admin-test-env-modules/*.mjs` | 由 Admin 总入口动态加载,除文档明确的独立编排器外不得当作独立全量入口;`*contract.test.mjs` 只做离线合同 | +| 入仓测量与整票库位块专项 | `admin-test-env-modules/inbound-measurement.mjs`、`admin-test-env-modules/README.md` | 仍从 Admin 总入口按 `orders,inbound-measurement` 依赖链执行;开启 `--verify-pallet-location-guide true` 后逐张确认回查块扩容只能追加,既有槽身份不得漂移,并验证删除图片后空槽保留与替代图片复用;会写测试环境,必须有当前轮授权 | | Finance 扩展 HTTP 模块 | `admin-test-env-modules/firstleg-container-fee-allocation.mjs`、`finance-direct-paid-isolation.mjs`、`phase4-overseas-finance-cross-region.mjs` | 分别验证柜费跨客户分摊、海外仓服务直接已付隔离、五类海外费用跨区入池;必须由 Admin 总入口按已登记依赖运行,使用本轮资源且禁止借历史数据,真实 HTTP 仍须当前轮授权 | | 定向 HTTP/诊断 | 以 `README.md` 的“仓库级入口清单”为准 | 必须明确是只读诊断还是会补证据、创建资源或推进状态,不能用诊断成功冒充全量通过 | | 本地质量与静态合同 | `backend-full-regression.ps1`、`frontend-full-regression.ps1`、`mapper-scan-contract.mjs`、`script-documentation-contract.test.mjs` 等 | 默认不访问测试环境;数据库非 dry-run、Nacos 发布等有写入能力的入口必须按 README 单独识别 | diff --git a/scripts/admin-test-env-modules/fulfillment-list-field-contract.mjs b/scripts/admin-test-env-modules/fulfillment-list-field-contract.mjs index 3a20ab712..3ea3488da 100644 --- a/scripts/admin-test-env-modules/fulfillment-list-field-contract.mjs +++ b/scripts/admin-test-env-modules/fulfillment-list-field-contract.mjs @@ -111,12 +111,10 @@ export function portScheduleFieldGaps(record) { } export function truckAppointmentFieldGaps(record) { - return gaps(record, { + const result = gaps(record, { id: isPositiveId, appointmentNo: isNonBlank, oceanBookingId: isPositiveId, - containerBookingId: isPositiveId, - loadingPlanId: (value) => value == null || isPositiveId(value), warehouseId: (value) => value == null || isPositiveId(value), plannedLoadingAt: isIsoDateTime, status: isNonBlank, @@ -126,6 +124,10 @@ export function truckAppointmentFieldGaps(record) { currency: (value) => value === 'RMB' || value === 'USD', attachmentCount: isNonNegativeNumber, }) + for (const field of ['containerBookingId', 'loadingPlanId', 'containerNo', 'cabinetNo', 'containers']) { + if (Object.hasOwn(record || {}, field)) result.push(`forbidden:${field}`) + } + return result } export function customsCaseFieldGaps(record) { diff --git a/scripts/admin-test-env-modules/fulfillment-prototype-field-matrix-contract.test.mjs b/scripts/admin-test-env-modules/fulfillment-prototype-field-matrix-contract.test.mjs index 3f5eea1a1..d0e401f6e 100644 --- a/scripts/admin-test-env-modules/fulfillment-prototype-field-matrix-contract.test.mjs +++ b/scripts/admin-test-env-modules/fulfillment-prototype-field-matrix-contract.test.mjs @@ -54,7 +54,7 @@ test('Admin 当前列表逐字段列与验收矩阵一致', () => { 'loading-lists/components/LoadingListsView.vue': ['preloadNo', 'customers', 'skuCount', 'loadingListNo', 'totalPackageCount', 'totalGrossWeightKg', 'totalNetWeightKg', 'totalVolumeCbm', 'totalQuantity', 'createdByName'], 'ocean-bookings/pages/OceanBookingsView.vue': ['bookingNo', 'customerName', 'billOfLadingNo', 'carrierName', 'bookingAgent', 'vesselName', 'pol', 'pod', 'etd', 'eta', 'updatedAt'], 'port-schedules/pages/PortSchedulesView.vue': ['schedule.scheduleNo', 'schedule.sourceName', 'schedule.vesselName', 'schedule.departurePortName', 'schedule.destinationPortName', 'schedule.etdAt', 'schedule.etaAt', 'schedule.observedAt', 'expired'], - 'trucking/pages/TruckAppointmentsView.vue': ['appointmentNo', 'containerNo', 'company', 'driver', 'vehicleNo', 'store', 'payableAmount', 'currency', 'plannedLoadingAt', 'attachmentCount'], + 'trucking/pages/TruckAppointmentsView.vue': ['appointmentNo', 'oceanBookingNo', 'billOfLadingNo', 'warehouseName', 'company', 'driver', 'vehicleNo', 'store', 'payableAmount', 'currency', 'plannedLoadingAt', 'attachmentCount'], 'outbound-dispatch/pages/OutboundDispatchesView.vue': ['inboundCode', 'customerName', 'containerNo', 'oceanBookingStatus', 'truckAppointmentStatus', 'pickTaskStatus', 'dispatchStatus', 'gate'], } for (const [relative, fields] of Object.entries(expectations)) { @@ -87,7 +87,7 @@ test('字段校验器接受正式完整投影并精确暴露缺字段', () => { [loadingListFieldGaps, { planId: ids.plan, containerBookingId: ids.container, loadingListNo: 'LL-1', containerNo: 'MSCU1234567', cabinetNo: 'CAB-1', warehouseName: '宁波仓', status: 'COMPLETED', totalPackageCount: 1, totalGrossWeightKg: 10, totalNetWeightKg: 9, totalVolumeCbm: 1, totalQuantity: 2, createdAt: at }, undefined, 'containerNo'], [oceanBookingFieldGaps, { id: ids.id, bookingNo: 'BK-1', status: 'RELEASED', vesselName: 'VESSEL', voyageNo: 'V1', estimatedSailingAt: at, estimatedArrivalAt: at, customerId: ids.id, customerName: '测试客户', shippingCompanyId: null, shippingCompanyName: null, bookingAgentId: ids.id, bookingAgentName: '代理' }, undefined, 'voyageNo'], [portScheduleFieldGaps, { portSchedule: { schedule: { id: ids.id, scheduleNo: 'PS-1', sourceName: '船公司', vesselName: 'VESSEL', voyageNo: 'V1', departurePortCode: 'CNNGB', destinationPortCode: 'USLAX', etdAt: at, etaAt: at, observedAt: at, updatedAt: at, expiresAt: at }, expired: false } }, undefined, 'sourceName'], - [truckAppointmentFieldGaps, { id: ids.id, appointmentNo: 'TA-1', oceanBookingId: ids.id, containerBookingId: ids.container, loadingPlanId: ids.plan, warehouseId: ids.id, warehouseName: '宁波仓', carrierId: ids.id, carrierName: '拖车商', payableAmount: 0, currency: 'RMB', plannedLoadingAt: at, status: 'DRAFT', attachmentCount: 0 }, undefined, 'appointmentNo'], + [truckAppointmentFieldGaps, { id: ids.id, appointmentNo: 'TA-1', oceanBookingId: ids.id, warehouseId: ids.id, warehouseName: '宁波仓', carrierId: ids.id, carrierName: '拖车商', payableAmount: 0, currency: 'RMB', plannedLoadingAt: at, status: 'DRAFT', attachmentCount: 0 }, undefined, 'appointmentNo'], [outboundAggregateFieldGaps, { containerBookingId: ids.container, containerNo: 'MSCU1234567', loadingPlanId: ids.plan, loadingPlanStatus: 'COMPLETED', oceanBookingId: ids.id, oceanBookingStatus: 'RELEASED', dispatchStatus: 'READY_FOR_DISPATCH', version: 1, inbounds: [{ orderId: ids.order, inboundId: ids.inbound, inboundCode: 'IN-1', tenantId: ids.id, inboundStatus: 'LOADED', pickTaskId: ids.task, pickTaskStatus: 'COMPLETED' }], gate: { inbounds: [{ inboundId: ids.inbound, inboundCode: 'IN-1', gate: { items: [] } }] } }, undefined, 'containerNo'], ] for (const [validator, sample, options, missing] of samples) { @@ -97,6 +97,11 @@ test('字段校验器接受正式完整投影并精确暴露缺字段', () => { target[missing] = '' assert.ok(validator(invalid, options).includes(missing)) } + assert.ok(truckAppointmentFieldGaps({ + id: ids.id, appointmentNo: 'TA-1', oceanBookingId: ids.id, warehouseId: ids.id, + carrierId: ids.id, carrierName: '拖车商', payableAmount: 0, currency: 'RMB', + plannedLoadingAt: at, status: 'DRAFT', attachmentCount: 0, containerNo: 'MSCU1234567', + }).includes('forbidden:containerNo')) }) test('字段断言接入正式生产模块和默认全量入口', () => { diff --git a/scripts/admin-test-env-modules/loading-history-noop-contract.test.mjs b/scripts/admin-test-env-modules/loading-history-noop-contract.test.mjs index d66c0e20f..4e7e4fbeb 100644 --- a/scripts/admin-test-env-modules/loading-history-noop-contract.test.mjs +++ b/scripts/admin-test-env-modules/loading-history-noop-contract.test.mjs @@ -38,16 +38,13 @@ test('deduplication uses stable event identity and never identical display text' assert.deepEqual(rows.map((row) => row.historyId), ['1', '3', '4', '5']) }) -test('backend never appends a generic STATUS_UPDATE and push replay is read-only', () => { +test('backend never appends a generic STATUS_UPDATE and current operation replay is read-only', () => { assert.doesNotMatch(serviceSources, /(?:history|appendHistory)\([^;]{0,500}"STATUS_UPDATE"/s) - assert.match(smartLoading, /if \(key\.equals\(plan\.getPushIdempotencyKey\(\)\)\)[\s\S]{0,300}return view\(plan, booking\)/) - assert.match(smartLoading, /history\(planId, null, "PUSH_PRELOAD"/) - const replayStart = smartLoadingTest.indexOf('void exactPushReplayIsReadOnly()') - const replayEnd = smartLoadingTest.indexOf('@Test', replayStart + 10) - const replayBlock = smartLoadingTest.slice(replayStart, replayEnd) - assert.notEqual(replayStart, -1) - assert.match(replayBlock, /never\(\)\)\.insert/) - assert.match(smartLoadingTest, /getActionType\(\)\)\.isEqualTo\("PUSH_PRELOAD"\)/) + assert.match(smartLoading, /private Plan replay\([\s\S]{0,900}sameRequestHash/) + assert.match(smartLoadingTest, /void replaysBeforeVersionCheck\(\)/) + assert.match(smartLoadingTest, /void exactWarehouseRemarkReplayIsReadOnly\(\)/) + assert.match(smartLoadingTest, /replaysBeforeVersionCheck[\s\S]{0,1200}verify\(repository, never\(\)\)\.candidate/) + assert.match(smartLoadingTest, /exactWarehouseRemarkReplayIsReadOnly[\s\S]{0,1200}verify\(repository, never\(\)\)\.updateWarehouseRemark/) }) test('operationId is projected end-to-end for identity-based frontend deduplication', () => { diff --git a/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs b/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs index 2427b20b7..1fa71c449 100644 --- a/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs @@ -37,7 +37,8 @@ test('批量待分柜参数固定同一客户并限制为订单测量链', () => assert.match(acceptance, /merchant-username和merchant-password必须同时提供/) assert.match(acceptance, /modules\.splice\(fixtureIndex, 1\)/) assert.match(acceptance, /wait-split-count必须是1-100的整数/) - assert.match(acceptance, /批量WAIT_SPLIT只允许执行merchant-qualification-fixture、orders、inbound-measurement/) + assert.match(acceptance, /fixtureModules = new Set\(\['merchant-qualification-fixture', 'orders', 'inbound-measurement',[\s\S]{0,180}'coload-groups'[\s\S]{0,180}'firstleg-container-fee-allocation'/) + assert.match(acceptance, /批量WAIT_SPLIT只允许执行WAIT_SPLIT造数、同柜组与柜次跨客户分摊模块/) assert.match(acceptance, /createAdditionalWaitSplitFixtures/) assert.match(acceptance, /orderBlockingFailure[\s\S]*step\.deploymentBlocker/) assert.match(acceptance, /measurementBlockingFailure[\s\S]*step\.deploymentBlocker/) diff --git a/scripts/admin-test-env-modules/pda-complete-cross-service-recovery-contract.test.mjs b/scripts/admin-test-env-modules/pda-complete-cross-service-recovery-contract.test.mjs index da2d5aa12..f4201f234 100644 --- a/scripts/admin-test-env-modules/pda-complete-cross-service-recovery-contract.test.mjs +++ b/scripts/admin-test-env-modules/pda-complete-cross-service-recovery-contract.test.mjs @@ -91,9 +91,10 @@ test('a different operationId is rejected while the completion command is proces test('fifteen completed Warehouse settlements can be reconciled after Firstleg rollback', () => { assert.match(loadingPickTests, /workerProcessesFifteenInboundOwnersInStableOrderAndFinalizesOnce/) assert.match(loadingPickTests, /IntStream\.range\(0, 15\)/) - assert.match(settlementTests, /reusesWarehouseSettlementTargetWhenRetryingAfterLocalRollback/) - assert.match(settlementTests, /warehouse\.settlementTarget\("internal-token", 31L, "PDA-SETTLE-1-31"\)/) - assert.match(settlementTests, /targetBusinessId\(\) == 999L/) + assert.match(settlementTests, /repeatedSettleWithExistingChildDoesNotConsumeOrTransferAgain/) + assert.match(settlementTests, /splits\.appliedBatch\(10001L, "PDA-SPLIT-1-31"\)/) + assert.match(settlementTests, /verify\(warehouse, never\(\)\)\.transferBlock/) + assert.match(settlementTests, /verify\(warehouse, never\(\)\)\.releaseBlock/) assert.match(loadingPickTests, /workerRestartResumesFromOwnerCheckpointsWithoutRepeatingSettlements/) }) diff --git a/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs b/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs index 4445e2f2b..507161c2a 100644 --- a/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs @@ -64,7 +64,7 @@ test('active overview photos download through controlled paths while void histor ]) assert.equal(controlledPhotoDownload(path, response), false) }) -test('truck attachment API and Admin upload-reference-delete flow are frozen', () => { +test('truck attachment API reference flow and Admin upload-delete flow are frozen', () => { assert.match(truckApi, /referenceTruckAppointmentAttachment/) assert.match(truckApi, /uploadTruckAppointmentAttachment/) assert.match(truckApi, /deleteTruckAppointmentAttachment/) @@ -91,7 +91,8 @@ test('existing container number replacement keeps container plan and PDA identit }) test('PDA task opens only after no-number push, release, four-field link and plan-level confirmation', () => { - assert.match(containerLoadingAcceptance, /无真实柜号推送预装计划成功/) + assert.match(containerLoadingAcceptance, /分配后自动建立预装关联且计划可读/) + assert.match(containerLoadingAcceptance, /无号 push 后预装清单可见/) assert.match(containerLoadingAcceptance, /无号 push 成功后 PDA 查询 A0404/) assert.match(containerLoadingAcceptance, /oceanBookingId: bookingId, requirementId: id\(requirement\.id\), containerNo: realContainerNo/) assert.match(containerLoadingAcceptance, /关联后列表仅保留模拟柜原地补全的一条 ACTIVE 操作身份/) diff --git a/scripts/admin-test-env-modules/phase3-http-fail-closed-contract.test.mjs b/scripts/admin-test-env-modules/phase3-http-fail-closed-contract.test.mjs index 1a4f1bfa4..829146440 100644 --- a/scripts/admin-test-env-modules/phase3-http-fail-closed-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-http-fail-closed-contract.test.mjs @@ -30,8 +30,9 @@ test('phase3 inventory mutation and downstream gates cannot pass as coverage gap const convergence = read('phase3-pda-convergence.mjs') const dispatch = read('phase3-outbound-dispatch.mjs') const aggregate = read('phase3-outbound-aggregate.mjs') - assert.match(loading, /自动制造库存变化并验证旧 snapshot token A0409[\s\S]{0,300}false/) - assert.match(convergence, /PDA 真实库存变化后旧 token A0409 与刷新重试[\s\S]{0,300}false/) + assert.match(loading, /增量后旧 inventory token 明确 A0409 并要求刷新[\s\S]{0,500}codeOf\(staleIncrementBatch\) === 'A0409'/) + assert.match(loading, /增量旧 token 冲突后 plan\/task\/items 零写入/) + assert.match(convergence, /fixture\?\.dynamicIncrementVerified !== true[\s\S]{0,500}不能跳过增量重开、旧 token A0409、刷新续拣和已拣项保护/) assert.match(dispatch, /国内出仓六项硬门禁必须在同轮全部通过[\s\S]{0,300}false/) assert.match(dispatch, /拖车与独立船期仅作聚合参考且不参与出仓门禁/) assert.match(dispatch, /'CUSTOMS_CLEARANCE'/) diff --git a/scripts/admin-test-env-modules/phase3-loading-marker-contract.test.mjs b/scripts/admin-test-env-modules/phase3-loading-marker-contract.test.mjs index 72a1f1658..ec85dd985 100644 --- a/scripts/admin-test-env-modules/phase3-loading-marker-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-marker-contract.test.mjs @@ -14,11 +14,13 @@ test('phase3 loading fixture identifiers are deterministic and distinct across m assert.ok(`P3B-${primary}`.length <= 64) }) -test('phase3 loading uses the collision-safe marker suffix for inventory mutation locations', async () => { +test('phase3 loading uses the collision-safe marker suffix for generated business identifiers', async () => { const source = await import('node:fs/promises').then((fs) => fs.readFile( new URL('./phase3-loading.mjs', import.meta.url), 'utf8')) - assert.match(source, /const mutationSuffix = markerSuffix\(context\.marker\)/) - assert.doesNotMatch(source, /mutationSuffix = context\.marker\.replace\(\/\\D\/g/) - assert.match(source, /if \(!targetLocationId\)[\s\S]+库存变化fixture创建失败后停止旧snapshot提交[\s\S]+return/) - assert.match(source, /if \(!reboundValid\)[\s\S]+库存换绑未成功后停止旧snapshot提交[\s\S]+return/) + assert.match(source, /const suffix = markerSuffix\(context\.marker\)/) + assert.match(source, /billOfLadingNo: `P3BL-\$\{suffix\}`/) + assert.match(source, /vesselName: `P3VESSEL-\$\{suffix\}`/) + assert.doesNotMatch(source, /suffix = context\.marker\.replace\(\/\\D\/g/) + assert.match(source, /增量后旧 inventory token 明确 A0409 并要求刷新/) + assert.match(source, /增量旧 token 冲突后 plan\/task\/items 零写入/) }) diff --git a/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs b/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs index 65e5c3560..96a8a44ec 100644 --- a/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs @@ -18,7 +18,8 @@ test('each remainder uses a simulation linked to a released booking requirement' assert.match(source, /ocean-bookings\/\$\{oceanBookingId\}\/release/) const releaseStart = source.indexOf('const releaseResponse') const releaseBlock = source.slice(releaseStart, source.indexOf('bookingDetailResponse = await', releaseStart)) - assert.match(releaseBlock, /expectedVersion: Number\(bookingCreated\?\.oceanBooking\?\.version\)/) + assert.match(source, /bookingDocumentForm\.append\('expectedVersion', String\(Number\(bookingCreated\?\.oceanBooking\?\.version\)\)\)/) + assert.match(releaseBlock, /expectedVersion: releaseVersion/) assert.match(releaseBlock, /'Idempotency-Key': `\$\{operationBase\}-booking-release`\.slice\(0, 128\)/) assert.match(source, /oceanBooking\?\.status === 'RELEASED' && releasedAllocationMatches/) assert.match(source, /id\(item\?\.containerBookingId\) === containerId/) diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index b5dbf0a4b..25f023468 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -88,7 +88,8 @@ test('container loading follows push, claim, real pick, completed sequence', () test('bridge loading confirms preload before creating and claiming the PDA task', () => { const value = source('./phase3-loading.mjs') assertOrder(value, [ - '无真实柜号推送预装成功并冻结柜次明细', + '分配后自动建立预装关联且柜次计划可读', + '无号推送后预装清单正式可见', '无号 push 成功后 PDA 查询明确 A0404', 'push exact replay 对计划版本历史关联和任务零漂移', '无号且未关联订舱仍无 PDA 任务', @@ -111,7 +112,7 @@ test('bridge loading confirms preload before creating and claiming the PDA task' assert.match(value, /计划级确认同 key 异参返回冲突/) assert.match(value, /已确认计划使用新 key 重复确认返回冲突/) assert.doesNotMatch(value, /\/preload\/confirming|\/preload\/confirm\?tenantId=/) - assert.doesNotMatch(value.slice(value.indexOf('无真实柜号推送预装成功并冻结柜次明细'), value.indexOf('const linkResponse')), + assert.doesNotMatch(value.slice(value.indexOf('分配后自动建立预装关联且柜次计划可读'), value.indexOf('const linkResponse')), /\/transport/) assert.match(value, /missingContainerTaskResponse\.http === 200 && codeOf\(missingContainerTaskResponse\) === 'A0404'/) assert.doesNotMatch(value, /无真实柜号拒绝推送柜次计划|PDA complete后补确认/) @@ -159,22 +160,22 @@ test('PDA completion production and HTTP chain never call or gate on truck appoi assert.doesNotMatch(loading, /\/truck-appointments|completeLoadingFromPda/) }) -test('push replay and both conflict modes preserve the complete pre-PDA snapshot', () => { +test('automatic preload bridge repeated reads preserve the complete pre-PDA snapshot', () => { for (const file of ['./phase3-loading.mjs', './phase3-container-loading.mjs']) { const value = source(file) - assert.match(value, /const pushRequest = Object\.freeze\([\s\S]*body: Object\.freeze\(\{ expectedVersion:/) + assert.match(value, /const pushRequest = Object\.freeze\(\{ path: `\$\{(?:smartLoadingBase|SMART)\}\/plans\/\$\{(?:loadingPlanId|planId)\}` \}\)/) + assert.equal((value.match(/request\(context, 'GET', pushRequest\.path\)/g) || []).length >= 4, true) assert.match(value, /push exact replay[\s\S]*零漂移/) - assert.match(value, /同 key 异参[\s\S]*新 key 重复 push/) - assert.match(value, /非法 push 后回查柜次计划[\s\S]*非法 push 后回查预装历史[\s\S]*非法 push 后回查 bridge/) - assert.match(value, /两类非法 push 后计划版本历史关联(?:和)?任务仍?零漂移/) + assert.match(value, /自动关联计划重复读取稳定[\s\S]*自动关联计划再次读取稳定/) + assert.match(value, /重放后回查柜次计划[\s\S]*重放后回查预装历史[\s\S]*重放后回查 bridge/) assert.match(value, /codeOf\(rejectedTaskResponse\) === 'A0404'/) assert.match(value, /projectPushPreloadStability/) assert.match(value, /diffPushPreloadStability/) assert.match(value, /首次 push 后读取事务完成态柜次计划/) assert.match(value, /事务完成态计划版本与 bridge 一致且保持 EDITING/) assert.match(value, /plan: postPushPlan, detail:/) - assert.match(value, /expectedVersion: Number\(postPushPlan\.version\)/) assert.match(value, /changed=\$\{(?:pushReplayDiff|replayDiff|rejectedDiff)\.join/) + assert.doesNotMatch(value, /\/plans\/[^'`]*\/push-preload/) assert.doesNotMatch(value, /baselineBytes=|replayBytes=|rejectedBytes=/) } }) @@ -215,15 +216,15 @@ test('push stability projection sorts unordered facts and reports persisted fiel ]) }) -test('push orchestration is transactional and a bridge failure cannot reach PDA creation', () => { +test('assignment transaction establishes preload and bridge before PDA synchronization', () => { const service = source('../../firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java') const serviceTest = source('../../firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java') - const push = service.slice(service.indexOf('public Plan pushPreload'), service.indexOf('private void requirePushReplay')) - assert.match(service, /@Transactional[\s\S]*public Plan pushPreload/) - assert.match(push, /repository\.push[\s\S]*history\([\s\S]*loadingModelBridge\.bindAllocatedBeforeLoading/) - assert.doesNotMatch(push, /loadingPickService\.beginIfReady/) - assert.match(serviceTest, /bindingFailureStopsBeforePdaAndPropagatesForTransactionRollback/) - assert.match(serviceTest, /verify\(loadingPickService, never\(\)\)\.beginIfReady/) + const assign = service.slice(service.indexOf('public Plan assign('), service.indexOf('public Plan assignInbound(')) + assert.match(service, /@Transactional[\s\S]{0,300}public Plan assign\(/) + assert.match(assign, /splitPreloadService\.ensure(?:Pending|ForRunning)[\s\S]*touch\(plan[\s\S]*history\([\s\S]*loadingModelBridge\.bindIncrementalAllocation[\s\S]*loadingPickService\.sync/) + assert.match(serviceTest, /addsItemToLoadingPlanWithoutChangingStableTaskOrPlanStatus/) + assert.match(serviceTest, /inOrder\(loadingPickService, splitPreloadService, repository, loadingModelBridge\)/) + assert.match(serviceTest, /order\.verify\(loadingModelBridge\)\.bindIncrementalAllocation/) }) test('finalize rechecks the same unified order detail and list as LOADED', () => { @@ -274,9 +275,9 @@ test('container loading arranges a simulation before linking a released booking assertOrder(value, [ "const simulationResponse = await request(context, 'POST', CONTAINERS", "const candidateResponse = await context.request('GET', `${SMART}/candidates?bookingId=", - "const planResponse = await request(context, 'POST', `${SMART}/plans`", + "const planResponse = await request(context, 'GET', `${SMART}/plans/by-booking/", "const assignResponse = await request", - '无真实柜号推送预装计划成功', + '分配后自动建立预装关联且计划可读', "const bookingCreate = await request", "const linkResponse = await request(context, 'POST', `${CONTAINERS}/${containerId}/ocean-booking-link`", ]) @@ -304,10 +305,10 @@ test('container loading arranges a simulation before linking a released booking assert.match(value, /bookingId=\$\{encodeURIComponent\(containerId\)\}&warehouseId=/) assert.match(value, /tasks\?loadingPlanId=\$\{encodeURIComponent\(planId\)\}&warehouseId=/) assert.match(value, /loadingPlanId: planId, containerBookingId: containerId/) - assert.doesNotMatch(value.slice(value.indexOf('无真实柜号推送预装计划成功'), value.indexOf('const linkResponse')), + assert.doesNotMatch(value.slice(value.indexOf('分配后自动建立预装关联且计划可读'), value.indexOf('const linkResponse')), /\/transport/) assert.match(value, /订舱放舱为 RELEASED 且不预生成真实货柜/) - assert.match(value, /无真实柜号推送预装计划成功/) + assert.match(value, /分配后自动建立预装关联且计划可读/) assert.match(value, /订舱 RELEASED 但预装未 CONFIRMED 不生成 PDA 任务/) assert.match(value, /计划级原子确认整份预装并触发唯一 PDA 任务/) assert.match(value, /confirmationStatus === 'CONFIRMED'/) @@ -398,7 +399,7 @@ test('container loading permits manual cross-channel allocation and keeps stable '手工装柜允许两个真实有效渠道之间跨渠道分配', '跨渠道分配验收后释放入仓码供主流程继续', '人工分配本轮候选货物', - '无真实柜号推送预装计划成功', + '分配后自动建立预装关联且计划可读', '无号 push 成功后 PDA 查询 A0404', '无号且未关联订舱不生成 PDA 任务', '订舱放舱为 RELEASED 且不预生成真实货柜', @@ -431,16 +432,18 @@ test('container loading permits manual cross-channel allocation and keeps stable assert.doesNotMatch(batch, /containerNo:/) }) -test('preload and loading versions use immediate operation baselines and exact push replay', () => { +test('preload and loading versions use immediate baselines and stable automatic bridge reads', () => { const container = source('./phase3-container-loading.mjs') - assert.match(container, /const pushRequest = Object\.freeze\(\{[\s\S]*path:[\s\S]*body: Object\.freeze[\s\S]*headers: Object\.freeze/) - assert.equal((container.match(/request\(context, 'POST', pushRequest\.path, pushRequest\.body, pushRequest\.headers\)/g) || []).length, 2) + assert.match(container, /const pushRequest = Object\.freeze\(\{ path: `\$\{SMART\}\/plans\/\$\{planId\}` \}\)/) + assert.equal((container.match(/request\(context, 'GET', pushRequest\.path\)/g) || []).length >= 4, true) + assert.match(container, /自动关联保持 EDITING 且无需额外推送版本/) assert.match(container, /const beforeClaimPlanVersion = Number\(beforeClaimPlan\?\.version\)/) assert.match(container, /Number\(claimedPlan\?\.version\) > beforeClaimPlanVersion/) const loading = source('./phase3-loading.mjs') - assert.match(loading, /const pushRequest = Object\.freeze\(\{[\s\S]*body: Object\.freeze[\s\S]*headers: Object\.freeze/) - assert.equal((loading.match(/request\(context, 'POST', pushRequest\.path, pushRequest\.body, pushRequest\.headers\)/g) || []).length, 2) + assert.match(loading, /const pushRequest = Object\.freeze\(\{ path: `\$\{smartLoadingBase\}\/plans\/\$\{loadingPlanId\}` \}\)/) + assert.equal((loading.match(/request\(context, 'GET', pushRequest\.path\)/g) || []).length >= 4, true) + assert.match(loading, /自动关联保持 EDITING、身份稳定且无需额外推送版本/) assert.match(loading, /push exact replay 对计划版本历史关联和任务零漂移/) assert.match(loading, /Number\(claimedPlan\?\.version\) > beforeClaimPlanVersion/) assert.match(loading, /MATSON_SUPPLIER_NAME = 'MATSON 开发联调船司'/) @@ -524,18 +527,19 @@ test('phase3 loading proves stale inventory zero-write and defers final completi assert.match(loading, /authoritativeRemaining === authoritativeTotal - assignedPackageCount/) assert.match(loading, /调整箱数超过服务端权威总箱数返回A0400/) assert.match(loading, /超权威总箱数失败后计划和明细零写入/) - assert.match(loading, /taskItemsSignature\(afterStaleTask\) === taskItemsSignature\(beforeMutationTask\)/) + assert.match(loading, /taskItemsSignature\(taskAfterStale\) === taskItemsSignature\(incrementedTask\)/) assert.match(loading, /动态新增计划明细按 inboundDetailId 唯一定位/) assert.match(loading, /重新加入动态明细仍按 inboundDetailId 唯一定位/) assert.equal((loading.match(/id\(item\?\.inboundDetailId\) === id\(dynamicCandidate\.inboundDetailId\)/g) || []).length, 2) assert.doesNotMatch(loading, /items\?\.find\?\.\(\(item\) => id\(item\?\.inboundId\) === incrementalInboundId\)/) assertOrder(loading, [ - '旧 token 冲突对任务、明细、已拣数量和操作历史零写入', - '库存变化后刷新 PDA 实时库存快照', - '原子提交柜次 PDA 整批拣货', - 'PDA 原子批次同参同键重放保持待人工确认', '回查下游门禁前原子 Batch 审计', '最终确认前仅保留 CLAIM/BATCH 且无 COMPLETE_LOADING', + 'LOADING 阶段向同一 PDA task 增加入库单', + '增量后旧 inventory token 明确 A0409 并要求刷新', + '增量旧 token 冲突后 plan/task/items 零写入', + '重新增量后刷新 inventory snapshot', + '刷新后完成动态新增项拣货', '报关与最终完成前保持 LOADING 且不提前写 COMPLETE_LOADING', ]) assert.doesNotMatch(loading, /complete-before-downstream/) @@ -629,10 +633,11 @@ test('downstream container consumers prefer the phase3Loading fixture', () => { assert.match(ocean, /完整 DRAFT 放舱为 RELEASED 且冻结五项运输事实/) assert.match(ocean, /finalStatus: 'RELEASED', containerBookingId: null, loadingPlanId: null/) assert.match(source('./phase3-loading.mjs'), /oceanBookingId: bookingId, loadingPlanId, containerBookingId/) - assert.match(serviceCenter, /if \(!allocationMatches\) return/) assert.match(serviceCenter, /category=TRUCKING&status=ACTIVE/) assert.match(serviceCenter, /oceanBookingId: ocean\.id/) - assert.match(serviceCenter, /containerBookingId: linkContainer \? id\(containerFlow\.containerBookingId\) : null/) + const servicePayload = serviceCenter.slice(serviceCenter.indexOf('const payload = {'), + serviceCenter.indexOf('const response =', serviceCenter.indexOf('const payload = {'))) + assert.doesNotMatch(servicePayload, /containerBookingId:|loadingPlanId:/) assert.match(serviceCenter, /carrierId: id\(carrier\.id\), carrierName: carrier\.name/) assert.doesNotMatch(serviceCenter, /customerId:|customerName:/) assert.doesNotMatch(serviceCenter, /链路承运商-/) @@ -682,7 +687,7 @@ test('phase3 loading provisions its own marker-isolated WAIT_SPLIT fixture', asy const run = new AcceptanceRun({ gateway: 'http://127.0.0.1', modules: [], username: '', password: '', output: '' }) const entry = source('../admin-test-env-acceptance.mjs') assert.match(entry, /module === 'phase3-loading'/) - assert.match(entry, /preparePhase3LoadingFixture\(\)/) + assert.match(entry, /preparePhase3LoadingFixture\('', requireSharedOverflow\)/) assert.match(entry, /orders-phase3-loading-fixture/) assert.match(entry, /inbound-measurement-phase3-loading-fixture/) assert.match(entry, /marker: originalMarker/) diff --git a/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs index 261b0e84b..b785266cd 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs @@ -12,15 +12,21 @@ const source = fs.readFileSync(path.join(here, 'phase4-overseas-warehouse.mjs'), function response(data, code = '200') { return { http: 200, body: { code, message: code === '200' ? 'success' : 'conflict', data }, requestId: 'request-test', traceId: 'trace-test' } } function voidResponse() { return { http: 200, body: { code: '200', message: 'success' }, requestId: 'request-test', traceId: 'trace-test' } } function page(records = []) { return { records, page: 1, size: 20, total: records.length, pages: records.length ? 1 : 0 } } -function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehouseRecords = [{ id: '20001', status: 'ACTIVE', warehouseCode: 'LAX', warehouseName: '洛杉矶仓' }] } = {}) { +function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehouseRecords = [{ id: '20001', status: 'ACTIVE', warehouseType: 'OVERSEAS', warehouseCode: 'LAX', warehouseName: '洛杉矶仓' }] } = {}) { const calls = []; const steps = []; const gaps = []; let createdPallet = null; let createdLocation = null; let createdRelabel = null const context = { context: {}, createdResources: {}, marker: 'ACC-PHASE4-TEST', calls, steps, gaps, - async request(method, requestPath) { calls.push({ channel: 'domestic', method, path: requestPath }); return response(page(tenants)) }, - async requestOverseas(method, requestPath, body) { - calls.push({ channel: 'overseas', method, path: requestPath, body }) + async request(method, requestPath, body) { + calls.push({ channel: 'domestic', method, path: requestPath, body }) + if (requestPath.startsWith('/api/platform/v1/iam/tenants?')) return response(page(tenants)) + if (method === 'GET' && requestPath.startsWith('/api/platform/v1/warehouse-admin?')) return response(page(warehouseRecords)) + if (method === 'GET' && requestPath.includes('/warehouse-admin/20001?')) return response({ warehouse: { id: '20001', customerId: '0', warehouseType: 'OVERSEAS', status: 'ACTIVE' } }) if (method === 'POST' && requestPath === '/api/platform/v1/warehouse-admin') return response('20001') if (method === 'PATCH' && requestPath.includes('/warehouse-admin/20001/status?')) return response(null) + throw new Error(`Unexpected domestic path: ${requestPath}`) + }, + async requestOverseas(method, requestPath, body) { + calls.push({ channel: 'overseas', method, path: requestPath, body }) if (method === 'POST' && requestPath.endsWith('/warehouse-admin/20001/locations')) { createdLocation = { id: '72002', customerId: '0', warehouseId: '20001', ...body }; return response('72002') } if (method === 'PUT' && requestPath.endsWith('/warehouse-admin/20001/locations/72002')) { createdLocation = { ...createdLocation, ...body }; return voidResponse() } if (method === 'DELETE' && requestPath.endsWith('/warehouse-admin/20001/locations/72002')) { createdLocation = null; return voidResponse() } @@ -48,8 +54,6 @@ function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehou if (requestPath.includes('/inventory/balances?')) return response(page([{ id: '60001', customerId: '10001', skuCode: 'SKU-1', totalPackages: 10, availablePackages: 10, updatedAt: '2026-08-21T10:00:00' }])) if (requestPath.includes('/inventory/balances/60001/positions?')) return response([{ id: '61001', balanceId: '60001', totalPackages: 10 }]) if (requestPath.includes('/inventory/ledger?')) return response(page([{ id: '62001', customerId: '10001', businessType: 'STOCK_IN', beforePackages: 0, deltaPackages: 10, afterPackages: 10, occurredAt: '2026-08-21T10:00:00' }])) - if (requestPath.startsWith('/api/platform/v1/warehouse-admin?')) return response(page(warehouseRecords)) - if (requestPath.includes('/warehouse-admin/20001?')) return response({ warehouse: { id: '20001', customerId: '10001', status: 'ACTIVE' } }) if (requestPath.endsWith('/warehouse-admin/20001/locations?page=1&size=20')) return response(page([{ id: '72001', customerId: '0', warehouseId: '20001', zoneCode: 'A', locationCode: 'A-01', locationType: 'STORAGE', maxLoadKg: 1000, status: 'ACTIVE' }])) if (requestPath.endsWith('/warehouse-admin/20001/locations/72001')) return response({ id: '72001', customerId: '0', warehouseId: '20001', locationCode: 'A-01', status: 'ACTIVE' }) if (requestPath.endsWith('/warehouse-admin/20001/locations/72002')) return createdLocation == null @@ -81,7 +85,10 @@ test('既有仓库场景只写本轮库位托盘与换标生命周期', () => { return run(context).then(() => { assert.ok(context.calls.length >= 12) assert.ok(context.calls.every((call) => ['GET', 'POST', 'PUT', 'DELETE'].includes(call.method))) - assert.equal(context.calls.filter((call) => call.channel === 'domestic').length, 1) + assert.equal(context.calls.filter((call) => call.channel === 'domestic').length, 3) + assert.ok(context.calls.some((call) => call.channel === 'domestic' && call.path.startsWith('/api/platform/v1/warehouse-admin?'))) + assert.ok(context.calls.some((call) => call.channel === 'domestic' && call.path.includes('/warehouse-admin/20001?'))) + assert.equal(context.calls.some((call) => call.channel === 'overseas' && call.path === '/api/platform/v1/warehouse-admin'), false) assert.ok(context.calls.filter((call) => call.channel === 'overseas').every((call) => call.path.startsWith('/api/platform/v1/'))) const writes = context.calls.filter((call) => call.method !== 'GET') assert.ok(writes.some((call) => call.method === 'DELETE' && call.path.includes('/locations/72002'))) @@ -103,10 +110,12 @@ test('既有仓库场景只写本轮库位托盘与换标生命周期', () => { test('覆盖冻结的海外只读列表与有记录才查详情', async () => { const context = contextFixture() await run(context) - for (const pathToken of ['/last-leg/orders?', '/relabel-applications?', '/inbound-plans?', '/inventory/summary?', '/inventory/balances?', '/inventory/ledger?', '/warehouse-admin?', '/locations?', '/pallets?']) { + for (const pathToken of ['/last-leg/orders?', '/relabel-applications?', '/inbound-plans?', '/inventory/summary?', '/inventory/balances?', '/inventory/ledger?', '/locations?', '/pallets?']) { assert.ok(context.calls.some((call) => call.channel === 'overseas' && call.path.includes(pathToken)), pathToken) } - for (const detailToken of ['/orders/30001?', '/relabel-applications/RL-1?', '/inbound-plans/IP-1?', '/balances/60001/positions?', '/warehouse-admin/20001?', '/pallets/70001']) { + assert.ok(context.calls.some((call) => call.channel === 'domestic' && call.path.includes('/warehouse-admin?'))) + assert.ok(context.calls.some((call) => call.channel === 'domestic' && call.path.includes('/warehouse-admin/20001?'))) + for (const detailToken of ['/orders/30001?', '/relabel-applications/RL-1?', '/inbound-plans/IP-1?', '/balances/60001/positions?', '/pallets/70001']) { assert.ok(context.calls.some((call) => call.path.includes(detailToken)), detailToken) } for (const call of context.calls.filter((item) => item.path.includes('/inventory/summary?') || item.path.includes('/inventory/balances/export?') || item.path.includes('/inventory/ledger/export?'))) { diff --git a/scripts/admin-test-env-modules/service-center-flow.mjs b/scripts/admin-test-env-modules/service-center-flow.mjs index eba1206e9..e5ecff44d 100644 --- a/scripts/admin-test-env-modules/service-center-flow.mjs +++ b/scripts/admin-test-env-modules/service-center-flow.mjs @@ -84,8 +84,6 @@ export async function run(context) { const payload = { outboundOrderId: null, oceanBookingId: ocean.id, - containerBookingId: null, - loadingPlanId: null, warehouseId: id(containerFlow?.warehouseId || context.createdResources?.warehouse?.id || context.context?.warehouseId || '193000000000000001'), carrierId: id(carrier.id), carrierName: carrier.name, @@ -101,7 +99,8 @@ export async function run(context) { const appointmentId = id(appointment?.id) context.assert(MODULE, '拖车预约仅关联订舱且版本和审计字段完整', response, Boolean(appointmentId) && id(appointment?.oceanBookingId) === id(ocean.id) && Number.isFinite(Number(appointment?.version)) - && !id(appointment?.containerBookingId) && !id(appointment?.loadingPlanId) + && !Object.hasOwn(appointment || {}, 'containerBookingId') + && !Object.hasOwn(appointment || {}, 'loadingPlanId') && Boolean(appointment?.createdAt || appointment?.createdBy || appointment?.operatorName), `appointmentId=${appointmentId || 'missing'}, oceanBookingId=${appointment?.oceanBookingId || 'missing'}, containerBookingId=${appointment?.containerBookingId || 'n/a'}, loadingPlanId=${appointment?.loadingPlanId || 'n/a'}, version=${appointment?.version ?? 'missing'}`, OPTIONS) if (!appointmentId) return @@ -130,7 +129,8 @@ export async function run(context) { const final = context.apiSuccess(MODULE, '回查跨服务链路安全终态', detail, '', OPTIONS) context.assert(MODULE, '跨服务链路终态持久化且关联订舱未漂移', detail, final?.status === 'COMPLETED' && id(final?.oceanBookingId) === id(ocean.id) - && !id(final?.containerBookingId) && !id(final?.loadingPlanId), + && !Object.hasOwn(final || {}, 'containerBookingId') + && !Object.hasOwn(final || {}, 'loadingPlanId'), `status=${final?.status || 'missing'}, oceanBookingId=${final?.oceanBookingId || 'missing'}, containerBookingId=${final?.containerBookingId || 'n/a'}, loadingPlanId=${final?.loadingPlanId || 'n/a'}`, OPTIONS) const historyResponse = await context.request('GET', `${TRUCK}/${appointmentId}/history`) diff --git a/scripts/admin-test-env-modules/suppliers-contract.test.mjs b/scripts/admin-test-env-modules/suppliers-contract.test.mjs index 075a79a26..e07505397 100644 --- a/scripts/admin-test-env-modules/suppliers-contract.test.mjs +++ b/scripts/admin-test-env-modules/suppliers-contract.test.mjs @@ -8,7 +8,7 @@ const openapi=readFileSync(new URL('../../warehouse/src/main/resources/openapi/s test('supplier acceptance covers full CRUD-like lifecycle, carrier matrix and boundary failures',()=>{ for(const marker of ['列表分页与订舱筛选','查询四类供应商统计','新增供应商','查询供应商详情','编辑供应商','停用供应商','重复名称返回冲突','tenantId非0被拒绝','非BOOKING携带carrierCodes返回A0400并保留链路ID','客户账号调用平台供应商接口被拒绝','requestId','traceId']) assert.match(source,new RegExp(marker)) assert.match(source,/carrierCodes:\['MATSON','COSCO'\]/) - assert.match(source,/JSON\.stringify\(detail\?\.carrierCodes\)===JSON\.stringify\(\['MATSON','COSCO'\]\)/) + assert.match(source,/sameCarrierCodes\(detail\?\.carrierCodes,\['MATSON','COSCO'\]\)/) assert.doesNotMatch(source,/scope\s*:/) }) test('supplier module is registered in the default full acceptance orchestration',()=>assert.match(orchestrator,/CONFIGURED_MODULES = \[[^\]]*'suppliers'/s)) diff --git a/scripts/admin-test-env-modules/test-container-inbound-plan-batch-contract.test.mjs b/scripts/admin-test-env-modules/test-container-inbound-plan-batch-contract.test.mjs index 61d75e712..472e02f9b 100644 --- a/scripts/admin-test-env-modules/test-container-inbound-plan-batch-contract.test.mjs +++ b/scripts/admin-test-env-modules/test-container-inbound-plan-batch-contract.test.mjs @@ -96,6 +96,9 @@ test('Admin consumes a server-paged container summary and never derives 15 from assert.match(adminApi, /listContainerPlanProgress/) assert.match(adminApi, /container-summaries/) assert.match(adminView, /listContainerPlanProgress\(/) - assert.match(adminView, /containerRows\.value\s*=\s*result\.records/) + assert.match(adminView, /const pendingRecords\s*=\s*result\.records\.filter/) + assert.match(adminView, /containerRows\.value\s*=\s*pendingRecords/) + assert.match(adminView, /containerOptions\.value\s*=\s*result\.records/) + assert.match(adminView, /if \(request !== containerOptionRequest\) return/) assert.doesNotMatch(adminView, /(?:rows|result\.records)\.value?\.(?:reduce|filter|forEach)[\s\S]{0,300}containerNo/) }) diff --git a/scripts/admin-test-env-modules/truck-appointments.mjs b/scripts/admin-test-env-modules/truck-appointments.mjs index d87df2a34..0a0c9f485 100644 --- a/scripts/admin-test-env-modules/truck-appointments.mjs +++ b/scripts/admin-test-env-modules/truck-appointments.mjs @@ -1,5 +1,6 @@ import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs' import { oceanBookingDraft } from './ocean-booking-fixture.mjs' +import { truckAppointmentFieldGaps } from './fulfillment-list-field-contract.mjs' const MODULE = 'truck-appointments' const OPTIONS = { scenario: 'booking-level-truck-appointment-lifecycle', deploymentBlocker: true } @@ -9,6 +10,9 @@ const FORBIDDEN_TRUCK_FIELDS = ['containerBookingId', 'loadingPlanId', 'containe function id(value) { return value == null ? '' : String(value) } function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } +function responseEvidence(response) { + return `HTTP=${response?.http ?? 'missing'}, code=${codeOf(response)}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` +} function data(response) { return codeOf(response) === '200' ? response.body.data : null } function failure(response) { return response?.http === 0 || response?.http >= 400 || (response?.http === 200 && codeOf(response) !== '200') } function records(value) { return Array.isArray(value) ? value : value?.records || [] } @@ -201,8 +205,10 @@ export async function run(context) { const appointmentId = id(appointment?.id); if (!appointmentId) return const path = `${TRUCKS}/${appointmentId}` context.assert(MODULE, '创建响应完全无柜次协议且费用正确', createdResponse, - !hasForbiddenTruckFields(appointment) && appointment?.currency === 'RMB' && Number(appointment?.payableAmount) === 0, - `appointmentNo=${appointment?.appointmentNo || 'missing'}`, OPTIONS) + truckAppointmentFieldGaps(appointment).length === 0 + && !hasForbiddenTruckFields(appointment) && appointment?.currency === 'RMB' + && Number(appointment?.payableAmount) === 0, + `appointmentNo=${appointment?.appointmentNo || 'missing'}, fieldGaps=${truckAppointmentFieldGaps(appointment).join('|') || 'none'}`, OPTIONS) const afterCreateCandidate = await candidate(context, identity) context.assert(MODULE, '创建后订舱立即退出候选', afterCreateCandidate.response, !afterCreateCandidate.item, `appointmentNo=${appointment.appointmentNo}`, OPTIONS) @@ -219,12 +225,45 @@ export async function run(context) { `dispatch-order-${identity.suffix}.pdf`) const uploadAttachmentResponse = await context.requestMultipart('POST', `${path}/attachments/upload`, attachmentForm, context.token) - const attachment = context.apiSuccess(MODULE, '上传DISPATCH_ORDER拖车附件', uploadAttachmentResponse, '', OPTIONS) + const attachment = context.apiSuccess(MODULE, '上传并关联DISPATCH_ORDER拖车附件', uploadAttachmentResponse, '', OPTIONS) const attachmentListResponse = await request(context, 'GET', `${path}/attachments`) const attachmentList = context.apiSuccess(MODULE, '查询拖车附件列表', attachmentListResponse, '', OPTIONS) context.assert(MODULE, '附件列表包含本轮运营附件', attachmentListResponse, records(attachmentList).some((item) => id(item?.id) === id(attachment?.id) && item?.attachmentType === 'DISPATCH_ORDER'), `attachmentId=${id(attachment?.id) || 'missing'}`, OPTIONS) + if (!attachment?.id || !attachment?.fileId) return + const deleteUploadedResponse = await request(context, 'DELETE', + `${path}/attachments/${encodeURIComponent(id(attachment?.id))}`) + context.addStep(MODULE, '删除首次上传的拖车附件引用', deleteUploadedResponse, + deleteUploadedResponse.http === 200 && codeOf(deleteUploadedResponse) === '200', + responseEvidence(deleteUploadedResponse), OPTIONS) + const referencedResponse = await request(context, 'POST', `${path}/attachments`, { + fileId: id(attachment?.fileId), attachmentType: 'DISPATCH_ORDER', + fileName: attachment?.fileName || `dispatch-order-${identity.suffix}.pdf`, + fileHash: attachment?.fileHash || null, + }) + const referenced = context.apiSuccess(MODULE, '重新引用平台归属拖车附件', referencedResponse, '', OPTIONS) + const referencedListResponse = await request(context, 'GET', `${path}/attachments`) + const referencedList = context.apiSuccess(MODULE, '回查重新引用的拖车附件', referencedListResponse, '', OPTIONS) + context.assert(MODULE, '附件列表包含重新引用的DISPATCH_ORDER', referencedListResponse, + records(referencedList).some((item) => id(item?.id) === id(referenced?.id) + && id(item?.fileId) === id(attachment?.fileId) && item?.attachmentType === 'DISPATCH_ORDER'), + `attachmentId=${id(referenced?.id) || 'missing'}, fileId=${id(attachment?.fileId) || 'missing'}`, OPTIONS) + const deleteReferencedResponse = await request(context, 'DELETE', + `${path}/attachments/${encodeURIComponent(id(referenced?.id))}`) + context.addStep(MODULE, '删除重新引用的拖车附件', deleteReferencedResponse, + deleteReferencedResponse.http === 200 && codeOf(deleteReferencedResponse) === '200', + responseEvidence(deleteReferencedResponse), OPTIONS) + const replacementForm = new FormData(); replacementForm.append('attachmentType', 'DISPATCH_ORDER') + replacementForm.append('file', new Blob([minimalPdfBytes(`${context.marker}-DISPATCH_ORDER-REPLACEMENT`)], + { type: 'application/pdf' }), `dispatch-order-replacement-${identity.suffix}.pdf`) + const replacementResponse = await context.requestMultipart('POST', `${path}/attachments/upload`, + replacementForm, context.token) + const replacement = context.apiSuccess(MODULE, '恢复一份DISPATCH_ORDER拖车附件供后续状态流转', + replacementResponse, '', OPTIONS) + context.assert(MODULE, '恢复附件使用新引用且仍指向平台文件', replacementResponse, + Boolean(id(replacement?.id) && id(replacement?.fileId)) && id(replacement?.id) !== id(attachment?.id), + `attachmentId=${id(replacement?.id) || 'missing'}, fileId=${id(replacement?.fileId) || 'missing'}`, OPTIONS) const editedResponse = await request(context, 'PUT', path, truckAppointmentPayload(context, identity.suffix, identity, Number(appointment.version), { currency: 'USD', payableAmount: 12.34 })) @@ -254,9 +293,10 @@ export async function run(context) { const finalDetailResponse = await request(context, 'GET', path) const finalDetail = context.apiSuccess(MODULE, '回查COMPLETED订舱级拖车详情', finalDetailResponse, '', OPTIONS) context.assert(MODULE, '完整生命周期完成且详情无柜次协议', finalDetailResponse, - finalDetail?.status === 'COMPLETED' && !hasForbiddenTruckFields(finalDetail) + finalDetail?.status === 'COMPLETED' && truckAppointmentFieldGaps(finalDetail).length === 0 + && !hasForbiddenTruckFields(finalDetail) && id(finalDetail?.oceanBookingId) === identity.oceanBookingId, - `appointmentNo=${finalDetail?.appointmentNo || 'missing'}, status=${finalDetail?.status || 'missing'}`, OPTIONS) + `appointmentNo=${finalDetail?.appointmentNo || 'missing'}, status=${finalDetail?.status || 'missing'}, fieldGaps=${truckAppointmentFieldGaps(finalDetail).join('|') || 'none'}`, OPTIONS) const afterCompleteCandidate = await candidate(context, identity) context.assert(MODULE, 'COMPLETED后订舱恢复候选', afterCompleteCandidate.response, Boolean(afterCompleteCandidate.item), `oceanBookingNo=${identity.oceanBookingNo}`, OPTIONS) diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index d57e476ad..0e09e69e1 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -221,6 +221,8 @@ Assert-Path 'apps/client-web/src/modules/first-leg/components/InboundBillingPane # 称重确认后的整票库位块和智能装柜暂估数量已成为跨端权威事实,索引必须直达并锁住回退顺序。 $measurementLocationOccupationService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementLocationOccupationService.java') +$inboundMeasurementService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java') +$measurementSubmissionService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementSubmissionService.java') $smartLoadingService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java') $inboundSchema = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/db/phase2-inbound-schema.sql') $adminWeightPanel = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/inbounds/weight/components/WeightOcrPanel.vue') @@ -228,10 +230,17 @@ $clientOrdersView = Read-RequiredText (Join-Path $workspaceRoot 'apps/client-web Assert-Contains $rootAgents '称重确认、整票库位块与部分拣货资源结算' '根称重库位块路由' Assert-Contains $backendAgents '称重确认、整票库位块与 PDA 资源结算' '后端称重库位块路由' Assert-Contains $firstlegAgents 'MeasurementLocationOccupationService.java' 'Firstleg 称重库位块入口' -Assert-Contains $firstlegAgents '实测体积、测量批次冻结申报体积、Order 最新客户申报体积' 'Firstleg 库位块体积回退顺序' +Assert-Contains $firstlegAgents '最新测量批次的正数 `totalVolumeCbm/originalVolumeCbm` 取较大值' 'Firstleg 库位块测量批次体积选择' +Assert-Contains $firstlegAgents '再无有效值才查询 Order 最新客户申报体积' 'Firstleg 库位块体积回退顺序' +Assert-Contains $firstlegAgents '库位指引失败只记录警告,不阻断称重确认' 'Firstleg 称重库位非阻断语义' +Assert-Contains $firstlegAgents '`ensureForSubmission` 再次补齐和校验' 'Firstleg 提交库位补偿入口' Assert-Contains $firstlegAgents '槽位与称重唯一键必须包含 `inbound_id`' 'Firstleg 部分拣货快照身份' Assert-Contains $adminAgents '`locationCode/slotStatus/sharedOverflow`' 'Admin 权威称重库位状态' Assert-Contains $measurementLocationOccupationService 'orderCargo.measurementDeclaration' 'Firstleg Order 申报体积回退' +Assert-Contains $measurementLocationOccupationService 'measuredVolume.max(declaredVolume)' 'Firstleg 测量批次体积取较大值' +Assert-Contains $measurementLocationOccupationService 'ensureForSubmission' 'Firstleg 提交库位补齐实现' +Assert-Contains $inboundMeasurementService 'ensureLocationWithoutBlocking' 'Firstleg 称重库位非阻断实现' +Assert-Contains $measurementSubmissionService 'locations.ensureForSubmission' 'Firstleg 提交库位补齐调用' Assert-Contains $firstlegOpenApi '测量批次冻结的申报体积、Order 最新客户申报体积' '称重确认体积回退 OpenAPI' Assert-Contains $inboundSchema 'UNIQUE KEY uk_pallet_allocation_slot (tenant_id, inbound_id, block_id, slot_no)' '部分拣货块槽快照唯一键' Assert-Contains $inboundSchema 'UNIQUE KEY uk_pallet_allocation_weight (tenant_id, inbound_id, weight_result_id)' '部分拣货称重快照唯一键' From c55f2aed11a06bb6aa678ebf5bfb5471b175c5aa Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 4 Sep 2026 15:24:45 +0800 Subject: [PATCH 3/7] test: add pallet location http matrix --- scripts/README.md | 13 + ...tion-test-env-acceptance.contract.test.mjs | 84 +++++++ .../pallet-location-test-env-acceptance.mjs | 238 ++++++++++++++++++ 3 files changed, 335 insertions(+) create mode 100644 scripts/pallet-location-test-env-acceptance.contract.test.mjs create mode 100644 scripts/pallet-location-test-env-acceptance.mjs diff --git a/scripts/README.md b/scripts/README.md index 5058291d1..4dd6b2a24 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -63,6 +63,7 @@ Client 全量、Client 二期、待审核订单和批量待分柜均复用这套 | 分类 | 入口 | 用途与副作用 | |---|---|---| | 测试环境 HTTP | `admin-test-env-acceptance.mjs` | Admin 全模块真实 HTTP 验收;按模块创建、更新、停用测试数据 | +| 测试环境 HTTP | `pallet-location-test-env-acceptance.mjs` | 订单测量与连续库位扩展矩阵;串行创建 5 票独立订单,覆盖 1/2/3 张称重图、体积跨档、删图释放、补传复用和跨票隔离,不主动停用库位制造 A0 | | 测试环境 HTTP | `client-test-env-acceptance.mjs` | Client 完整真实 HTTP 链路;写入客户、订单、入库、权限和消息数据 | | 测试环境 HTTP | `client-quick-order-test-env-acceptance.mjs` | Client 快速下单定向真实 HTTP 验收;使用既有测试客户创建 FBA、海外仓和私人地址草稿,验证逐行失败与幂等重放,不提交订单 | | 测试环境 HTTP | `client-phase2-test-env-acceptance.mjs` | Client 二期入库、测量、费用和权限专项;写测试数据 | @@ -123,6 +124,18 @@ node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env" 报告写入 `reports/client-quick-order-test-env-*.json`。脚本不提交、不审核、不取消订单,成功草稿保留为可丢弃测试数据;真实 HTTP 仅在当前轮明确授权测试环境验收后执行。 +## 订单测量与连续库位扩展矩阵 + +部署完成后,可用独立入口串行执行订单创建、审核建入库单、称重确认、库位指引、删除释放、补传复用、测量提交/审核至 `WAIT_SPLIT`。默认固定“义乌仓”并执行 5 个确定性场景:单图单槽、单图两预留槽、实测体积跨档、两图逐次扩容、三图连续扩容。每票使用独立订单、入库单、库位块和 reservation,最后额外验证跨票不串数据。 + +```powershell +node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env" scripts/pallet-location-test-env-acceptance.mjs +``` + +只回归部分场景时使用 `--scenarios single-image-single-slot,measured-volume-growth`;仓库可用 `--origin-warehouse-name <名称>` 覆盖。汇总默认写入 `reports/pallet-location-http-matrix-*.json`,每票完整证据写入相邻的 `*-details/` 目录。任一场景失败后仍继续执行剩余场景,最终以非零退出码和汇总报告统一暴露失败。 + +该入口只复用正式 HTTP 验收模块,不执行 SQL、不部署服务,也不会为了制造容量不足而批量停用普通库位;普通库位自然不足时记录是否落到共享 `A0`。共享 Warehouse 故障不做停服注入,其“不回滚测量主流程”语义由服务单元测试覆盖。真实 HTTP 仅在当前轮明确授权测试环境验收后执行。 + ## 固定客户待审核订单 需要为人工审核准备固定数量的订单时,使用 `create-pending-review-orders.mjs`。Gateway 统一读取 `ADMIN_TEST_GATEWAY`,客户账号统一读取 `MERCHANT_TEST_USERNAME/PASSWORD`;数量可通过 `--count` 或 `PENDING_REVIEW_ORDER_COUNT` 设置,默认 50。脚本不会创建客户,也不会调用平台审核、入库或后续履约接口。数量支持 `1-100`,每张订单创建后立即提交并回查客户端状态为 `SUBMITTED`,报告统一记录为 `PENDING_REVIEW`。 diff --git a/scripts/pallet-location-test-env-acceptance.contract.test.mjs b/scripts/pallet-location-test-env-acceptance.contract.test.mjs new file mode 100644 index 000000000..46374bed6 --- /dev/null +++ b/scripts/pallet-location-test-env-acceptance.contract.test.mjs @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' + +import { + PALLET_LOCATION_SCENARIOS, + inspectCrossScenarioIsolation, + inspectScenarioReport, + parseMatrixArguments, +} from './pallet-location-test-env-acceptance.mjs' + +const source = await readFile(new URL('./pallet-location-test-env-acceptance.mjs', import.meta.url), 'utf8') + +test('pallet location matrix declares deterministic one, two, and three-image scenarios', () => { + assert.equal(PALLET_LOCATION_SCENARIOS.length, 5) + assert.deepEqual(PALLET_LOCATION_SCENARIOS.map((item) => item.expectedPalletCount), [1, 1, 1, 2, 3]) + assert.match(source, /--modules', 'orders,inbound-measurement'/) + assert.match(source, /--verify-pallet-location-guide', 'true'/) + assert.match(source, /--wait-split-package-count', '18'/) + assert.match(source, /--wait-split-package-count', '12'/) + assert.match(source, /--wait-split-package-count', '61'/) + assert.match(source, /第3张确认后扩容只追加且既有槽身份不漂移/) +}) + +test('matrix options select known scenarios without duplicate or unknown names', () => { + const parsed = parseMatrixArguments([ + '--origin-warehouse-name', '测试仓', + '--scenarios', 'single-image-single-slot,two-image-incremental-growth', + '--output=reports/custom.json', + ]) + assert.equal(parsed.originWarehouseName, '测试仓') + assert.deepEqual(parsed.scenarioNames, ['single-image-single-slot', 'two-image-incremental-growth']) + assert.equal(parsed.output, 'reports/custom.json') + assert.throws(() => parseMatrixArguments(['--scenarios', 'unknown']), /未知场景/) + assert.throws(() => parseMatrixArguments(['--scenarios', 'measured-volume-growth,measured-volume-growth']), /重复场景/) +}) + +test('scenario inspection requires deletion release, refill reuse, WAIT_SPLIT, and guide identity', () => { + const scenario = PALLET_LOCATION_SCENARIOS[0] + const requiredNames = [ + '删除只解绑图片且原 reservation/location 变为 RESERVED_EMPTY', + '替代图片按新 resultId 复用原空槽', + '固定客户第 1 单停在待分柜', + '报告敏感信息扫描', + ] + const report = { + success: true, + marker: 'TEST', + summary: { total: 10, failed: 0 }, + createdResources: { + inboundMeasurement: { inboundId: '11', measurementId: '12', palletCount: 1, processStatus: 'WAIT_SPLIT' }, + inboundMeasurementLocationGuide: { + inboundId: '11', blockId: '13', reservationId: '14', locationId: '15', + deleteSucceeded: true, slotReused: true, + }, + }, + steps: [ + ...requiredNames.map((name) => ({ name, passed: true })), + { name: '第1张确认后整票库位块计数与槽身份完整', passed: true, + evidence: 'blockId=13, reserved=1, actual=1, empty=0, slots=1' }, + ], + } + const inspected = inspectScenarioReport(scenario, report, 0) + assert.equal(inspected.passed, true) + report.steps.find((step) => step.name.startsWith('删除只解绑')).passed = false + assert.equal(inspectScenarioReport(scenario, report, 0).passed, false) +}) + +test('cross-scenario inspection permits shared A0 location but rejects shared reservation or block', () => { + const results = [ + { resources: { inboundId: '1', blockId: '11', reservationId: '21', locationId: 'A0' } }, + { resources: { inboundId: '2', blockId: '12', reservationId: '22', locationId: 'A0' } }, + ] + assert.equal(inspectCrossScenarioIsolation(results).passed, true) + results[1].resources.reservationId = '21' + assert.equal(inspectCrossScenarioIsolation(results).passed, false) +}) + +test('matrix does not disable locations, execute SQL, deploy, or use a shell child process', () => { + assert.doesNotMatch(source, /location-status|batch-disable|UPDATE\s+|DELETE\s+FROM|INSERT\s+INTO/i) + assert.doesNotMatch(source, /jenkins|docker\s+compose|kubectl/i) + assert.match(source, /shell: false/) + assert.match(source, /A0 仅在普通库位自然不足时观测/) +}) diff --git a/scripts/pallet-location-test-env-acceptance.mjs b/scripts/pallet-location-test-env-acceptance.mjs new file mode 100644 index 000000000..475d46669 --- /dev/null +++ b/scripts/pallet-location-test-env-acceptance.mjs @@ -0,0 +1,238 @@ +#!/usr/bin/env node + +import { spawn } from 'node:child_process' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { basename, dirname, extname, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import process from 'node:process' + +export const PALLET_LOCATION_SCENARIOS = Object.freeze([ + { + name: 'single-image-single-slot', + description: '1 箱、1 张称重图,验证最小库位块与删除补传复用', + expectedPalletCount: 1, + args: ['--wait-split-package-count', '1', '--wait-split-random-value', '4'], + expectedGuideEvidence: /reserved=1, actual=1, empty=0, slots=1/, + }, + { + name: 'single-image-two-reserved-slots', + description: '18 箱、1 张称重图,验证体积预留 2 个连续槽且空槽不阻塞', + expectedPalletCount: 1, + args: ['--wait-split-package-count', '18', '--wait-split-random-value', '74'], + expectedGuideEvidence: /reserved=2, actual=1, empty=1, slots=2/, + }, + { + name: 'measured-volume-growth', + description: '12 箱、1 张称重图,验证实测体积跨档后扩为 2 个槽', + expectedPalletCount: 1, + args: ['--wait-split-package-count', '12', '--wait-split-random-value', '87'], + expectedGuideEvidence: /reserved=2, actual=1, empty=1, slots=2/, + }, + { + name: 'two-image-incremental-growth', + description: '2 张称重图,验证第二张只追加槽且原槽身份不漂移', + expectedPalletCount: 2, + args: ['--route-mode', 'random', '--wait-split-random-value', '1'], + requiredSteps: ['第2张确认后扩容只追加且既有槽身份不漂移'], + }, + { + name: 'three-image-incremental-growth', + description: '61 箱、3 张称重图,验证连续两次增托与既有槽身份稳定', + expectedPalletCount: 3, + args: ['--wait-split-package-count', '61', '--wait-split-random-value', '1'], + requiredSteps: [ + '第2张确认后扩容只追加且既有槽身份不漂移', + '第3张确认后扩容只追加且既有槽身份不漂移', + ], + }, +]) + +const COMMON_REQUIRED_STEPS = Object.freeze([ + '删除只解绑图片且原 reservation/location 变为 RESERVED_EMPTY', + '替代图片按新 resultId 复用原空槽', + '固定客户第 1 单停在待分柜', + '报告敏感信息扫描', +]) + +export function parseMatrixArguments(argv) { + const result = { + help: false, + originWarehouseName: '义乌仓', + scenarioNames: PALLET_LOCATION_SCENARIOS.map((item) => item.name), + output: '', + } + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index] + if (argument === '--help') { result.help = true; continue } + if (!argument.startsWith('--')) throw new Error(`无法识别的参数: ${argument}`) + const separator = argument.indexOf('=') + const name = separator >= 0 ? argument.slice(2, separator) : argument.slice(2) + const inline = separator >= 0 ? argument.slice(separator + 1) : undefined + const value = inline === undefined ? argv[++index] : inline + if (!value || value.startsWith('--')) throw new Error(`参数 --${name} 缺少值`) + if (name === 'origin-warehouse-name') result.originWarehouseName = value.trim() + else if (name === 'scenarios') result.scenarioNames = value.split(',').map((item) => item.trim()).filter(Boolean) + else if (name === 'output') result.output = value + else throw new Error(`无法识别的参数: --${name}`) + } + if (!result.originWarehouseName) throw new Error('origin-warehouse-name不能为空') + if (result.scenarioNames.length === 0) throw new Error('scenarios至少包含一个场景') + const available = new Set(PALLET_LOCATION_SCENARIOS.map((item) => item.name)) + const unknown = result.scenarioNames.filter((name) => !available.has(name)) + if (unknown.length > 0) throw new Error(`未知场景: ${unknown.join(',')}`) + if (new Set(result.scenarioNames).size !== result.scenarioNames.length) { + throw new Error('scenarios不能包含重复场景') + } + return result +} + +export function inspectScenarioReport(scenario, report, exitCode = 0) { + const steps = Array.isArray(report?.steps) ? report.steps : [] + const stepByName = new Map(steps.map((step) => [step.name, step])) + const requiredSteps = [...COMMON_REQUIRED_STEPS, ...(scenario.requiredSteps || [])] + const missingOrFailedSteps = requiredSteps.filter((name) => stepByName.get(name)?.passed !== true) + const measurement = report?.createdResources?.inboundMeasurement || {} + const guide = report?.createdResources?.inboundMeasurementLocationGuide || {} + const guideEvidence = steps + .filter((step) => /整票库位块计数与槽身份完整$/.test(String(step.name || ''))) + .map((step) => String(step.evidence || '')) + const guideEvidenceMatched = !scenario.expectedGuideEvidence + || guideEvidence.some((evidence) => scenario.expectedGuideEvidence.test(evidence)) + const assertions = { + childExitCode: exitCode === 0, + childReportSuccess: report?.success === true, + requiredSteps: missingOrFailedSteps.length === 0, + expectedPalletCount: Number(measurement.palletCount) === scenario.expectedPalletCount, + waitSplitReached: measurement.processStatus === 'WAIT_SPLIT', + locationIdentityRecorded: Boolean(guide.blockId && guide.reservationId && guide.locationId), + deletedSlotReused: guide.deleteSucceeded === true && guide.slotReused === true, + expectedGuideShape: guideEvidenceMatched, + } + const passed = Object.values(assertions).every(Boolean) + const overflowStep = steps.find((step) => String(step.name || '').includes('A0共享溢出位状态可见')) + return { + name: scenario.name, + description: scenario.description, + passed, + assertions, + missingOrFailedSteps, + summary: report?.summary || null, + resources: { + marker: report?.marker || null, + inboundId: measurement.inboundId || guide.inboundId || null, + measurementId: measurement.measurementId || null, + blockId: guide.blockId || null, + reservationId: guide.reservationId || null, + locationId: guide.locationId || null, + palletCount: measurement.palletCount ?? null, + processStatus: measurement.processStatus || null, + sharedOverflowObserved: /sharedOverflow=true/.test(String(overflowStep?.evidence || '')), + }, + } +} + +export function inspectCrossScenarioIsolation(results) { + const fields = ['inboundId', 'blockId', 'reservationId'] + const assertions = Object.fromEntries(fields.map((field) => { + const values = results.map((result) => result.resources?.[field]).filter(Boolean).map(String) + return [`unique${field[0].toUpperCase()}${field.slice(1)}`, values.length === results.length + && new Set(values).size === values.length] + })) + return { passed: Object.values(assertions).every(Boolean), assertions } +} + +function usage() { + console.log(`用法: + node scripts/pallet-location-test-env-acceptance.mjs [选项] + +选项: + --origin-warehouse-name <名称> 固定国内收货仓,默认“义乌仓” + --scenarios <逗号列表> 只执行指定场景,默认执行全部 + --output <报告路径> 汇总报告路径;明细写入同名 -details 目录 + --help 显示帮助 + +可选场景: + ${PALLET_LOCATION_SCENARIOS.map((item) => `${item.name}: ${item.description}`).join('\n ')}`) +} + +async function runChild(scriptPath, args) { + return await new Promise((resolvePromise) => { + const child = spawn(process.execPath, [scriptPath, ...args], { + cwd: process.cwd(), env: process.env, stdio: 'inherit', shell: false, + }) + child.once('error', (error) => resolvePromise({ exitCode: 1, error: error.message })) + child.once('exit', (code, signal) => resolvePromise({ + exitCode: Number.isInteger(code) ? code : 1, + error: signal ? `子进程被信号 ${signal} 终止` : null, + })) + }) +} + +async function main() { + const options = parseMatrixArguments(process.argv.slice(2)) + if (options.help) { usage(); return } + const timestamp = new Date().toISOString().replace(/\D/g, '').slice(0, 14) + const outputPath = resolve(options.output || `reports/pallet-location-http-matrix-${timestamp}.json`) + const extension = extname(outputPath) + const detailsName = `${basename(outputPath, extension)}-details` + const detailsDir = resolve(dirname(outputPath), detailsName) + const runnerPath = resolve('scripts/admin-test-env-acceptance.mjs') + await mkdir(detailsDir, { recursive: true }) + + const selected = options.scenarioNames.map((name) => + PALLET_LOCATION_SCENARIOS.find((scenario) => scenario.name === name)) + const startedAt = new Date().toISOString() + const results = [] + for (const scenario of selected) { + const scenarioReportPath = resolve(detailsDir, `${scenario.name}.json`) + console.log(`\n[PALLET-LOCATION] START ${scenario.name}: ${scenario.description}`) + const child = await runChild(runnerPath, [ + '--modules', 'orders,inbound-measurement', + '--origin-warehouse-name', options.originWarehouseName, + '--verify-pallet-location-guide', 'true', + '--output', scenarioReportPath, + ...scenario.args, + ]) + let report = null + let reportReadError = child.error + try { report = JSON.parse(await readFile(scenarioReportPath, 'utf8')) } + catch (error) { reportReadError ||= error instanceof Error ? error.message : String(error) } + const inspected = inspectScenarioReport(scenario, report, child.exitCode) + results.push({ ...inspected, reportPath: scenarioReportPath, reportReadError }) + console.log(`[PALLET-LOCATION] ${inspected.passed ? 'PASS' : 'FAIL'} ${scenario.name}`) + } + + const isolation = inspectCrossScenarioIsolation(results) + const success = results.every((result) => result.passed) && isolation.passed + const aggregate = { + reportName: '订单测量与连续库位真实 HTTP 扩展矩阵', + generatedAt: new Date().toISOString(), startedAt, success, + originWarehouseName: options.originWarehouseName, + summary: { + totalScenarios: results.length, + passedScenarios: results.filter((result) => result.passed).length, + failedScenarios: results.filter((result) => !result.passed).length, + sharedOverflowObserved: results.some((result) => result.resources.sharedOverflowObserved), + }, + isolation, + results, + coverageNotes: [ + '每个场景使用独立订单、入库单和库位块,并验证跨场景 reservation 不串票。', + 'A0 仅在普通库位自然不足时观测;脚本不会批量停用普通库位制造容量不足。', + 'Warehouse 调用异常时不回滚订单测量主流程由服务单元测试覆盖;真实 HTTP 矩阵不关闭共享服务做故障注入。', + '脚本不执行 SQL、不部署服务、不直接操作数据库。', + ], + } + await mkdir(dirname(outputPath), { recursive: true }) + await writeFile(outputPath, `${JSON.stringify(aggregate, null, 2)}\n`, 'utf8') + console.log(`\nREPORT=${outputPath}`) + console.log(`RESULT=${success ? 'PASS' : 'FAIL'} scenarios=${results.length} passed=${aggregate.summary.passedScenarios} failed=${aggregate.summary.failedScenarios}`) + process.exitCode = success ? 0 : 1 +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)) + process.exitCode = 1 + }) +} From c8c402ffc5cac70c93561088524cb03a097884f7 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 4 Sep 2026 15:29:06 +0800 Subject: [PATCH 4/7] test: detect shared overflow evidence --- ...let-location-test-env-acceptance.contract.test.mjs | 11 +++++++++++ scripts/pallet-location-test-env-acceptance.mjs | 5 +++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/scripts/pallet-location-test-env-acceptance.contract.test.mjs b/scripts/pallet-location-test-env-acceptance.contract.test.mjs index 46374bed6..5d1141170 100644 --- a/scripts/pallet-location-test-env-acceptance.contract.test.mjs +++ b/scripts/pallet-location-test-env-acceptance.contract.test.mjs @@ -66,6 +66,17 @@ test('scenario inspection requires deletion release, refill reuse, WAIT_SPLIT, a assert.equal(inspectScenarioReport(scenario, report, 0).passed, false) }) +test('scenario inspection detects naturally observed shared A0 from evidence without coupling to step wording', () => { + const scenario = PALLET_LOCATION_SCENARIOS[0] + const report = { + success: false, + createdResources: {}, + steps: [{ name: '正式数据 A0 共享溢出观测', passed: true, + evidence: 'sharedOverflow=true, slots=1:reservation:A0' }], + } + assert.equal(inspectScenarioReport(scenario, report, 1).resources.sharedOverflowObserved, true) +}) + test('cross-scenario inspection permits shared A0 location but rejects shared reservation or block', () => { const results = [ { resources: { inboundId: '1', blockId: '11', reservationId: '21', locationId: 'A0' } }, diff --git a/scripts/pallet-location-test-env-acceptance.mjs b/scripts/pallet-location-test-env-acceptance.mjs index 475d46669..ba3dc874d 100644 --- a/scripts/pallet-location-test-env-acceptance.mjs +++ b/scripts/pallet-location-test-env-acceptance.mjs @@ -109,7 +109,8 @@ export function inspectScenarioReport(scenario, report, exitCode = 0) { expectedGuideShape: guideEvidenceMatched, } const passed = Object.values(assertions).every(Boolean) - const overflowStep = steps.find((step) => String(step.name || '').includes('A0共享溢出位状态可见')) + const sharedOverflowObserved = steps.some((step) => + /sharedOverflow=true/.test(String(step.evidence || ''))) return { name: scenario.name, description: scenario.description, @@ -126,7 +127,7 @@ export function inspectScenarioReport(scenario, report, exitCode = 0) { locationId: guide.locationId || null, palletCount: measurement.palletCount ?? null, processStatus: measurement.processStatus || null, - sharedOverflowObserved: /sharedOverflow=true/.test(String(overflowStep?.evidence || '')), + sharedOverflowObserved, }, } } From 7babcb9e0d4229d718a30f0e0f3a5d6d1939ef24 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 4 Sep 2026 15:29:43 +0800 Subject: [PATCH 5/7] test: expand truck attachment acceptance contract --- .../truck-appointments-contract.test.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs b/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs index 9e90ca778..31654964d 100644 --- a/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs +++ b/scripts/admin-test-env-modules/truck-appointments-contract.test.mjs @@ -50,7 +50,9 @@ test('fixture creates and releases booking without creating or linking a contain test('HTTP lifecycle covers uniqueness idempotency attachments and every action', () => { for (const label of ['创建后订舱立即退出候选', '同订舱第二张非终态拖车单冲突', - '重复创建未改变版本和状态', '上传DISPATCH_ORDER拖车附件', '查询拖车附件列表', + '重复创建未改变版本和状态', '上传并关联DISPATCH_ORDER拖车附件', '查询拖车附件列表', + '删除首次上传的拖车附件引用', '重新引用平台归属拖车附件', + '删除重新引用的拖车附件', '恢复一份DISPATCH_ORDER拖车附件供后续状态流转', '编辑订舱级拖车为USD应付费用', '整单推进SUBMIT', 'SUBMIT同键同载荷重放', 'SUBMIT重放版本零漂移', 'SUBMIT同键异载荷A0409', 'COMPLETED后订舱恢复候选', 'COMPLETED后同订舱可新建下一张拖车单', From 9c96bdfaa842a71cc68ca76c7ee5834300e0c479 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 4 Sep 2026 15:30:59 +0800 Subject: [PATCH 6/7] test: require incremental pallet growth --- scripts/README.md | 2 +- ...tion-test-env-acceptance.contract.test.mjs | 30 ++++++++++++++++++- .../pallet-location-test-env-acceptance.mjs | 13 ++++++-- 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/scripts/README.md b/scripts/README.md index 4dd6b2a24..6ed0c980d 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -126,7 +126,7 @@ node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env" ## 订单测量与连续库位扩展矩阵 -部署完成后,可用独立入口串行执行订单创建、审核建入库单、称重确认、库位指引、删除释放、补传复用、测量提交/审核至 `WAIT_SPLIT`。默认固定“义乌仓”并执行 5 个确定性场景:单图单槽、单图两预留槽、实测体积跨档、两图逐次扩容、三图连续扩容。每票使用独立订单、入库单、库位块和 reservation,最后额外验证跨票不串数据。 +部署完成后,可用独立入口串行执行订单创建、审核建入库单、称重确认、库位指引、删除释放、补传复用、测量提交/审核至 `WAIT_SPLIT`。默认固定“义乌仓”并执行 5 个确定性场景:单图单槽、单图两预留槽、实测体积跨档、两图逐次扩容、小体积订单三图 `1→2→3` 连续扩容。每票使用独立订单、入库单、库位块和 reservation,最后额外验证跨票不串数据。 ```powershell node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env" scripts/pallet-location-test-env-acceptance.mjs diff --git a/scripts/pallet-location-test-env-acceptance.contract.test.mjs b/scripts/pallet-location-test-env-acceptance.contract.test.mjs index 5d1141170..579099afd 100644 --- a/scripts/pallet-location-test-env-acceptance.contract.test.mjs +++ b/scripts/pallet-location-test-env-acceptance.contract.test.mjs @@ -18,8 +18,10 @@ test('pallet location matrix declares deterministic one, two, and three-image sc assert.match(source, /--verify-pallet-location-guide', 'true'/) assert.match(source, /--wait-split-package-count', '18'/) assert.match(source, /--wait-split-package-count', '12'/) - assert.match(source, /--wait-split-package-count', '61'/) + assert.match(source, /--route-mode', 'random', '--wait-split-random-value', '2'/) assert.match(source, /第3张确认后扩容只追加且既有槽身份不漂移/) + assert.match(source, /beforeSlots=1, afterSlots=2/) + assert.match(source, /beforeSlots=2, afterSlots=3/) }) test('matrix options select known scenarios without duplicate or unknown names', () => { @@ -77,6 +79,32 @@ test('scenario inspection detects naturally observed shared A0 from evidence wit assert.equal(inspectScenarioReport(scenario, report, 1).resources.sharedOverflowObserved, true) }) +test('three-image scenario rejects a no-op growth assertion even when the base step passed', () => { + const scenario = PALLET_LOCATION_SCENARIOS.at(-1) + const report = { + success: true, + createdResources: { + inboundMeasurement: { palletCount: 3, processStatus: 'WAIT_SPLIT' }, + inboundMeasurementLocationGuide: { + blockId: '1', reservationId: '2', locationId: '3', deleteSucceeded: true, slotReused: true, + }, + }, + steps: [ + { name: '删除只解绑图片且原 reservation/location 变为 RESERVED_EMPTY', passed: true }, + { name: '替代图片按新 resultId 复用原空槽', passed: true }, + { name: '固定客户第 1 单停在待分柜', passed: true }, + { name: '报告敏感信息扫描', passed: true }, + { name: '第2张确认后扩容只追加且既有槽身份不漂移', passed: true, + evidence: 'beforeSlots=3, afterSlots=3' }, + { name: '第3张确认后扩容只追加且既有槽身份不漂移', passed: true, + evidence: 'beforeSlots=3, afterSlots=3' }, + ], + } + const inspected = inspectScenarioReport(scenario, report, 0) + assert.equal(inspected.assertions.requiredStepEvidence, false) + assert.deepEqual(inspected.mismatchedStepEvidence, scenario.requiredSteps) +}) + test('cross-scenario inspection permits shared A0 location but rejects shared reservation or block', () => { const results = [ { resources: { inboundId: '1', blockId: '11', reservationId: '21', locationId: 'A0' } }, diff --git a/scripts/pallet-location-test-env-acceptance.mjs b/scripts/pallet-location-test-env-acceptance.mjs index ba3dc874d..39cd89d68 100644 --- a/scripts/pallet-location-test-env-acceptance.mjs +++ b/scripts/pallet-location-test-env-acceptance.mjs @@ -37,13 +37,17 @@ export const PALLET_LOCATION_SCENARIOS = Object.freeze([ }, { name: 'three-image-incremental-growth', - description: '61 箱、3 张称重图,验证连续两次增托与既有槽身份稳定', + description: '小体积订单上传 3 张称重图,强制验证库位块 1→2→3 连续扩容', expectedPalletCount: 3, - args: ['--wait-split-package-count', '61', '--wait-split-random-value', '1'], + args: ['--route-mode', 'random', '--wait-split-random-value', '2'], requiredSteps: [ '第2张确认后扩容只追加且既有槽身份不漂移', '第3张确认后扩容只追加且既有槽身份不漂移', ], + requiredStepEvidence: [ + { name: '第2张确认后扩容只追加且既有槽身份不漂移', pattern: /beforeSlots=1, afterSlots=2/ }, + { name: '第3张确认后扩容只追加且既有槽身份不漂移', pattern: /beforeSlots=2, afterSlots=3/ }, + ], }, ]) @@ -91,6 +95,9 @@ export function inspectScenarioReport(scenario, report, exitCode = 0) { const stepByName = new Map(steps.map((step) => [step.name, step])) const requiredSteps = [...COMMON_REQUIRED_STEPS, ...(scenario.requiredSteps || [])] const missingOrFailedSteps = requiredSteps.filter((name) => stepByName.get(name)?.passed !== true) + const mismatchedStepEvidence = (scenario.requiredStepEvidence || []) + .filter(({ name, pattern }) => !pattern.test(String(stepByName.get(name)?.evidence || ''))) + .map(({ name }) => name) const measurement = report?.createdResources?.inboundMeasurement || {} const guide = report?.createdResources?.inboundMeasurementLocationGuide || {} const guideEvidence = steps @@ -102,6 +109,7 @@ export function inspectScenarioReport(scenario, report, exitCode = 0) { childExitCode: exitCode === 0, childReportSuccess: report?.success === true, requiredSteps: missingOrFailedSteps.length === 0, + requiredStepEvidence: mismatchedStepEvidence.length === 0, expectedPalletCount: Number(measurement.palletCount) === scenario.expectedPalletCount, waitSplitReached: measurement.processStatus === 'WAIT_SPLIT', locationIdentityRecorded: Boolean(guide.blockId && guide.reservationId && guide.locationId), @@ -117,6 +125,7 @@ export function inspectScenarioReport(scenario, report, exitCode = 0) { passed, assertions, missingOrFailedSteps, + mismatchedStepEvidence, summary: report?.summary || null, resources: { marker: report?.marker || null, From 15b16634dbf0b80515cab0ec00a1a8ad50e446b0 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 4 Sep 2026 15:36:56 +0800 Subject: [PATCH 7/7] test: cover smart loading quantity operations --- .../admin-test-env-modules/phase3-loading.mjs | 60 +++++++++++++++++-- ...source-allocation-recent-contract.test.mjs | 10 +++- 2 files changed, 63 insertions(+), 7 deletions(-) diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 5ba1a6556..435c5d111 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -1336,6 +1336,20 @@ export async function run(context) { && authoritativeRemaining === authoritativeTotal - assignedPackageCount, `totalPackageCount=${reassignedPlanItem?.totalPackageCount ?? 'missing'}, packageCount=${reassignedPlanItem?.packageCount ?? 'missing'}, remainingPackageCount=${reassignedPlanItem?.remainingPackageCount ?? 'missing'}`, OPTIONS) + const assignedByBookingResponse = await context.request('GET', + `${smartLoadingBase}/plans/by-booking/${encodeURIComponent(containerBookingId)}`) + const assignedByBooking = context.apiSuccess(MODULE, '计划已有明细后按柜次读取智能装柜详情', + assignedByBookingResponse, '', OPTIONS) + const assignedByBookingItem = assignedByBooking?.items?.find?.((item) => + id(item?.id) === id(reassignedPlanItem?.id)) + context.assert(MODULE, '按柜次详情返回同一计划及权威箱数且数量守恒', assignedByBookingResponse, + id(assignedByBooking?.id) === loadingPlanId + && Number(assignedByBooking?.version) === Number(reassignedPlan?.version) + && Number(assignedByBookingItem?.packageCount) === assignedPackageCount + && Number(assignedByBookingItem?.totalPackageCount) === authoritativeTotal + && Number(assignedByBookingItem?.remainingPackageCount) === authoritativeRemaining, + `loadingPlanId=${id(assignedByBooking?.id) || 'missing'}, packageCount=${assignedByBookingItem?.packageCount ?? 'missing'}, totalPackageCount=${assignedByBookingItem?.totalPackageCount ?? 'missing'}, remainingPackageCount=${assignedByBookingItem?.remainingPackageCount ?? 'missing'}`, + OPTIONS) if (reassignedPlanItem?.id && Number.isInteger(authoritativeTotal) && authoritativeTotal > 0) { const overAuthorityResponse = await request(context, 'PATCH', `${smartLoadingBase}/plans/${loadingPlanId}/items/${encodeURIComponent(id(reassignedPlanItem.id))}`, { @@ -1343,8 +1357,8 @@ export async function run(context) { expectedItemVersion: Number(reassignedPlanItem.version), operationId: `${context.marker}-dynamic-over-authority`.slice(0, 128), }) - context.addStep(MODULE, '调整箱数超过服务端权威总箱数返回A0400', overAuthorityResponse, - overAuthorityResponse.http === 200 && codeOf(overAuthorityResponse) === 'A0400', + context.addStep(MODULE, '调整箱数超过服务端权威总箱数返回A0422', overAuthorityResponse, + overAuthorityResponse.http === 200 && codeOf(overAuthorityResponse) === 'A0422', responseEvidence(overAuthorityResponse), OPTIONS) const afterOverAuthorityResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`) const afterOverAuthority = context.apiSuccess(MODULE, '超权威总箱数失败后回查计划', @@ -1379,8 +1393,44 @@ export async function run(context) { && Number(adjustedItem?.packageCount) === 2 && adjustedItem?.warehouseRemark === reassignedPlanItem?.warehouseRemark && id(adjustedPlan?.pdaTaskId) === canonicalTaskId, `planVersion=${adjustedPlan?.version ?? 'missing'}, taskVersion=${adjustedPlan?.pdaTaskVersion ?? 'missing'}, itemVersion=${adjustedItem?.version ?? 'missing'}`, OPTIONS) - effectiveDynamicPlan = adjustedPlan - reassignedPlanItem = adjustedItem + const decreaseResponse = await request(context, 'PATCH', + `${smartLoadingBase}/plans/${loadingPlanId}/items/${encodeURIComponent(id(adjustedItem?.id))}`, { + packageCount: 1, expectedPlanVersion: Number(adjustedPlan?.version), + expectedItemVersion: Number(adjustedItem?.version), + operationId: `${context.marker}-dynamic-adjust-down`.slice(0, 128), + }) + const decreasedPlan = context.apiSuccess(MODULE, '未拣动态增量明细允许向下调整数量', + decreaseResponse, '', OPTIONS) + const decreasedItem = decreasedPlan?.items?.find?.((item) => id(item?.id) === id(adjustedItem?.id)) + context.assert(MODULE, '向下调整后版本递增且权威总数与剩余数同步', decreaseResponse, + Number(decreasedPlan?.version) > Number(adjustedPlan?.version) + && Number(decreasedItem?.version) > Number(adjustedItem?.version) + && Number(decreasedItem?.packageCount) === 1 + && Number(decreasedItem?.totalPackageCount) === authoritativeTotal + && Number(decreasedItem?.remainingPackageCount) === authoritativeTotal - 1 + && id(decreasedPlan?.pdaTaskId) === canonicalTaskId, + `planVersion=${decreasedPlan?.version ?? 'missing'}, itemVersion=${decreasedItem?.version ?? 'missing'}, packageCount=${decreasedItem?.packageCount ?? 'missing'}, remainingPackageCount=${decreasedItem?.remainingPackageCount ?? 'missing'}`, + OPTIONS) + const restoreAdjustedResponse = await request(context, 'PATCH', + `${smartLoadingBase}/plans/${loadingPlanId}/items/${encodeURIComponent(id(decreasedItem?.id))}`, { + packageCount: 2, expectedPlanVersion: Number(decreasedPlan?.version), + expectedItemVersion: Number(decreasedItem?.version), + operationId: `${context.marker}-dynamic-adjust-restore`.slice(0, 128), + }) + const restoredAdjustedPlan = context.apiSuccess(MODULE, '向下调整验证后恢复两箱供已拣下限校验', + restoreAdjustedResponse, '', OPTIONS) + const restoredAdjustedItem = restoredAdjustedPlan?.items?.find?.((item) => id(item?.id) === id(decreasedItem?.id)) + context.assert(MODULE, '恢复后版本递增且数量回到两箱', restoreAdjustedResponse, + Number(restoredAdjustedPlan?.version) > Number(decreasedPlan?.version) + && Number(restoredAdjustedItem?.version) > Number(decreasedItem?.version) + && Number(restoredAdjustedItem?.packageCount) === 2 + && Number(restoredAdjustedItem?.totalPackageCount) === authoritativeTotal + && Number(restoredAdjustedItem?.remainingPackageCount) === authoritativeTotal - 2 + && id(restoredAdjustedPlan?.pdaTaskId) === canonicalTaskId, + `planVersion=${restoredAdjustedPlan?.version ?? 'missing'}, itemVersion=${restoredAdjustedItem?.version ?? 'missing'}, packageCount=${restoredAdjustedItem?.packageCount ?? 'missing'}`, + OPTIONS) + effectiveDynamicPlan = restoredAdjustedPlan + reassignedPlanItem = restoredAdjustedItem } const refreshedDynamicTaskResponse = await context.request('GET', taskDetailPath) const refreshedDynamicTask = context.apiSuccess(MODULE, '重新增量后刷新 PDA task detail', refreshedDynamicTaskResponse, '', OPTIONS) @@ -1409,7 +1459,7 @@ export async function run(context) { const beforePickedMutationTask = context.apiSuccess(MODULE, '已拣项负向前读取任务快照', beforePickedMutationTaskResponse, '', OPTIONS) const reducePickedResponse = await request(context, 'PATCH', `${smartLoadingBase}/plans/${loadingPlanId}/items/${encodeURIComponent(id(pickedDynamicPlanItem?.id))}`, { - packageCount: Math.max(1, Number(pickedDynamicPlanItem?.minPackageCount) - 1), + packageCount: Number(pickedDynamicPlanItem?.minPackageCount) - 1, expectedPlanVersion: Number(pickedDynamicPlan?.version), expectedItemVersion: Number(pickedDynamicPlanItem?.version), operationId: `${context.marker}-dynamic-reduce-picked`.slice(0, 128), }) diff --git a/scripts/admin-test-env-modules/resource-allocation-recent-contract.test.mjs b/scripts/admin-test-env-modules/resource-allocation-recent-contract.test.mjs index 81acd3a0c..d93f08f2c 100644 --- a/scripts/admin-test-env-modules/resource-allocation-recent-contract.test.mjs +++ b/scripts/admin-test-env-modules/resource-allocation-recent-contract.test.mjs @@ -16,12 +16,18 @@ test('measurement HTTP acceptance preserves whole-ticket slot identities across assert.match(measurement, /asId\(current\.locationId\) === asId\(prior\.locationId\)/) }) -test('smart-loading HTTP acceptance uses authoritative totals and proves over-allocation zero drift', () => { +test('smart-loading HTTP acceptance covers by-booking reads and bidirectional quantity changes', () => { const loading = source('./phase3-loading.mjs') assert.match(loading, /计划详情由服务端返回权威总箱数和剩余箱数/) assert.match(loading, /authoritativeRemaining === authoritativeTotal - assignedPackageCount/) + assert.match(loading, /计划已有明细后按柜次读取智能装柜详情/) + assert.match(loading, /按柜次详情返回同一计划及权威箱数且数量守恒/) assert.match(loading, /packageCount: authoritativeTotal \+ 1/) - assert.match(loading, /调整箱数超过服务端权威总箱数返回A0400/) + assert.match(loading, /调整箱数超过服务端权威总箱数返回A0422/) + assert.match(loading, /未拣动态增量明细允许向下调整数量/) + assert.match(loading, /Number\(decreasedItem\?\.remainingPackageCount\) === authoritativeTotal - 1/) + assert.match(loading, /向下调整验证后恢复两箱供已拣下限校验/) + assert.match(loading, /packageCount: Number\(pickedDynamicPlanItem\?\.minPackageCount\) - 1/) assert.match(loading, /Number\(afterOverAuthority\?\.version\) === Number\(reassignedPlan\?\.version\)/) assert.match(loading, /Number\(afterOverAuthorityItem\?\.remainingPackageCount\) === authoritativeRemaining/) })