Merge remote-tracking branch 'origin/test' into dev

# Conflicts:
#	scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs
#	scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs
This commit is contained in:
2026-09-04 15:41:18 +08:00
30 changed files with 762 additions and 147 deletions
+2 -2
View File
@@ -19,7 +19,7 @@
| 箱唛箱码生成、打印与测量绑定 | 审核建单生成钩子 `controller/InternalInboundOrderController.java`;生成/解析 `service/InboundCartonLabelService.java`、`repository/InboundCartonLabelRepository.java`;平台与客户打印 `controller/PlatformInboundOrderController.java`、`MerchantInboundCartonLabelController.java`、`service/InboundCartonLabelPrintService.java`。`fl_inbound_carton_label.sku_code` 是箱唛的 SKU 冻结快照,生成时经 `client/OrderCargoClient.java` 获取;既有记录按 `inbound_detail_id` 回填。`ONE_CLICK_DROPSHIP` 缺 SKU 时禁止生成箱唛,打印面显示 SKU;`box_code` 与二维码内容仍只携带稳定箱码。基线/升级 SQL 为 `src/main/resources/db/phase2-inbound-carton-label.sql`、`phase2-carton-label-versioning-migration.sql`、`../deploy/sql/头程数据库统一增量-MySQL8.sql` | | 箱唛箱码生成、打印与测量绑定 | 审核建单生成钩子 `controller/InternalInboundOrderController.java`;生成/解析 `service/InboundCartonLabelService.java`、`repository/InboundCartonLabelRepository.java`;平台与客户打印 `controller/PlatformInboundOrderController.java`、`MerchantInboundCartonLabelController.java`、`service/InboundCartonLabelPrintService.java`。`fl_inbound_carton_label.sku_code` 是箱唛的 SKU 冻结快照,生成时经 `client/OrderCargoClient.java` 获取;既有记录按 `inbound_detail_id` 回填。`ONE_CLICK_DROPSHIP` 缺 SKU 时禁止生成箱唛,打印面显示 SKU;`box_code` 与二维码内容仍只携带稳定箱码。基线/升级 SQL 为 `src/main/resources/db/phase2-inbound-carton-label.sql`、`phase2-carton-label-versioning-migration.sql`、`../deploy/sql/头程数据库统一增量-MySQL8.sql` |
| 一件代发测量后 PDA 合托 | `controller/PlatformDropshipPalletizationController.java`、`service/DropshipPalletizationService.java`、`mapper/DropshipPalletizationMapper.java`;测量通过进入 `WAIT_PALLETIZE` 并下发独立任务,不创建国内库存,封托生成 `DP` 托盘号,全部箱数完成后进入 `WAIT_SPLIT`。契约 `src/main/resources/openapi/dropship-palletization-v1.yaml` | | 一件代发测量后 PDA 合托 | `controller/PlatformDropshipPalletizationController.java`、`service/DropshipPalletizationService.java`、`mapper/DropshipPalletizationMapper.java`;测量通过进入 `WAIT_PALLETIZE` 并下发独立任务,不创建国内库存,封托生成 `DP` 托盘号,全部箱数完成后进入 `WAIT_SPLIT`。契约 `src/main/resources/openapi/dropship-palletization-v1.yaml` |
| 墨家设备测量上传 | `controller/OpenApiInboundMeasurementController.java`、`dto/MojiaMeasurementDtos.java`、`service/MojiaMeasurementOpenApiService.java`、`security/MeasurementRequestAuthenticator.java`;接口契约见 `src/main/resources/openapi/firstleg-v1.yaml` 与 `../../docs/墨家设备测量上传接口说明-V3.md` | | 墨家设备测量上传 | `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/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` | | 客户四档箱规与默认算法 | `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`;客户规则可保存多个版本,但每个客户仅一条默认规则,计算请求可提交已确认的重量/体积选择值 | | FBA 主数据/计费与客户规则 | `controller/PlatformFbaMasterDataController.java`、`PlatformFbaPricingController.java`、`service/FbaPricingService.java`、`repository/FbaPricingRepository.java`;客户规则可保存多个版本,但每个客户仅一条默认规则,计算请求可提交已确认的重量/体积选择值 |
@@ -84,7 +84,7 @@
- 客户入库费用只在登录客户范围、测量审核通过、`feeStatus=SENT` 且存在已发布明细时可见;确认必须具备 `merchant:order:edit`,携带当前 `expectedFeeVersion` 与幂等键。成功后记录确认成员和时间并将该费用版本永久锁定,平台不得再调整;旧版本、未发送或重复异参确认均返回冲突,不做兼容覆盖。 - 客户入库费用只在登录客户范围、测量审核通过、`feeStatus=SENT` 且存在已发布明细时可见;确认必须具备 `merchant:order:edit`,携带当前 `expectedFeeVersion` 与幂等键。成功后记录确认成员和时间并将该费用版本永久锁定,平台不得再调整;旧版本、未发送或重复异参确认均返回冲突,不做兼容覆盖。
- PDA 拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数或页面展示字段替代箱码。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。 - PDA 拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数或页面展示字段替代箱码。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。
- PDA 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。 - 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`;槽位和整票块继续归当前入仓码。解绑失败时不得调用文件删除,文件删除失败时本地解绑和重量结果删除必须回滚。 - 删除称重图片只解除图片和托盘快照绑定,将槽位恢复为 `RESERVED_EMPTY`;槽位和整票块继续归当前入仓码。解绑失败时不得调用文件删除,文件删除失败时本地解绑和重量结果删除必须回滚。
- 头程快照必须保存 Warehouse `blockId + slotNo + reservationId`;A0 可被多个整票块共享 `locationId`,业务资源身份始终使用 `reservationId`,不得把 `locationId` 当作唯一标识。 - 头程快照必须保存 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` 协议。 - 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` 协议。
@@ -25,6 +25,8 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import feign.FeignException; import feign.FeignException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.RoundingMode; import java.math.RoundingMode;
@@ -37,6 +39,7 @@ import java.util.Set;
/** 平台端入库测量、OCR 修正、重量确认和提交审核应用服务。 */ /** 平台端入库测量、OCR 修正、重量确认和提交审核应用服务。 */
@Service @Service
public class InboundMeasurementService { public class InboundMeasurementService {
private static final Logger LOGGER = LoggerFactory.getLogger(InboundMeasurementService.class);
/** 测量批次已提交,等待测量审核。 */ /** 测量批次已提交,等待测量审核。 */
private static final String STATUS_SUBMITTED = "SUBMITTED"; private static final String STATUS_SUBMITTED = "SUBMITTED";
/** OCR 或人工修正的重量已确认,可参与计费。 */ /** OCR 或人工修正的重量已确认,可参与计费。 */
@@ -298,8 +301,7 @@ public class InboundMeasurementService {
request.adjustedWeightValue(), request.adjustedWeightUnit(), request.adjustedWeightValue(), request.adjustedWeightUnit(),
request.adjustmentReason(), operatorId, operatorName); request.adjustmentReason(), operatorId, operatorName);
} else if (WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus())) { } else if (WEIGHT_CONFIRMED.equals(weight.getRecognitionStatus())) {
locationOccupation.ensureForConfirmedWeight( ensureLocationWithoutBlocking(tenantId, inboundId, operatorId, weight.getId(), weight.getVersion());
tenantId, inboundId, operatorId, weight.getId(), weight.getVersion());
return toWeight(weight); return toWeight(weight);
} }
BigDecimal value = effectiveValue(weight); BigDecimal value = effectiveValue(weight);
@@ -329,11 +331,21 @@ public class InboundMeasurementService {
insertRevision(weight, confirmedVersion, "CONFIRM", value, unit, value, unit, insertRevision(weight, confirmedVersion, "CONFIRM", value, unit, value, unit,
grossWeightKg, netWeightKg, null, operatorId, operatorName); grossWeightKg, netWeightKg, null, operatorId, operatorName);
recalculateConfirmedWeight(inbound, operatorId); recalculateConfirmedWeight(inbound, operatorId);
locationOccupation.ensureForConfirmedWeight( ensureLocationWithoutBlocking(tenantId, inboundId, operatorId, weight.getId(), confirmedVersion);
tenantId, inboundId, operatorId, weight.getId(), confirmedVersion);
return toWeight(weight); 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 @Transactional
public void deleteWeightResult(long tenantId, long inboundId, long resultId, public void deleteWeightResult(long tenantId, long inboundId, long resultId,
int version, long operatorId) { int version, long operatorId) {
@@ -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); InboundOrderEntity order = requireOrder(tenantId, inboundId);
long warehouseId = requireWarehouse(order); long warehouseId = requireWarehouse(order);
var confirmedWeights = confirmedWeights(tenantId, inboundId); var confirmedWeights = confirmedWeights(tenantId, inboundId);
@@ -6,9 +6,12 @@ import com.baiyue.firstleg.dto.InboundMeasurementRequests;
import com.baiyue.firstleg.dto.InboundMeasurementResponses; import com.baiyue.firstleg.dto.InboundMeasurementResponses;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@Service @Service
public class MeasurementSubmissionService { public class MeasurementSubmissionService {
private static final Logger LOGGER = LoggerFactory.getLogger(MeasurementSubmissionService.class);
@Resource @Resource
private InboundMeasurementService measurements; private InboundMeasurementService measurements;
@Resource @Resource
@@ -19,7 +22,12 @@ public class MeasurementSubmissionService {
long tenantId, long inboundId, long operatorId, long tenantId, long inboundId, long operatorId,
InboundMeasurementRequests.SubmitRequest request) { InboundMeasurementRequests.SubmitRequest request) {
var result = measurements.submit(tenantId, inboundId, operatorId, 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; return result;
} }
} }
@@ -44,6 +44,7 @@ import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.clearInvocations; import static org.mockito.Mockito.clearInvocations;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.spy; import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.times; import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@@ -579,6 +580,28 @@ class InboundMeasurementServiceTest {
assertThat(result.slotStatus()).isEqualTo("BOUND"); 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 @Test
void confirmingFifthPalletSeparatelyAggregatesGrossAndCargoNetWeight() { void confirmingFifthPalletSeparatelyAggregatesGrossAndCargoNetWeight() {
InboundOrderEntity inbound = inbound("WAIT_MEASURE"); InboundOrderEntity inbound = inbound("WAIT_MEASURE");
@@ -320,7 +320,7 @@ class MeasurementLocationOccupationServiceTest {
when(measurements.listWeights(BUSINESS_TENANT, INBOUND)).thenReturn(weights); when(measurements.listWeights(BUSINESS_TENANT, INBOUND)).thenReturn(weights);
when(allocations.list(BUSINESS_TENANT, INBOUND)).thenReturn(List.of(first, second)); 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(), verify(warehouse, never()).reserveBlock(org.mockito.ArgumentMatchers.anyString(),
org.mockito.ArgumentMatchers.any()); org.mockito.ArgumentMatchers.any());
@@ -330,6 +330,51 @@ class MeasurementLocationOccupationServiceTest {
org.mockito.ArgumentMatchers.any()); org.mockito.ArgumentMatchers.any());
} }
@Test
void submissionReconcilesMissingBlockFromLatestConfirmedWeight() {
InboundOrderEntity inbound = inbound();
inbound.setActualVolumeCbm(new BigDecimal("2.5000"));
List<InboundWeightResultEntity> 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<InboundWeightResultEntity> 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() { private static InboundProperties properties() {
InboundProperties value = new InboundProperties(); InboundProperties value = new InboundProperties();
value.setDefaultPalletCapacityCbm(new BigDecimal("1.200000")); value.setDefaultPalletCapacityCbm(new BigDecimal("1.200000"));
@@ -377,6 +422,18 @@ class MeasurementLocationOccupationServiceTest {
return value; return value;
} }
private static List<com.baiyue.firstleg.entity.InboundPalletAllocationEntity> blockSlotsBoundTo(
List<InboundWeightResultEntity> 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<InboundWeightResultEntity> confirmedWeights() { private static List<InboundWeightResultEntity> confirmedWeights() {
return java.util.stream.LongStream.rangeClosed(1, 3).mapToObj(index -> { return java.util.stream.LongStream.rangeClosed(1, 3).mapToObj(index -> {
InboundWeightResultEntity weight = new InboundWeightResultEntity(); InboundWeightResultEntity weight = new InboundWeightResultEntity();
@@ -9,6 +9,7 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import static org.assertj.core.api.Assertions.assertThat; 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.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@@ -35,7 +36,7 @@ class MeasurementSubmissionServiceTest {
assertThat(service.submit(10001L, 88L, 9L, request)).isSameAs(submitted); assertThat(service.submit(10001L, 88L, 9L, request)).isSameAs(submitted);
verify(locations).requireOccupied(10001L, 88L); verify(locations).ensureForSubmission(10001L, 88L, 9L);
} }
@Test @Test
@@ -44,7 +45,10 @@ class MeasurementSubmissionServiceTest {
var submitted = new InboundMeasurementResponses.MeasurementSubmitted( var submitted = new InboundMeasurementResponses.MeasurementSubmitted(
30L, 3, "MEASURE_PENDING", "PENDING", "STANDARD", null, null, null); 30L, 3, "MEASURE_PENDING", "PENDING", "STANDARD", null, null, null);
when(measurements.submit(10001L, 88L, 9L, request)).thenReturn(submitted); 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); assertThat(service.submit(10001L, 88L, 9L, request)).isSameAs(submitted);
verify(locations).requireOccupied(10001L, 88L); verify(locations).ensureForSubmission(10001L, 88L, 9L);
} }
} }
+1
View File
@@ -15,6 +15,7 @@
| 待审核订单造数 | `create-pending-review-orders.mjs` | 使用已配置客户创建并提交订单,停在待审核;不审核、不推进后续履约 | | 待审核订单造数 | `create-pending-review-orders.mjs` | 使用已配置客户创建并提交订单,停在待审核;不审核、不推进后续履约 |
| 批量待分柜造数 | `it-test-wait-split-bulk.mjs` | 固定“IT测试”和“义乌仓”,随机客户可选真实线路并尽力推进到 `WAIT_SPLIT`;失败记录后继续,不自动删除成功数据;只复用 `TP-001`/`YW-TP-002`,不新增托盘或库位,普通位不足落 `A0` | | 批量待分柜造数 | `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 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 仍须当前轮授权 | | 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` 的“仓库级入口清单”为准 | 必须明确是只读诊断还是会补证据、创建资源或推进状态,不能用诊断成功冒充全量通过 | | 定向 HTTP/诊断 | 以 `README.md` 的“仓库级入口清单”为准 | 必须明确是只读诊断还是会补证据、创建资源或推进状态,不能用诊断成功冒充全量通过 |
| 本地质量与静态合同 | `backend-full-regression.ps1`、`frontend-full-regression.ps1`、`mapper-scan-contract.mjs`、`script-documentation-contract.test.mjs` 等 | 默认不访问测试环境;数据库非 dry-run、Nacos 发布等有写入能力的入口必须按 README 单独识别 | | 本地质量与静态合同 | `backend-full-regression.ps1`、`frontend-full-regression.ps1`、`mapper-scan-contract.mjs`、`script-documentation-contract.test.mjs` 等 | 默认不访问测试环境;数据库非 dry-run、Nacos 发布等有写入能力的入口必须按 README 单独识别 |
+13
View File
@@ -63,6 +63,7 @@ Client 全量、Client 入库测量与计费、待审核订单和批量待分柜
| 分类 | 入口 | 用途与副作用 | | 分类 | 入口 | 用途与副作用 |
|---|---|---| |---|---|---|
| 测试环境 HTTP | `admin-test-env-acceptance.mjs` | Admin 全模块真实 HTTP 验收;按模块创建、更新、停用测试数据 | | 测试环境 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-test-env-acceptance.mjs` | Client 完整真实 HTTP 链路;写入客户、订单、入库、权限和消息数据 |
| 测试环境 HTTP | `client-quick-order-test-env-acceptance.mjs` | Client 快速下单定向真实 HTTP 验收;使用既有测试客户创建 FBA、海外仓和私人地址草稿,验证逐行失败与幂等重放,不提交订单 | | 测试环境 HTTP | `client-quick-order-test-env-acceptance.mjs` | Client 快速下单定向真实 HTTP 验收;使用既有测试客户创建 FBA、海外仓和私人地址草稿,验证逐行失败与幂等重放,不提交订单 |
| 测试环境 HTTP | `client-phase2-test-env-acceptance.mjs` | Client 入库、测量、费用和权限专项;写测试数据 | | 测试环境 HTTP | `client-phase2-test-env-acceptance.mjs` | Client 入库、测量、费用和权限专项;写测试数据 |
@@ -125,6 +126,18 @@ node --env-file="$env:USERPROFILE\.codex\chongchong-global-test-acceptance.env"
报告写入 `reports/client-quick-order-test-env-*.json`。脚本不提交、不审核、不取消订单,成功草稿保留为可丢弃测试数据;真实 HTTP 仅在当前轮明确授权测试环境验收后执行。 报告写入 `reports/client-quick-order-test-env-*.json`。脚本不提交、不审核、不取消订单,成功草稿保留为可丢弃测试数据;真实 HTTP 仅在当前轮明确授权测试环境验收后执行。
## 订单测量与连续库位扩展矩阵
部署完成后,可用独立入口串行执行订单创建、审核建入库单、称重确认、库位指引、删除释放、补传复用、测量提交/审核至 `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
```
只回归部分场景时使用 `--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`。 需要为人工审核准备固定数量的订单时,使用 `create-pending-review-orders.mjs`。Gateway 统一读取 `ADMIN_TEST_GATEWAY`,客户账号统一读取 `MERCHANT_TEST_USERNAME/PASSWORD`;数量可通过 `--count` 或 `PENDING_REVIEW_ORDER_COUNT` 设置,默认 50。脚本不会创建客户,也不会调用平台审核、入库或后续履约接口。数量支持 `1-100`,每张订单创建后立即提交并回查客户端状态为 `SUBMITTED`,报告统一记录为 `PENDING_REVIEW`。
@@ -111,12 +111,10 @@ export function portScheduleFieldGaps(record) {
} }
export function truckAppointmentFieldGaps(record) { export function truckAppointmentFieldGaps(record) {
return gaps(record, { const result = gaps(record, {
id: isPositiveId, id: isPositiveId,
appointmentNo: isNonBlank, appointmentNo: isNonBlank,
oceanBookingId: isPositiveId, oceanBookingId: isPositiveId,
containerBookingId: isPositiveId,
loadingPlanId: (value) => value == null || isPositiveId(value),
warehouseId: (value) => value == null || isPositiveId(value), warehouseId: (value) => value == null || isPositiveId(value),
plannedLoadingAt: isIsoDateTime, plannedLoadingAt: isIsoDateTime,
status: isNonBlank, status: isNonBlank,
@@ -126,6 +124,10 @@ export function truckAppointmentFieldGaps(record) {
currency: (value) => value === 'RMB' || value === 'USD', currency: (value) => value === 'RMB' || value === 'USD',
attachmentCount: isNonNegativeNumber, 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) { export function customsCaseFieldGaps(record) {
@@ -54,7 +54,7 @@ test('Admin 当前列表逐字段列与验收矩阵一致', () => {
'loading-lists/components/LoadingListsView.vue': ['preloadNo', 'customers', 'skuCount', 'loadingListNo', 'totalPackageCount', 'totalGrossWeightKg', 'totalNetWeightKg', 'totalVolumeCbm', 'totalQuantity', 'createdByName'], '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'], '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'], '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'], 'outbound-dispatch/pages/OutboundDispatchesView.vue': ['inboundCode', 'customerName', 'containerNo', 'oceanBookingStatus', 'truckAppointmentStatus', 'pickTaskStatus', 'dispatchStatus', 'gate'],
} }
for (const [relative, fields] of Object.entries(expectations)) { 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'], [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'], [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'], [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'], [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) { for (const [validator, sample, options, missing] of samples) {
@@ -97,6 +97,11 @@ test('字段校验器接受正式完整投影并精确暴露缺字段', () => {
target[missing] = '' target[missing] = ''
assert.ok(validator(invalid, options).includes(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('字段断言接入正式生产模块和默认全量入口', () => { test('字段断言接入正式生产模块和默认全量入口', () => {
@@ -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']) 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.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, /private Plan replay\([\s\S]{0,900}sameRequestHash/)
assert.match(smartLoading, /history\(planId, null, "PUSH_PRELOAD"/) assert.match(smartLoadingTest, /void replaysBeforeVersionCheck\(\)/)
const replayStart = smartLoadingTest.indexOf('void exactPushReplayIsReadOnly()') assert.match(smartLoadingTest, /void exactWarehouseRemarkReplayIsReadOnly\(\)/)
const replayEnd = smartLoadingTest.indexOf('@Test', replayStart + 10) assert.match(smartLoadingTest, /replaysBeforeVersionCheck[\s\S]{0,1200}verify\(repository, never\(\)\)\.candidate/)
const replayBlock = smartLoadingTest.slice(replayStart, replayEnd) assert.match(smartLoadingTest, /exactWarehouseRemarkReplayIsReadOnly[\s\S]{0,1200}verify\(repository, never\(\)\)\.updateWarehouseRemark/)
assert.notEqual(replayStart, -1)
assert.match(replayBlock, /never\(\)\)\.insert/)
assert.match(smartLoadingTest, /getActionType\(\)\)\.isEqualTo\("PUSH_PRELOAD"\)/)
}) })
test('operationId is projected end-to-end for identity-based frontend deduplication', () => { test('operationId is projected end-to-end for identity-based frontend deduplication', () => {
@@ -37,7 +37,8 @@ test('批量待分柜参数固定同一客户并限制为订单测量链', () =>
assert.match(acceptance, /merchant-username和merchant-password必须同时提供/) assert.match(acceptance, /merchant-username和merchant-password必须同时提供/)
assert.match(acceptance, /modules\.splice\(fixtureIndex, 1\)/) assert.match(acceptance, /modules\.splice\(fixtureIndex, 1\)/)
assert.match(acceptance, /wait-split-count必须是1-100的整数/) 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, /createAdditionalWaitSplitFixtures/)
assert.match(acceptance, /orderBlockingFailure[\s\S]*step\.deploymentBlocker/) assert.match(acceptance, /orderBlockingFailure[\s\S]*step\.deploymentBlocker/)
assert.match(acceptance, /measurementBlockingFailure[\s\S]*step\.deploymentBlocker/) assert.match(acceptance, /measurementBlockingFailure[\s\S]*step\.deploymentBlocker/)
@@ -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', () => { test('fifteen completed Warehouse settlements can be reconciled after Firstleg rollback', () => {
assert.match(loadingPickTests, /workerProcessesFifteenInboundOwnersInStableOrderAndFinalizesOnce/) assert.match(loadingPickTests, /workerProcessesFifteenInboundOwnersInStableOrderAndFinalizesOnce/)
assert.match(loadingPickTests, /IntStream\.range\(0, 15\)/) assert.match(loadingPickTests, /IntStream\.range\(0, 15\)/)
assert.match(settlementTests, /reusesWarehouseSettlementTargetWhenRetryingAfterLocalRollback/) assert.match(settlementTests, /repeatedSettleWithExistingChildDoesNotConsumeOrTransferAgain/)
assert.match(settlementTests, /warehouse\.settlementTarget\("internal-token", 31L, "PDA-SETTLE-1-31"\)/) assert.match(settlementTests, /splits\.appliedBatch\(10001L, "PDA-SPLIT-1-31"\)/)
assert.match(settlementTests, /targetBusinessId\(\) == 999L/) assert.match(settlementTests, /verify\(warehouse, never\(\)\)\.transferBlock/)
assert.match(settlementTests, /verify\(warehouse, never\(\)\)\.releaseBlock/)
assert.match(loadingPickTests, /workerRestartResumesFromOwnerCheckpointsWithoutRepeatingSettlements/) assert.match(loadingPickTests, /workerRestartResumesFromOwnerCheckpointsWithoutRepeatingSettlements/)
}) })
@@ -64,7 +64,7 @@ test('active overview photos download through controlled paths while void histor
]) assert.equal(controlledPhotoDownload(path, response), false) ]) 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, /referenceTruckAppointmentAttachment/)
assert.match(truckApi, /uploadTruckAppointmentAttachment/) assert.match(truckApi, /uploadTruckAppointmentAttachment/)
assert.match(truckApi, /deleteTruckAppointmentAttachment/) 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', () => { 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, /无号 push 成功后 PDA 查询 A0404/)
assert.match(containerLoadingAcceptance, /oceanBookingId: bookingId, requirementId: id\(requirement\.id\), containerNo: realContainerNo/) assert.match(containerLoadingAcceptance, /oceanBookingId: bookingId, requirementId: id\(requirement\.id\), containerNo: realContainerNo/)
assert.match(containerLoadingAcceptance, /关联后列表仅保留模拟柜原地补全的一条 ACTIVE 操作身份/) assert.match(containerLoadingAcceptance, /关联后列表仅保留模拟柜原地补全的一条 ACTIVE 操作身份/)
@@ -30,8 +30,9 @@ test('phase3 inventory mutation and downstream gates cannot pass as coverage gap
const convergence = read('phase3-pda-convergence.mjs') const convergence = read('phase3-pda-convergence.mjs')
const dispatch = read('phase3-outbound-dispatch.mjs') const dispatch = read('phase3-outbound-dispatch.mjs')
const aggregate = read('phase3-outbound-aggregate.mjs') const aggregate = read('phase3-outbound-aggregate.mjs')
assert.match(loading, /自动制造库存变化并验证旧 snapshot token A0409[\s\S]{0,300}false/) assert.match(loading, /增量后旧 inventory token 明确 A0409 并要求刷新[\s\S]{0,500}codeOf\(staleIncrementBatch\) === 'A0409'/)
assert.match(convergence, /PDA 真实库存变化后旧 token A0409 与刷新重试[\s\S]{0,300}false/) 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, /国内出仓六项硬门禁必须在同轮全部通过[\s\S]{0,300}false/)
assert.match(dispatch, /拖车与独立船期仅作聚合参考且不参与出仓门禁/) assert.match(dispatch, /拖车与独立船期仅作聚合参考且不参与出仓门禁/)
assert.match(dispatch, /'CUSTOMS_CLEARANCE'/) assert.match(dispatch, /'CUSTOMS_CLEARANCE'/)
@@ -14,11 +14,13 @@ test('phase3 loading fixture identifiers are deterministic and distinct across m
assert.ok(`P3B-${primary}`.length <= 64) 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( const source = await import('node:fs/promises').then((fs) => fs.readFile(
new URL('./phase3-loading.mjs', import.meta.url), 'utf8')) new URL('./phase3-loading.mjs', import.meta.url), 'utf8'))
assert.match(source, /const mutationSuffix = markerSuffix\(context\.marker\)/) assert.match(source, /const suffix = markerSuffix\(context\.marker\)/)
assert.doesNotMatch(source, /mutationSuffix = context\.marker\.replace\(\/\\D\/g/) assert.match(source, /billOfLadingNo: `P3BL-\$\{suffix\}`/)
assert.match(source, /if \(!targetLocationId\)[\s\S]+库存变化fixture创建失败后停止旧snapshot提交[\s\S]+return/) assert.match(source, /vesselName: `P3VESSEL-\$\{suffix\}`/)
assert.match(source, /if \(!reboundValid\)[\s\S]+库存换绑未成功后停止旧snapshot提交[\s\S]+return/) assert.doesNotMatch(source, /suffix = context\.marker\.replace\(\/\\D\/g/)
assert.match(source, /增量后旧 inventory token 明确 A0409 并要求刷新/)
assert.match(source, /增量旧 token 冲突后 plan\/task\/items 零写入/)
}) })
@@ -1334,6 +1334,20 @@ export async function run(context) {
&& authoritativeRemaining === authoritativeTotal - assignedPackageCount, && authoritativeRemaining === authoritativeTotal - assignedPackageCount,
`totalPackageCount=${reassignedPlanItem?.totalPackageCount ?? 'missing'}, packageCount=${reassignedPlanItem?.packageCount ?? 'missing'}, remainingPackageCount=${reassignedPlanItem?.remainingPackageCount ?? 'missing'}`, `totalPackageCount=${reassignedPlanItem?.totalPackageCount ?? 'missing'}, packageCount=${reassignedPlanItem?.packageCount ?? 'missing'}, remainingPackageCount=${reassignedPlanItem?.remainingPackageCount ?? 'missing'}`,
OPTIONS) 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) { if (reassignedPlanItem?.id && Number.isInteger(authoritativeTotal) && authoritativeTotal > 0) {
const overAuthorityResponse = await request(context, 'PATCH', const overAuthorityResponse = await request(context, 'PATCH',
`${smartLoadingBase}/plans/${loadingPlanId}/items/${encodeURIComponent(id(reassignedPlanItem.id))}`, { `${smartLoadingBase}/plans/${loadingPlanId}/items/${encodeURIComponent(id(reassignedPlanItem.id))}`, {
@@ -1341,8 +1355,8 @@ export async function run(context) {
expectedItemVersion: Number(reassignedPlanItem.version), expectedItemVersion: Number(reassignedPlanItem.version),
operationId: `${context.marker}-dynamic-over-authority`.slice(0, 128), operationId: `${context.marker}-dynamic-over-authority`.slice(0, 128),
}) })
context.addStep(MODULE, '调整箱数超过服务端权威总箱数返回A0400', overAuthorityResponse, context.addStep(MODULE, '调整箱数超过服务端权威总箱数返回A0422', overAuthorityResponse,
overAuthorityResponse.http === 200 && codeOf(overAuthorityResponse) === 'A0400', overAuthorityResponse.http === 200 && codeOf(overAuthorityResponse) === 'A0422',
responseEvidence(overAuthorityResponse), OPTIONS) responseEvidence(overAuthorityResponse), OPTIONS)
const afterOverAuthorityResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`) const afterOverAuthorityResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
const afterOverAuthority = context.apiSuccess(MODULE, '超权威总箱数失败后回查计划', const afterOverAuthority = context.apiSuccess(MODULE, '超权威总箱数失败后回查计划',
@@ -1377,8 +1391,44 @@ export async function run(context) {
&& Number(adjustedItem?.packageCount) === 2 && adjustedItem?.warehouseRemark === reassignedPlanItem?.warehouseRemark && Number(adjustedItem?.packageCount) === 2 && adjustedItem?.warehouseRemark === reassignedPlanItem?.warehouseRemark
&& id(adjustedPlan?.pdaTaskId) === canonicalTaskId, && id(adjustedPlan?.pdaTaskId) === canonicalTaskId,
`planVersion=${adjustedPlan?.version ?? 'missing'}, taskVersion=${adjustedPlan?.pdaTaskVersion ?? 'missing'}, itemVersion=${adjustedItem?.version ?? 'missing'}`, OPTIONS) `planVersion=${adjustedPlan?.version ?? 'missing'}, taskVersion=${adjustedPlan?.pdaTaskVersion ?? 'missing'}, itemVersion=${adjustedItem?.version ?? 'missing'}`, OPTIONS)
effectiveDynamicPlan = adjustedPlan const decreaseResponse = await request(context, 'PATCH',
reassignedPlanItem = adjustedItem `${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 refreshedDynamicTaskResponse = await context.request('GET', taskDetailPath)
const refreshedDynamicTask = context.apiSuccess(MODULE, '重新增量后刷新 PDA task detail', refreshedDynamicTaskResponse, '', OPTIONS) const refreshedDynamicTask = context.apiSuccess(MODULE, '重新增量后刷新 PDA task detail', refreshedDynamicTaskResponse, '', OPTIONS)
@@ -1407,7 +1457,7 @@ export async function run(context) {
const beforePickedMutationTask = context.apiSuccess(MODULE, '已拣项负向前读取任务快照', beforePickedMutationTaskResponse, '', OPTIONS) const beforePickedMutationTask = context.apiSuccess(MODULE, '已拣项负向前读取任务快照', beforePickedMutationTaskResponse, '', OPTIONS)
const reducePickedResponse = await request(context, 'PATCH', const reducePickedResponse = await request(context, 'PATCH',
`${smartLoadingBase}/plans/${loadingPlanId}/items/${encodeURIComponent(id(pickedDynamicPlanItem?.id))}`, { `${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), expectedPlanVersion: Number(pickedDynamicPlan?.version), expectedItemVersion: Number(pickedDynamicPlanItem?.version),
operationId: `${context.marker}-dynamic-reduce-picked`.slice(0, 128), operationId: `${context.marker}-dynamic-reduce-picked`.slice(0, 128),
}) })
@@ -18,7 +18,8 @@ test('each remainder uses a simulation linked to a released booking requirement'
assert.match(source, /ocean-bookings\/\$\{oceanBookingId\}\/release/) assert.match(source, /ocean-bookings\/\$\{oceanBookingId\}\/release/)
const releaseStart = source.indexOf('const releaseResponse') const releaseStart = source.indexOf('const releaseResponse')
const releaseBlock = source.slice(releaseStart, source.indexOf('bookingDetailResponse = await', releaseStart)) 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(releaseBlock, /'Idempotency-Key': `\$\{operationBase\}-booking-release`\.slice\(0, 128\)/)
assert.match(source, /oceanBooking\?\.status === 'RELEASED' && releasedAllocationMatches/) assert.match(source, /oceanBooking\?\.status === 'RELEASED' && releasedAllocationMatches/)
assert.match(source, /id\(item\?\.containerBookingId\) === containerId/) assert.match(source, /id\(item\?\.containerBookingId\) === containerId/)
@@ -69,11 +69,11 @@ test('records LOADED and clears the reason only after a real LOADED reread', ()
assert.equal(result.notCleanedReason, null) assert.equal(result.notCleanedReason, null)
}) })
test('container loading follows allocation sync, claim, real pick, completed sequence', () => { test('container loading follows push, claim, real pick, completed sequence', () => {
const value = source('./phase3-container-loading.mjs') const value = source('./phase3-container-loading.mjs')
assert.match(value, /\['EDITING', 'WAIT_LOADING'\]\.includes\(containerDetail\?\.planStatus\)/) assert.match(value, /\['EDITING', 'WAIT_LOADING'\]\.includes\(containerDetail\?\.planStatus\)/)
assertOrder(value, [ assertOrder(value, [
"synchronizedPlan?.status === 'EDITING'", "pushed?.status === 'EDITING'",
"claimedPlan?.status === 'LOADING'", "claimedPlan?.status === 'LOADING'",
"const pickResponse = await request", "const pickResponse = await request",
"const completeResponse = await request", "const completeResponse = await request",
@@ -88,9 +88,10 @@ test('container loading follows allocation sync, claim, real pick, completed seq
test('bridge loading confirms preload before creating and claiming the PDA task', () => { test('bridge loading confirms preload before creating and claiming the PDA task', () => {
const value = source('./phase3-loading.mjs') const value = source('./phase3-loading.mjs')
assertOrder(value, [ assertOrder(value, [
'读取柜次创建时原子生成的智能装柜计划', '分配后自动建立预装关联且柜次计划可读',
'分配同步建立 bridge 关联', '无号推送后预装清单正式可见',
'无真实柜号时 PDA 查询明确 A0404', '无号 push 成功后 PDA 查询明确 A0404',
'push exact replay 对计划版本历史关联和任务零漂移',
'无号且未关联订舱仍无 PDA 任务', '无号且未关联订舱仍无 PDA 任务',
'三期订舱 RELEASED 冻结运输快照', '三期订舱 RELEASED 冻结运输快照',
'模拟柜原地关联三期 RELEASED 订舱需求并补真实柜号', '模拟柜原地关联三期 RELEASED 订舱需求并补真实柜号',
@@ -111,10 +112,10 @@ test('bridge loading confirms preload before creating and claiming the PDA task'
assert.match(value, /计划级确认同 key 异参返回冲突/) assert.match(value, /计划级确认同 key 异参返回冲突/)
assert.match(value, /已确认计划使用新 key 重复确认返回冲突/) assert.match(value, /已确认计划使用新 key 重复确认返回冲突/)
assert.doesNotMatch(value, /\/preload\/confirming|\/preload\/confirm\?tenantId=/) assert.doesNotMatch(value, /\/preload\/confirming|\/preload\/confirm\?tenantId=/)
assert.doesNotMatch(value.slice(value.indexOf('分配同步建立 bridge 关联'), value.indexOf('const linkResponse')), assert.doesNotMatch(value.slice(value.indexOf('分配后自动建立预装关联且柜次计划可读'), value.indexOf('const linkResponse')),
/\/transport/) /\/transport/)
assert.match(value, /missingContainerTaskResponse\.http === 200 && codeOf\(missingContainerTaskResponse\) === 'A0404'/) assert.match(value, /missingContainerTaskResponse\.http === 200 && codeOf\(missingContainerTaskResponse\) === 'A0404'/)
assert.doesNotMatch(value, /push-preload|无真实柜号拒绝推送柜次计划|PDA complete后补确认/) assert.doesNotMatch(value, /无真实柜号拒绝推送柜次计划|PDA complete后补确认/)
assert.match(value, /billOfLadingNo:[\s\S]*vesselName:[\s\S]*voyageNo:[\s\S]*estimatedSailingAt:[\s\S]*estimatedArrivalAt:/) assert.match(value, /billOfLadingNo:[\s\S]*vesselName:[\s\S]*voyageNo:[\s\S]*estimatedSailingAt:[\s\S]*estimatedArrivalAt:/)
assert.doesNotMatch(value, /P3-SHIPPING|P3-AGENT/) assert.doesNotMatch(value, /P3-SHIPPING|P3-AGENT/)
assert.match(value, /const frozenTransport = Object\.fromEntries\(transportFields\.map/) assert.match(value, /const frozenTransport = Object\.fromEntries\(transportFields\.map/)
@@ -159,13 +160,24 @@ test('PDA completion production and HTTP chain never call or gate on truck appoi
assert.doesNotMatch(loading, /\/truck-appointments|completeLoadingFromPda/) assert.doesNotMatch(loading, /\/truck-appointments|completeLoadingFromPda/)
}) })
test('allocation synchronizes the complete pre-PDA snapshot without a push protocol', () => { test('automatic preload bridge repeated reads preserve the complete pre-PDA snapshot', () => {
const value = source('./phase3-loading.mjs') for (const file of ['./phase3-loading.mjs', './phase3-container-loading.mjs']) {
assert.match(value, /plans\/by-booking\/\$\{encodeURIComponent\(containerBookingId\)\}/) const value = source(file)
assert.match(value, /分配同步建立 bridge 关联/) assert.match(value, /const pushRequest = Object\.freeze\(\{ path: `\$\{(?:smartLoadingBase|SMART)\}\/plans\/\$\{(?:loadingPlanId|planId)\}` \}\)/)
assert.match(value, /分配事务完成态计划与 bridge 一致且保持 EDITING/) assert.equal((value.match(/request\(context, 'GET', pushRequest\.path\)/g) || []).length >= 4, true)
assert.match(value, /Number\(synchronizedPlan\?\.version\) === Number\(link\?\.loadingPlanVersion\)/) assert.match(value, /push exact replay[\s\S]*零漂移/)
assert.doesNotMatch(value, /push-preload|pushRequest|projectPushPreloadStability|diffPushPreloadStability/) 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, /changed=\$\{(?:pushReplayDiff|replayDiff|rejectedDiff)\.join/)
assert.doesNotMatch(value, /\/plans\/[^'`]*\/push-preload/)
assert.doesNotMatch(value, /baselineBytes=|replayBytes=|rejectedBytes=/)
}
}) })
test('push stability projection sorts unordered facts and reports persisted field drift', () => { test('push stability projection sorts unordered facts and reports persisted field drift', () => {
@@ -204,13 +216,15 @@ test('push stability projection sorts unordered facts and reports persisted fiel
]) ])
}) })
test('allocation orchestration is transactional and binds the preload before 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 service = source('../../firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java')
const assign = service.slice(service.indexOf('private Plan assign('), service.indexOf('public Plan assignInbound')) const serviceTest = source('../../firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java')
assert.match(service, /@Transactional[\s\S]*public Plan assign/) const assign = service.slice(service.indexOf('public Plan assign('), service.indexOf('public Plan assignInbound('))
assert.match(assign, /ensurePendingForLoadingAllocation[\s\S]*loadingModelBridge\.bindIncrementalAllocation/) assert.match(service, /@Transactional[\s\S]{0,300}public Plan assign\(/)
assert.match(assign, /loadingModelBridge\.bindIncrementalAllocation[\s\S]*loadingPickService\.sync/) assert.match(assign, /splitPreloadService\.ensure(?:Pending|ForRunning)[\s\S]*touch\(plan[\s\S]*history\([\s\S]*loadingModelBridge\.bindIncrementalAllocation[\s\S]*loadingPickService\.sync/)
assert.doesNotMatch(source('../../firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformSmartLoadingController.java'), /push-preload/) 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', () => { test('finalize rechecks the same unified order detail and list as LOADED', () => {
@@ -261,9 +275,9 @@ test('container loading arranges a simulation before linking a released booking
assertOrder(value, [ assertOrder(value, [
"const simulationResponse = await request(context, 'POST', CONTAINERS", "const simulationResponse = await request(context, 'POST', CONTAINERS",
"const candidateResponse = await context.request('GET', `${SMART}/candidates?bookingId=", "const candidateResponse = await context.request('GET', `${SMART}/candidates?bookingId=",
"const planResponse = await context.request('GET', `${SMART}/plans/by-booking/", "const planResponse = await request(context, 'GET', `${SMART}/plans/by-booking/",
"const assignResponse = await request", "const assignResponse = await request",
'分配同步建立 bridge', '分配后自动建立预装关联且计划可读',
"const bookingCreate = await request", "const bookingCreate = await request",
"const linkResponse = await request(context, 'POST', `${CONTAINERS}/${containerId}/ocean-booking-link`", "const linkResponse = await request(context, 'POST', `${CONTAINERS}/${containerId}/ocean-booking-link`",
]) ])
@@ -291,10 +305,10 @@ test('container loading arranges a simulation before linking a released booking
assert.match(value, /bookingId=\$\{encodeURIComponent\(containerId\)\}&warehouseId=/) assert.match(value, /bookingId=\$\{encodeURIComponent\(containerId\)\}&warehouseId=/)
assert.match(value, /tasks\?loadingPlanId=\$\{encodeURIComponent\(planId\)\}&warehouseId=/) assert.match(value, /tasks\?loadingPlanId=\$\{encodeURIComponent\(planId\)\}&warehouseId=/)
assert.match(value, /loadingPlanId: planId, containerBookingId: containerId/) assert.match(value, /loadingPlanId: planId, containerBookingId: containerId/)
assert.doesNotMatch(value.slice(value.indexOf('分配同步建立 bridge'), value.indexOf('const linkResponse')), assert.doesNotMatch(value.slice(value.indexOf('分配后自动建立预装关联且计划可读'), value.indexOf('const linkResponse')),
/\/transport/) /\/transport/)
assert.match(value, /订舱放舱为 RELEASED 且不预生成真实货柜/) assert.match(value, /订舱放舱为 RELEASED 且不预生成真实货柜/)
assert.match(value, /分配同步建立 bridge/) assert.match(value, /分配后自动建立预装关联且计划可读/)
assert.match(value, /订舱 RELEASED 但预装未 CONFIRMED 不生成 PDA 任务/) assert.match(value, /订舱 RELEASED 但预装未 CONFIRMED 不生成 PDA 任务/)
assert.match(value, /计划级原子确认整份预装并触发唯一 PDA 任务/) assert.match(value, /计划级原子确认整份预装并触发唯一 PDA 任务/)
assert.match(value, /confirmationStatus === 'CONFIRMED'/) assert.match(value, /confirmationStatus === 'CONFIRMED'/)
@@ -365,7 +379,6 @@ test('channel isolation reports BLOCKED_FIXTURE instead of inventing a second ch
const value = source('./phase3-container-loading.mjs') const value = source('./phase3-container-loading.mjs')
assert.doesNotMatch(value, /foreignChannelCode|\$\{channelCode\}-OTHER|异渠道-\$\{suffix\}/) assert.doesNotMatch(value, /foreignChannelCode|\$\{channelCode\}-OTHER|异渠道-\$\{suffix\}/)
assert.match(value, /context\.createdResources\?\.platformLogisticsChannels/) assert.match(value, /context\.createdResources\?\.platformLogisticsChannels/)
assert.match(value, /codeOf\(foreignAssignResponse\) === 'A0422'/)
}) })
test('manual cross-channel loading fixture releases the inbound before the primary flow', () => { test('manual cross-channel loading fixture releases the inbound before the primary flow', () => {
@@ -386,8 +399,8 @@ test('container loading permits manual cross-channel allocation and keeps stable
'手工装柜允许两个真实有效渠道之间跨渠道分配', '手工装柜允许两个真实有效渠道之间跨渠道分配',
'跨渠道分配验收后释放入仓码供主流程继续', '跨渠道分配验收后释放入仓码供主流程继续',
'人工分配本轮候选货物', '人工分配本轮候选货物',
'分配同步建立 bridge', '分配后自动建立预装关联且计划可读',
'无真实柜号时 PDA 查询 A0404', '无号 push 成功后 PDA 查询 A0404',
'无号且未关联订舱不生成 PDA 任务', '无号且未关联订舱不生成 PDA 任务',
'订舱放舱为 RELEASED 且不预生成真实货柜', '订舱放舱为 RELEASED 且不预生成真实货柜',
'首次关联 RELEASED 订舱需求并补真实柜号', '首次关联 RELEASED 订舱需求并补真实柜号',
@@ -419,21 +432,22 @@ test('container loading permits manual cross-channel allocation and keeps stable
assert.doesNotMatch(batch, /containerNo:/) assert.doesNotMatch(batch, /containerNo:/)
}) })
test('preload and loading versions use immediate allocation and claim baselines', () => { test('preload and loading versions use immediate baselines and stable automatic bridge reads', () => {
const container = source('./phase3-container-loading.mjs') const container = source('./phase3-container-loading.mjs')
assert.match(container, /plans\/by-booking\/\$\{encodeURIComponent\(containerId\)\}/) assert.match(container, /const pushRequest = Object\.freeze\(\{ path: `\$\{SMART\}\/plans\/\$\{planId\}` \}\)/)
assert.match(container, /分配同步建立 bridge/) assert.equal((container.match(/request\(context, 'GET', pushRequest\.path\)/g) || []).length >= 4, true)
assert.doesNotMatch(container, /push-preload|pushRequest/) assert.match(container, /自动关联保持 EDITING 且无需额外推送版本/)
assert.match(container, /const beforeClaimPlanVersion = Number\(beforeClaimPlan\?\.version\)/) assert.match(container, /const beforeClaimPlanVersion = Number\(beforeClaimPlan\?\.version\)/)
assert.match(container, /Number\(claimedPlan\?\.version\) > beforeClaimPlanVersion/) assert.match(container, /Number\(claimedPlan\?\.version\) > beforeClaimPlanVersion/)
const loading = source('./phase3-loading.mjs') const loading = source('./phase3-loading.mjs')
assert.match(loading, /plans\/by-booking\/\$\{encodeURIComponent\(containerBookingId\)\}/) assert.match(loading, /const pushRequest = Object\.freeze\(\{ path: `\$\{smartLoadingBase\}\/plans\/\$\{loadingPlanId\}` \}\)/)
assert.match(loading, /分配事务完成态计划与 bridge 一致且保持 EDITING/) assert.equal((loading.match(/request\(context, 'GET', pushRequest\.path\)/g) || []).length >= 4, true)
assert.doesNotMatch(loading, /push-preload|pushRequest/) assert.match(loading, /自动关联保持 EDITING、身份稳定且无需额外推送版本/)
assert.match(loading, /push exact replay 对计划版本历史关联和任务零漂移/)
assert.match(loading, /Number\(claimedPlan\?\.version\) > beforeClaimPlanVersion/) assert.match(loading, /Number\(claimedPlan\?\.version\) > beforeClaimPlanVersion/)
assert.match(loading, /MATSON_SUPPLIER_NAME = 'MATSON 开发联调订舱代理'/) assert.match(loading, /MATSON_SUPPLIER_NAME = 'MATSON 开发联调船司'/)
assert.match(loading, /category=BOOKING&status=ACTIVE&carrierCode=MATSON&page=1&size=200/) assert.match(loading, /category=BOOKING&page=1&size=200/)
assert.match(loading, /ensureMatsonSupplier\(context, supplierRecords\)/) assert.match(loading, /ensureMatsonSupplier\(context, supplierRecords\)/)
assert.match(loading, /shippingCompanyId: id\(shippingCarrier\.id\), shippingCompanyName: shippingCarrier\.name/) assert.match(loading, /shippingCompanyId: id\(shippingCarrier\.id\), shippingCompanyName: shippingCarrier\.name/)
assert.match(loading, /bookingAgentId: id\(supplier\.id\), bookingAgentName: supplier\.name/) assert.match(loading, /bookingAgentId: id\(supplier\.id\), bookingAgentName: supplier\.name/)
@@ -486,10 +500,10 @@ test('PDA completion acceptance verifies immutable BATCH and COMPLETE_LOADING au
for (const key of ['bridge-batch', 'exception-skip-batch', 'exception-partial-batch', 'dynamic-pick']) { for (const key of ['bridge-batch', 'exception-skip-batch', 'exception-partial-batch', 'dynamic-pick']) {
assert.match(loading, new RegExp(key)) assert.match(loading, new RegExp(key))
} }
assert.match(loading, /expectedBatchOperationIds = dropshipWholePallet\s*\? \[batchKey\]\s*:\s*\[batchKey, skipBatchKey, partialBatchKey, dynamicBatchKey\]/) assert.match(loading, /expectedBatchOperationIds = \[batchKey, skipBatchKey, partialBatchKey, dynamicBatchKey\]/)
assert.match(loading, /skipBatchVerified, partialBatchVerified, dynamicIncrementVerified/) assert.match(loading, /skipBatchVerified, partialBatchVerified, dynamicIncrementVerified/)
assert.match(convergence, /expectedBatchCount = fixture\?\.fulfillmentType === 'ONE_CLICK_DROPSHIP' \? 1 : 4/) assert.match(convergence, /expectedBatchOperationIds\.length === 4/)
assert.match(convergence, /expectedBatchOperationIds\.length === expectedBatchCount/) assert.doesNotMatch(convergence, /expectedBatchCount = fixture\?\.dynamicIncrementVerified/)
}) })
test('phase3 loading proves mixed-line rollback across task, all items and operations', () => { test('phase3 loading proves mixed-line rollback across task, all items and operations', () => {
@@ -513,18 +527,19 @@ test('phase3 loading proves stale inventory zero-write and defers final completi
assert.match(loading, /authoritativeRemaining === authoritativeTotal - assignedPackageCount/) assert.match(loading, /authoritativeRemaining === authoritativeTotal - assignedPackageCount/)
assert.match(loading, /调整箱数超过服务端权威总箱数返回A0400/) assert.match(loading, /调整箱数超过服务端权威总箱数返回A0400/)
assert.match(loading, /超权威总箱数失败后计划和明细零写入/) 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.match(loading, /重新加入动态明细仍按 inboundDetailId 唯一定位/) assert.match(loading, /重新加入动态明细仍按 inboundDetailId 唯一定位/)
assert.equal((loading.match(/id\(item\?\.inboundDetailId\) === id\(dynamicCandidate\.inboundDetailId\)/g) || []).length, 2) assert.equal((loading.match(/id\(item\?\.inboundDetailId\) === id\(dynamicCandidate\.inboundDetailId\)/g) || []).length, 2)
assert.doesNotMatch(loading, /items\?\.find\?\.\(\(item\) => id\(item\?\.inboundId\) === incrementalInboundId\)/) assert.doesNotMatch(loading, /items\?\.find\?\.\(\(item\) => id\(item\?\.inboundId\) === incrementalInboundId\)/)
assertOrder(loading, [ assertOrder(loading, [
'旧 token 冲突对任务、明细、已拣数量和操作历史零写入',
'库存变化后刷新 PDA 实时库存快照',
'原子提交柜次 PDA 整批拣货',
'PDA 原子批次同参同键重放保持待人工确认',
'回查下游门禁前原子 Batch 审计', '回查下游门禁前原子 Batch 审计',
'最终确认前仅保留 CLAIM/BATCH 且无 COMPLETE_LOADING', '最终确认前仅保留 CLAIM/BATCH 且无 COMPLETE_LOADING',
'LOADING 阶段向同一 PDA task 增加入库单',
'增量后旧 inventory token 明确 A0409 并要求刷新',
'增量旧 token 冲突后 plan/task/items 零写入',
'重新增量后刷新 inventory snapshot',
'刷新后完成动态新增项拣货',
'报关与最终完成前保持 LOADING 且不提前写 COMPLETE_LOADING', '报关与最终完成前保持 LOADING 且不提前写 COMPLETE_LOADING',
]) ])
assert.doesNotMatch(loading, /complete-before-downstream/) assert.doesNotMatch(loading, /complete-before-downstream/)
@@ -575,26 +590,18 @@ test('phase3 loading fixture paginates A0 candidates and refuses mass location m
const orders = source('./orders.mjs') const orders = source('./orders.mjs')
assertOrder(entry, [ assertOrder(entry, [
'disableAvailableLocationsForA0(warehouseId, requiredPalletCount)', 'disableAvailableLocationsForA0(warehouseId, requiredPalletCount)',
'this.context.phase3LoadingRequireSharedOverflow = requireSharedOverflow', 'this.context.phase3LoadingRequireSharedOverflow = isolation.forced === true',
'createPhase3FreshMeasurementPallet(warehouseId)',
"executeModule('inbound-measurement-phase3-loading-fixture'", "executeModule('inbound-measurement-phase3-loading-fixture'",
'restoreAvailableLocationsAfterA0(warehouseId, isolation)', 'restoreAvailableLocationsAfterA0(warehouseId, isolation)',
]) ])
assert.match(entry, /page=\$\{pageNumber\}&size=\$\{A0_LOCATION_PAGE_SIZE\}/) assert.match(entry, /page=\$\{pageNumber\}&size=\$\{A0_LOCATION_PAGE_SIZE\}/)
assert.match(entry, /A0_MAX_LOCATION_MUTATIONS = 8/) assert.match(entry, /A0_MAX_LOCATION_MUTATIONS = 8/)
assert.match(entry, /A0 大仓保护:不批量停用普通库位/) assert.match(entry, /A0 大仓保护:不批量停用普通库位/)
assert.match(entry, /创建本轮库存快照专用称重托盘/)
assert.match(entry, /\/api\/platform\/v1\/pallets\?tenantId=0/)
assert.match(entry, /measurementPalletOverride/)
assert.doesNotMatch(entry, /internal\/warehouse.*inventory-snapshot-a0/)
assert.match(entry, /remainingNormal\.length < isolation\.requiredSlots/) assert.match(entry, /remainingNormal\.length < isolation\.requiredSlots/)
assert.match(entry, /\/locations\/\$\{encodeURIComponent\(location\.id\)\}\/status/) assert.match(entry, /\/locations\/\$\{encodeURIComponent\(location\.id\)\}\/status/)
assert.match(entry, /\{ status: 'DISABLED', version: Number\(location\.version\) \}/) assert.match(entry, /\{ status: 'DISABLED', version: Number\(location\.version\) \}/)
assert.match(entry, /\{ status: 'AVAILABLE', version: location\.version \}/) assert.match(entry, /\{ status: 'AVAILABLE', version: location\.version \}/)
assert.match(orders, /receivingWarehouseId: id\(master\.receiving\.id\)/) assert.match(orders, /receivingWarehouseId: id\(master\.receiving\.id\)/)
const loading = source('./phase3-loading.mjs')
assert.match(loading, /context\.context\?\.phase3LoadingRequireSharedOverflow === true/)
assert.match(loading, /附加生产者不重复要求 A0 库存快照变更专项/)
}) })
test('customs reuses the loading chain released booking without pre-completing truck', () => { test('customs reuses the loading chain released booking without pre-completing truck', () => {
@@ -602,8 +609,7 @@ test('customs reuses the loading chain released booking without pre-completing t
assertOrder(value, [ assertOrder(value, [
'selectCustomsFlow(context.createdResources)', 'selectCustomsFlow(context.createdResources)',
'if (!await ensureReleasedOceanBooking(context, container)) return', 'if (!await ensureReleasedOceanBooking(context, container)) return',
'if (!await verifyContainerOperationalDocuments(context, containerBookingId))', 'const slicesPath =',
'const customs = await completeContainerCustoms(context, loadingPlanId)',
]) ])
assert.match(value, /const oceanBookingId = id\(container\?\.oceanBookingId\)/) assert.match(value, /const oceanBookingId = id\(container\?\.oceanBookingId\)/)
assert.match(value, /ocean-bookings\/\$\{encodeURIComponent\(oceanBookingId\)\}/) assert.match(value, /ocean-bookings\/\$\{encodeURIComponent\(oceanBookingId\)\}/)
@@ -611,10 +617,11 @@ test('customs reuses the loading chain released booking without pre-completing t
assert.doesNotMatch(value, /oceanBookings\.run\(context\)/) assert.doesNotMatch(value, /oceanBookings\.run\(context\)/)
assert.doesNotMatch(value, /serviceCenter\.run\(context\)/) assert.doesNotMatch(value, /serviceCenter\.run\(context\)/)
assert.doesNotMatch(value, /truck\?\.finalStatus !== 'COMPLETED'/) assert.doesNotMatch(value, /truck\?\.finalStatus !== 'COMPLETED'/)
assert.match(value, /PLATFORM_CUSTOMS = '\/api\/platform\/v1\/customs\/loading-plans'/) assert.match(value, /request\('POST', '\/api\/platform\/v1\/suppliers\?tenantId=0'/)
assert.match(value, /complete-all-buy/) assert.match(value, /category: 'CUSTOMS'/)
assert.match(value, /finalStatus: 'COMPLETED'/) assert.match(value, /context\.createdResources\.phase3CustomsSupplier/)
assert.doesNotMatch(value, /checklist-versions|submission-batches|category: 'CUSTOMS'/) assert.match(value, /const batchPayload = \{ customsAgentId,/)
assert.doesNotMatch(value, /customsAgentName: `代理-/)
}) })
test('downstream container consumers prefer the phase3Loading fixture', () => { test('downstream container consumers prefer the phase3Loading fixture', () => {
@@ -626,13 +633,11 @@ test('downstream container consumers prefer the phase3Loading fixture', () => {
assert.match(ocean, /完整 DRAFT 放舱为 RELEASED 且冻结五项运输事实/) assert.match(ocean, /完整 DRAFT 放舱为 RELEASED 且冻结五项运输事实/)
assert.match(ocean, /finalStatus: 'RELEASED', containerBookingId: null, loadingPlanId: null/) assert.match(ocean, /finalStatus: 'RELEASED', containerBookingId: null, loadingPlanId: null/)
assert.match(source('./phase3-loading.mjs'), /oceanBookingId: bookingId, loadingPlanId, containerBookingId/) 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, /category=TRUCKING&status=ACTIVE/)
assert.match(serviceCenter, /oceanBookingId: ocean\.id/) assert.match(serviceCenter, /oceanBookingId: ocean\.id/)
assert.match(serviceCenter, /FORBIDDEN_TRUCK_FIELDS = \['customerId', 'customerName', 'containerBookingId', 'loadingPlanId'/) const servicePayload = serviceCenter.slice(serviceCenter.indexOf('const payload = {'),
assert.match(serviceCenter, /bookingLevel\(payload\)/) serviceCenter.indexOf('const response =', serviceCenter.indexOf('const payload = {')))
assert.match(serviceCenter, /bookingLevel\(appointment\)/) assert.doesNotMatch(servicePayload, /containerBookingId:|loadingPlanId:/)
assert.doesNotMatch(serviceCenter, /containerBookingId:\s*linkContainer|loadingPlanId:\s*linkContainer/)
assert.match(serviceCenter, /carrierId: id\(carrier\.id\), carrierName: carrier\.name/) assert.match(serviceCenter, /carrierId: id\(carrier\.id\), carrierName: carrier\.name/)
assert.doesNotMatch(serviceCenter, /customerId:|customerName:/) assert.doesNotMatch(serviceCenter, /customerId:|customerName:/)
assert.doesNotMatch(serviceCenter, /链路承运商-/) assert.doesNotMatch(serviceCenter, /链路承运商-/)
@@ -644,10 +649,6 @@ test('downstream container consumers prefer the phase3Loading fixture', () => {
assert.match(schedules, /allocatedContainerBookingIds: \(detail\.containers \|\| \[\]\)/) assert.match(schedules, /allocatedContainerBookingIds: \(detail\.containers \|\| \[\]\)/)
assert.match(schedules, /ocean\.allocatedContainerBookingIds\?\.includes\(containerBookingId\)/) assert.match(schedules, /ocean\.allocatedContainerBookingIds\?\.includes\(containerBookingId\)/)
assert.match(schedules, /if \(containerBookingId && !knownAllocation\)/) assert.match(schedules, /if \(containerBookingId && !knownAllocation\)/)
assert.match(schedules, /outbound-dispatches/)
assert.match(schedules, /\$\{OUTBOUND\}\/\$\{encodeURIComponent\(knownAllocation\)\}\/gate/)
assert.match(schedules, /item\?\.code !== 'SAILING_SCHEDULE'/)
assert.doesNotMatch(schedules, /\/check-gate\?|find\?\.\(\(item\) => item\?\.code === 'SAILING_SCHEDULE'/)
}) })
test('default acceptance order finalizes LOADED before customs reads fulfillment slices', () => { test('default acceptance order finalizes LOADED before customs reads fulfillment slices', () => {
@@ -686,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 run = new AcceptanceRun({ gateway: 'http://127.0.0.1', modules: [], username: '', password: '', output: '' })
const entry = source('../admin-test-env-acceptance.mjs') const entry = source('../admin-test-env-acceptance.mjs')
assert.match(entry, /module === 'phase3-loading'/) 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, /orders-phase3-loading-fixture/)
assert.match(entry, /inbound-measurement-phase3-loading-fixture/) assert.match(entry, /inbound-measurement-phase3-loading-fixture/)
assert.match(entry, /marker: originalMarker/) assert.match(entry, /marker: originalMarker/)
@@ -706,8 +707,7 @@ test('container fixture isolates order idempotency while publishing the run mark
test('measurement fixture polls asynchronous completion before exporting loading identity', () => { test('measurement fixture polls asynchronous completion before exporting loading identity', () => {
const measurement = source('./inbound-measurement.mjs') const measurement = source('./inbound-measurement.mjs')
assert.match(measurement, /waitForCompletedMeasurement/) assert.match(measurement, /waitForCompletedMeasurement/)
assert.match(measurement, /expectedStatuses\.includes\(measurement\?\.processStatus\)/) assert.match(measurement, /measurement\?\.processStatus === 'WAIT_SPLIT'/)
assert.match(measurement, /completeDropshipPalletization/)
assert.match(measurement, /setTimeout\(resolve, 1_000\)/) assert.match(measurement, /setTimeout\(resolve, 1_000\)/)
}) })
@@ -773,8 +773,6 @@ test('loaded boundary acceptance proves evidence and transport immutability with
assert.match(finalize, /evidenceSignature\(afterEvidence\) === immutableEvidenceSignature/) assert.match(finalize, /evidenceSignature\(afterEvidence\) === immutableEvidenceSignature/)
assert.match(finalize, /afterHistorySignature === historySignature/) assert.match(finalize, /afterHistorySignature === historySignature/)
assert.match(finalize, /afterPlanSignature === immutablePlanSignature/) assert.match(finalize, /afterPlanSignature === immutablePlanSignature/)
assert.match(finalize, /warehouseId: id\(fixture\.warehouseId\)/)
assert.doesNotMatch(finalize, /warehouseId: Number\(fixture\.warehouseId\)/)
}) })
test('finalize publishes a current-marker producer correlation for cross-region verification', () => { test('finalize publishes a current-marker producer correlation for cross-region verification', () => {
@@ -16,19 +16,17 @@ function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehou
const calls = []; const steps = []; const gaps = []; let createdPallet = null; let createdLocation = null; let createdRelabel = null const calls = []; const steps = []; const gaps = []; let createdPallet = null; let createdLocation = null; let createdRelabel = null
const context = { const context = {
context: {}, createdResources: {}, marker: 'ACC-PHASE4-TEST', calls, steps, gaps, context: {}, createdResources: {}, marker: 'ACC-PHASE4-TEST', calls, steps, gaps,
async request(method, requestPath) { async request(method, requestPath, body) {
calls.push({ channel: 'domestic', method, path: requestPath }) calls.push({ channel: 'domestic', method, path: requestPath, body })
if (method === 'GET' && requestPath.startsWith('/api/platform/v1/iam/tenants?')) return response(page(tenants)) 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.startsWith('/api/platform/v1/warehouse-admin?')) return response(page(warehouseRecords))
if (method === 'GET' && requestPath.startsWith('/api/platform/v1/warehouse-admin/20001?')) return response({ warehouse: { id: '20001', warehouseType: 'OVERSEAS', status: 'ACTIVE' } }) 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 === 'POST' && requestPath === '/api/platform/v1/warehouse-admin') return response('20001')
if (method === 'PATCH' && requestPath.includes('/warehouse-admin/20001/status?')) return voidResponse() if (method === 'PATCH' && requestPath.includes('/warehouse-admin/20001/status?')) return response(null)
throw new Error(`Unexpected domestic path: ${requestPath}`) throw new Error(`Unexpected domestic path: ${requestPath}`)
}, },
async requestOverseas(method, requestPath, body) { async requestOverseas(method, requestPath, body) {
calls.push({ channel: 'overseas', method, path: requestPath, body }) calls.push({ channel: 'overseas', method, path: requestPath, body })
if (method === 'POST' && requestPath === '/api/platform/v1/warehouse-admin') return response('20001')
if (method === 'PATCH' && requestPath.includes('/warehouse-admin/20001/status?')) return response(null)
if (method === 'POST' && requestPath.endsWith('/warehouse-admin/20001/locations')) { createdLocation = { id: '72002', customerId: '0', warehouseId: '20001', ...body }; return response('72002') } 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 === '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() } if (method === 'DELETE' && requestPath.endsWith('/warehouse-admin/20001/locations/72002')) { createdLocation = null; return voidResponse() }
@@ -56,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?')) 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/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.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?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/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 if (requestPath.endsWith('/warehouse-admin/20001/locations/72002')) return createdLocation == null
@@ -88,10 +84,11 @@ test('既有仓库场景只写本轮库位托盘与换标生命周期', () => {
const context = contextFixture() const context = contextFixture()
return run(context).then(() => { return run(context).then(() => {
assert.ok(context.calls.length >= 12) assert.ok(context.calls.length >= 12)
assert.ok(context.calls.every((call) => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(call.method))) assert.ok(context.calls.every((call) => ['GET', 'POST', 'PUT', 'DELETE'].includes(call.method)))
const domesticCalls = context.calls.filter((call) => call.channel === 'domestic') assert.equal(context.calls.filter((call) => call.channel === 'domestic').length, 3)
assert.ok(domesticCalls.length >= 1) assert.ok(context.calls.some((call) => call.channel === 'domestic' && call.path.startsWith('/api/platform/v1/warehouse-admin?')))
assert.ok(domesticCalls.every((call) => call.method === 'GET')) 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/'))) 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') const writes = context.calls.filter((call) => call.method !== 'GET')
assert.ok(writes.some((call) => call.method === 'DELETE' && call.path.includes('/locations/72002'))) assert.ok(writes.some((call) => call.method === 'DELETE' && call.path.includes('/locations/72002')))
@@ -116,8 +113,9 @@ test('覆盖冻结的海外只读列表与有记录才查详情', async () => {
for (const pathToken of ['/last-leg/orders?', '/relabel-applications?', '/inbound-plans?', '/inventory/summary?', '/inventory/balances?', '/inventory/ledger?', '/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) assert.ok(context.calls.some((call) => call.channel === 'overseas' && call.path.includes(pathToken)), pathToken)
} }
assert.ok(context.calls.some((call) => call.channel === 'domestic' && call.path.includes('/warehouse-admin?')), '/warehouse-admin?') assert.ok(context.calls.some((call) => call.channel === 'domestic' && call.path.includes('/warehouse-admin?')))
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/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) 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?'))) { 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?'))) {
@@ -16,12 +16,18 @@ test('measurement HTTP acceptance preserves whole-ticket slot identities across
assert.match(measurement, /asId\(current\.locationId\) === asId\(prior\.locationId\)/) 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') const loading = source('./phase3-loading.mjs')
assert.match(loading, /计划详情由服务端返回权威总箱数和剩余箱数/) assert.match(loading, /计划详情由服务端返回权威总箱数和剩余箱数/)
assert.match(loading, /authoritativeRemaining === authoritativeTotal - assignedPackageCount/) assert.match(loading, /authoritativeRemaining === authoritativeTotal - assignedPackageCount/)
assert.match(loading, /计划已有明细后按柜次读取智能装柜详情/)
assert.match(loading, /按柜次详情返回同一计划及权威箱数且数量守恒/)
assert.match(loading, /packageCount: authoritativeTotal \+ 1/) 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\(afterOverAuthority\?\.version\) === Number\(reassignedPlan\?\.version\)/)
assert.match(loading, /Number\(afterOverAuthorityItem\?\.remainingPackageCount\) === authoritativeRemaining/) assert.match(loading, /Number\(afterOverAuthorityItem\?\.remainingPackageCount\) === authoritativeRemaining/)
}) })
@@ -84,8 +84,6 @@ export async function run(context) {
const payload = { const payload = {
outboundOrderId: null, outboundOrderId: null,
oceanBookingId: ocean.id, oceanBookingId: ocean.id,
containerBookingId: null,
loadingPlanId: null,
warehouseId: id(containerFlow?.warehouseId warehouseId: id(containerFlow?.warehouseId
|| context.createdResources?.warehouse?.id || context.context?.warehouseId || '193000000000000001'), || context.createdResources?.warehouse?.id || context.context?.warehouseId || '193000000000000001'),
carrierId: id(carrier.id), carrierName: carrier.name, carrierId: id(carrier.id), carrierName: carrier.name,
@@ -101,7 +99,8 @@ export async function run(context) {
const appointmentId = id(appointment?.id) const appointmentId = id(appointment?.id)
context.assert(MODULE, '拖车预约仅关联订舱且版本和审计字段完整', response, context.assert(MODULE, '拖车预约仅关联订舱且版本和审计字段完整', response,
Boolean(appointmentId) && id(appointment?.oceanBookingId) === id(ocean.id) && Number.isFinite(Number(appointment?.version)) 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), && 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) `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 if (!appointmentId) return
@@ -130,7 +129,8 @@ export async function run(context) {
const final = context.apiSuccess(MODULE, '回查跨服务链路安全终态', detail, '', OPTIONS) const final = context.apiSuccess(MODULE, '回查跨服务链路安全终态', detail, '', OPTIONS)
context.assert(MODULE, '跨服务链路终态持久化且关联订舱未漂移', detail, context.assert(MODULE, '跨服务链路终态持久化且关联订舱未漂移', detail,
final?.status === 'COMPLETED' && id(final?.oceanBookingId) === id(ocean.id) 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) `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`) const historyResponse = await context.request('GET', `${TRUCK}/${appointmentId}/history`)
@@ -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',()=>{ 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)) 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,/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*:/) assert.doesNotMatch(source,/scope\s*:/)
}) })
test('supplier module is registered in the default full acceptance orchestration',()=>assert.match(orchestrator,/CONFIGURED_MODULES = \[[^\]]*'suppliers'/s)) test('supplier module is registered in the default full acceptance orchestration',()=>assert.match(orchestrator,/CONFIGURED_MODULES = \[[^\]]*'suppliers'/s))
@@ -96,6 +96,9 @@ test('Admin consumes a server-paged container summary and never derives 15 from
assert.match(adminApi, /listContainerPlanProgress/) assert.match(adminApi, /listContainerPlanProgress/)
assert.match(adminApi, /container-summaries/) assert.match(adminApi, /container-summaries/)
assert.match(adminView, /listContainerPlanProgress\(/) 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/) assert.doesNotMatch(adminView, /(?:rows|result\.records)\.value?\.(?:reduce|filter|forEach)[\s\S]{0,300}containerNo/)
}) })
@@ -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', () => { test('HTTP lifecycle covers uniqueness idempotency attachments and every action', () => {
for (const label of ['创建后订舱立即退出候选', '同订舱第二张非终态拖车单冲突', for (const label of ['创建后订舱立即退出候选', '同订舱第二张非终态拖车单冲突',
'重复创建未改变版本和状态', '上传DISPATCH_ORDER拖车附件', '查询拖车附件列表', '重复创建未改变版本和状态', '上传并关联DISPATCH_ORDER拖车附件', '查询拖车附件列表',
'删除首次上传的拖车附件引用', '重新引用平台归属拖车附件',
'删除重新引用的拖车附件', '恢复一份DISPATCH_ORDER拖车附件供后续状态流转',
'编辑订舱级拖车为USD应付费用', '整单推进SUBMIT', 'SUBMIT同键同载荷重放', '编辑订舱级拖车为USD应付费用', '整单推进SUBMIT', 'SUBMIT同键同载荷重放',
'SUBMIT重放版本零漂移', 'SUBMIT同键异载荷A0409', 'SUBMIT重放版本零漂移', 'SUBMIT同键异载荷A0409',
'COMPLETED后订舱恢复候选', 'COMPLETED后同订舱可新建下一张拖车单', 'COMPLETED后订舱恢复候选', 'COMPLETED后同订舱可新建下一张拖车单',
@@ -1,5 +1,6 @@
import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs' import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs'
import { oceanBookingDraft } from './ocean-booking-fixture.mjs' import { oceanBookingDraft } from './ocean-booking-fixture.mjs'
import { truckAppointmentFieldGaps } from './fulfillment-list-field-contract.mjs'
const MODULE = 'truck-appointments' const MODULE = 'truck-appointments'
const OPTIONS = { scenario: 'booking-level-truck-appointment-lifecycle', deploymentBlocker: true } 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 id(value) { return value == null ? '' : String(value) }
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } 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 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 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 || [] } function records(value) { return Array.isArray(value) ? value : value?.records || [] }
@@ -205,8 +209,10 @@ export async function run(context) {
const appointmentId = id(appointment?.id); if (!appointmentId) return const appointmentId = id(appointment?.id); if (!appointmentId) return
const path = `${TRUCKS}/${appointmentId}` const path = `${TRUCKS}/${appointmentId}`
context.assert(MODULE, '创建响应完全无柜次协议且费用正确', createdResponse, context.assert(MODULE, '创建响应完全无柜次协议且费用正确', createdResponse,
!hasForbiddenTruckFields(appointment) && appointment?.currency === 'RMB' && Number(appointment?.payableAmount) === 0, truckAppointmentFieldGaps(appointment).length === 0
`appointmentNo=${appointment?.appointmentNo || 'missing'}`, OPTIONS) && !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) const afterCreateCandidate = await candidate(context, identity)
context.assert(MODULE, '创建后订舱立即退出候选', afterCreateCandidate.response, !afterCreateCandidate.item, context.assert(MODULE, '创建后订舱立即退出候选', afterCreateCandidate.response, !afterCreateCandidate.item,
`appointmentNo=${appointment.appointmentNo}`, OPTIONS) `appointmentNo=${appointment.appointmentNo}`, OPTIONS)
@@ -223,12 +229,45 @@ export async function run(context) {
`dispatch-order-${identity.suffix}.pdf`) `dispatch-order-${identity.suffix}.pdf`)
const uploadAttachmentResponse = await context.requestMultipart('POST', `${path}/attachments/upload`, const uploadAttachmentResponse = await context.requestMultipart('POST', `${path}/attachments/upload`,
attachmentForm, context.token) 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 attachmentListResponse = await request(context, 'GET', `${path}/attachments`)
const attachmentList = context.apiSuccess(MODULE, '查询拖车附件列表', attachmentListResponse, '', OPTIONS) const attachmentList = context.apiSuccess(MODULE, '查询拖车附件列表', attachmentListResponse, '', OPTIONS)
context.assert(MODULE, '附件列表包含本轮运营附件', attachmentListResponse, context.assert(MODULE, '附件列表包含本轮运营附件', attachmentListResponse,
records(attachmentList).some((item) => id(item?.id) === id(attachment?.id) && item?.attachmentType === 'DISPATCH_ORDER'), records(attachmentList).some((item) => id(item?.id) === id(attachment?.id) && item?.attachmentType === 'DISPATCH_ORDER'),
`attachmentId=${id(attachment?.id) || 'missing'}`, OPTIONS) `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, const editedResponse = await request(context, 'PUT', path,
truckAppointmentPayload(context, identity.suffix, identity, Number(appointment.version), { currency: 'USD', payableAmount: 12.34 })) truckAppointmentPayload(context, identity.suffix, identity, Number(appointment.version), { currency: 'USD', payableAmount: 12.34 }))
@@ -258,9 +297,10 @@ export async function run(context) {
const finalDetailResponse = await request(context, 'GET', path) const finalDetailResponse = await request(context, 'GET', path)
const finalDetail = context.apiSuccess(MODULE, '回查COMPLETED订舱级拖车详情', finalDetailResponse, '', OPTIONS) const finalDetail = context.apiSuccess(MODULE, '回查COMPLETED订舱级拖车详情', finalDetailResponse, '', OPTIONS)
context.assert(MODULE, '完整生命周期完成且详情无柜次协议', finalDetailResponse, context.assert(MODULE, '完整生命周期完成且详情无柜次协议', finalDetailResponse,
finalDetail?.status === 'COMPLETED' && !hasForbiddenTruckFields(finalDetail) finalDetail?.status === 'COMPLETED' && truckAppointmentFieldGaps(finalDetail).length === 0
&& !hasForbiddenTruckFields(finalDetail)
&& id(finalDetail?.oceanBookingId) === identity.oceanBookingId, && 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) const afterCompleteCandidate = await candidate(context, identity)
context.assert(MODULE, 'COMPLETED后订舱恢复候选', afterCompleteCandidate.response, Boolean(afterCompleteCandidate.item), context.assert(MODULE, 'COMPLETED后订舱恢复候选', afterCompleteCandidate.response, Boolean(afterCompleteCandidate.item),
`oceanBookingNo=${identity.oceanBookingNo}`, OPTIONS) `oceanBookingNo=${identity.oceanBookingNo}`, OPTIONS)
+10 -1
View File
@@ -247,6 +247,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') $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') $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') $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') $adminWeightPanel = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/inbounds/weight/components/WeightOcrPanel.vue')
@@ -254,10 +256,17 @@ $clientOrdersView = Read-RequiredText (Join-Path $workspaceRoot 'apps/client-web
Assert-Contains $rootAgents '称重确认、整票库位块与部分拣货资源结算' '根称重库位块路由' Assert-Contains $rootAgents '称重确认、整票库位块与部分拣货资源结算' '根称重库位块路由'
Assert-Contains $backendAgents '称重确认、整票库位块与 PDA 资源结算' '后端称重库位块路由' Assert-Contains $backendAgents '称重确认、整票库位块与 PDA 资源结算' '后端称重库位块路由'
Assert-Contains $firstlegAgents 'MeasurementLocationOccupationService.java' 'Firstleg 称重库位块入口' 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 $firstlegAgents '槽位与称重唯一键必须包含 `inbound_id`' 'Firstleg 部分拣货快照身份'
Assert-Contains $adminAgents '`locationCode/slotStatus/sharedOverflow`' 'Admin 权威称重库位状态' Assert-Contains $adminAgents '`locationCode/slotStatus/sharedOverflow`' 'Admin 权威称重库位状态'
Assert-Contains $measurementLocationOccupationService 'orderCargo.measurementDeclaration' 'Firstleg Order 申报体积回退' 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 $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_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)' '部分拣货称重快照唯一键' Assert-Contains $inboundSchema 'UNIQUE KEY uk_pallet_allocation_weight (tenant_id, inbound_id, weight_result_id)' '部分拣货称重快照唯一键'
@@ -0,0 +1,123 @@
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, /--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', () => {
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('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('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' } },
{ 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 仅在普通库位自然不足时观测/)
})
@@ -0,0 +1,248 @@
#!/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: '小体积订单上传 3 张称重图,强制验证库位块 1→2→3 连续扩容',
expectedPalletCount: 3,
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/ },
],
},
])
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 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
.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,
requiredStepEvidence: mismatchedStepEvidence.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 sharedOverflowObserved = steps.some((step) =>
/sharedOverflow=true/.test(String(step.evidence || '')))
return {
name: scenario.name,
description: scenario.description,
passed,
assertions,
missingOrFailedSteps,
mismatchedStepEvidence,
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,
},
}
}
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
})
}