diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index d3d9f127c..bccc7a997 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -18,7 +18,7 @@ | 平台测量/OCR 与箱规计费快照 | `controller/PlatformInboundMeasurementController.java`、`service/InboundMeasurementService.java`、`CartonMeasurementService.java`、`CustomerDimensionWeightingService.java`;手工单箱测量保存实际尺寸、可选外箱修正、`chargeRuleId` 与 `coefficientMode`,计算结果以服务端返回值为准 | | 箱唛箱码生成、打印与测量绑定 | 审核建单生成钩子 `controller/InternalInboundOrderController.java`;生成/解析 `service/InboundCartonLabelService.java`、`repository/InboundCartonLabelRepository.java`;平台与客户打印 `controller/PlatformInboundOrderController.java`、`MerchantInboundCartonLabelController.java`、`service/InboundCartonLabelPrintService.java`;基线/升级 SQL 为 `src/main/resources/db/phase2-inbound-carton-label.sql`、`phase2-carton-label-versioning-migration.sql` | | 墨家设备测量上传 | `controller/OpenApiInboundMeasurementController.java`、`dto/MojiaMeasurementDtos.java`、`service/MojiaMeasurementOpenApiService.java`、`security/MeasurementRequestAuthenticator.java`;接口契约见 `src/main/resources/openapi/firstleg-v1.yaml` 与 `../../docs/墨家设备测量上传接口说明-V3.md` | -| 称重确认与整票库位块 | `service/MeasurementLocationOccupationService.java`、`InboundMeasurementService.java`、`MeasurementSubmissionService.java`、`client/WarehousePalletLocationClient.java`;Warehouse 块服务见 `../warehouse/src/main/java/com/baiyue/warehouse/service/LocationBlockService.java`,接口与上架引导见 `src/main/resources/openapi/firstleg-v1.yaml` | +| 称重确认与整票库位块 | `service/MeasurementLocationOccupationService.java` 的 `ensureForConfirmedWeight/ensureForSubmission`、`InboundMeasurementService.java`、`MeasurementSubmissionService.java`、`client/WarehousePalletLocationClient.java`;Warehouse 块服务见 `../warehouse/src/main/java/com/baiyue/warehouse/service/LocationBlockService.java`,接口与上架引导见 `src/main/resources/openapi/firstleg-v1.yaml` | | 审核与初始计价 | `controller/PlatformInboundMeasurementReviewController.java`、`service/MeasurementReviewService.java`、`service/InitialInboundPricingService.java`、`dto/MeasurementApprovalResponse.java` | | 客户四档箱规与默认算法 | `controller/PlatformCustomerDimensionConfigController.java`、`service/CustomerDimensionConfigService.java`、`repository/CustomerDimensionConfigRepository.java`;固定 `SMALL/MEDIUM/LARGE/EXTRA_LARGE`,默认算法只允许 `ADD/MULTIPLY` | | FBA 主数据/计费与客户规则 | `controller/PlatformFbaMasterDataController.java`、`PlatformFbaPricingController.java`、`service/FbaPricingService.java`、`repository/FbaPricingRepository.java`;客户规则可保存多个版本,但每个客户仅一条默认规则,计算请求可提交已确认的重量/体积选择值 | @@ -81,7 +81,7 @@ - 客户入库费用只在登录客户范围、测量审核通过、`feeStatus=SENT` 且存在已发布明细时可见;确认必须具备 `merchant:order:edit`,携带当前 `expectedFeeVersion` 与幂等键。成功后记录确认成员和时间并将该费用版本永久锁定,平台不得再调整;旧版本、未发送或重复异参确认均返回冲突,不做兼容覆盖。 - PDA 拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数或页面展示字段替代箱码。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。 - PDA 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。 -- 首张称重图片确认时按 `max(已确认称重图片数, ceil(有效体积 / 默认托盘容量))` 预留并确认整票库位块;有效体积严格依次取墨家/人工测量实测体积、测量批次冻结申报体积、Order 最新客户申报体积,全部无效才拒绝,不得把后两级回退冒充实测。后续确认绑定最小空槽,所需槽数增长时通过 Warehouse `/blocks/expand` 原子追加,既有 `blockId/slotNo/reservationId/locationId` 不得变化。测量提交只校验并复用已存在权威块,不得首次分配。Firstleg 必须显式保存 `weightResultId -> blockId/slotNo/reservationId/locationId/locationCode`,按 `confirmedAt + weightResultId` 稳定顺序绑定,禁止按列表下标推断;普通连续位不足的新增槽由 Warehouse 放入共享 A0,不阻断确认。 +- 首张称重图片确认时按 `max(已确认称重图片数, ceil(有效体积 / 默认托盘容量))` 尽力预留并确认整票库位块;有效体积先取入库单已落地的正数实测体积,否则对最新测量批次的正数 `totalVolumeCbm/originalVolumeCbm` 取较大值,再无有效值才查询 Order 最新客户申报体积,全部无效则拒绝。后续确认绑定最小空槽,所需槽数增长时通过 Warehouse `/blocks/expand` 原子追加,既有 `blockId/slotNo/reservationId/locationId/sharedOverflow` 不得变化。库位指引失败只记录警告,不阻断称重确认;测量提交后以最新已确认图片调用 `ensureForSubmission` 再次补齐和校验,失败同样不回滚已提交的测量主流程。Firstleg 必须显式保存 `weightResultId -> blockId/slotNo/reservationId/locationId/locationCode`,按 `confirmedAt + weightResultId` 稳定顺序绑定,禁止按列表下标推断;普通连续位不足的新增槽由 Warehouse 放入共享 A0,不阻断确认。 - 删除称重图片只解除图片和托盘快照绑定,将槽位恢复为 `RESERVED_EMPTY`;槽位和整票块继续归当前入仓码。解绑失败时不得调用文件删除,文件删除失败时本地解绑和重量结果删除必须回滚。 - 头程快照必须保存 Warehouse `blockId + slotNo + reservationId`;A0 可被多个整票块共享 `locationId`,业务资源身份始终使用 `reservationId`,不得把 `locationId` 当作唯一标识。 - PDA 部分拣货不得释放块内单槽;存在余量子单时通过 `/internal/warehouse/v1/pallet-locations/blocks/transfer` 将整段转移给子单,源单与余量子单可各自保留同一块槽及称重快照,因此 `fl_inbound_pallet_allocation` 的槽位与称重唯一键必须包含 `inbound_id`。整票全部拣完时只通过 `/internal/warehouse/v1/pallet-locations/blocks/release` 一次性释放全部普通位和 A0 预约,禁止恢复逐槽 `settlements/release-selected` 协议。 diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index 0c7aa987d..58c314e1f 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -15,6 +15,7 @@ | 待审核订单造数 | `create-pending-review-orders.mjs` | 使用已配置客户创建并提交订单,停在待审核;不审核、不推进后续履约 | | 批量待分柜造数 | `it-test-wait-split-bulk.mjs` | 固定“IT测试”和“义乌仓”,随机客户可选真实线路并尽力推进到 `WAIT_SPLIT`;失败记录后继续,不自动删除成功数据;只复用 `TP-001`/`YW-TP-002`,不新增托盘或库位,普通位不足落 `A0` | | Admin HTTP 内部模块 | `admin-test-env-modules/*.mjs` | 由 Admin 总入口动态加载,除文档明确的独立编排器外不得当作独立全量入口;`*contract.test.mjs` 只做离线合同 | +| 入仓测量与整票库位块专项 | `admin-test-env-modules/inbound-measurement.mjs`、`admin-test-env-modules/README.md` | 仍从 Admin 总入口按 `orders,inbound-measurement` 依赖链执行;开启 `--verify-pallet-location-guide true` 后逐张确认回查块扩容只能追加,既有槽身份不得漂移,并验证删除图片后空槽保留与替代图片复用;会写测试环境,必须有当前轮授权 | | Finance 扩展 HTTP 模块 | `admin-test-env-modules/firstleg-container-fee-allocation.mjs`、`finance-direct-paid-isolation.mjs`、`phase4-overseas-finance-cross-region.mjs` | 分别验证柜费跨客户分摊、海外仓服务直接已付隔离、五类海外费用跨区入池;必须由 Admin 总入口按已登记依赖运行,使用本轮资源且禁止借历史数据,真实 HTTP 仍须当前轮授权 | | 定向 HTTP/诊断 | 以 `README.md` 的“仓库级入口清单”为准 | 必须明确是只读诊断还是会补证据、创建资源或推进状态,不能用诊断成功冒充全量通过 | | 本地质量与静态合同 | `backend-full-regression.ps1`、`frontend-full-regression.ps1`、`mapper-scan-contract.mjs`、`script-documentation-contract.test.mjs` 等 | 默认不访问测试环境;数据库非 dry-run、Nacos 发布等有写入能力的入口必须按 README 单独识别 | diff --git a/scripts/admin-test-env-modules/fulfillment-list-field-contract.mjs b/scripts/admin-test-env-modules/fulfillment-list-field-contract.mjs index 3a20ab712..3ea3488da 100644 --- a/scripts/admin-test-env-modules/fulfillment-list-field-contract.mjs +++ b/scripts/admin-test-env-modules/fulfillment-list-field-contract.mjs @@ -111,12 +111,10 @@ export function portScheduleFieldGaps(record) { } export function truckAppointmentFieldGaps(record) { - return gaps(record, { + const result = gaps(record, { id: isPositiveId, appointmentNo: isNonBlank, oceanBookingId: isPositiveId, - containerBookingId: isPositiveId, - loadingPlanId: (value) => value == null || isPositiveId(value), warehouseId: (value) => value == null || isPositiveId(value), plannedLoadingAt: isIsoDateTime, status: isNonBlank, @@ -126,6 +124,10 @@ export function truckAppointmentFieldGaps(record) { currency: (value) => value === 'RMB' || value === 'USD', attachmentCount: isNonNegativeNumber, }) + for (const field of ['containerBookingId', 'loadingPlanId', 'containerNo', 'cabinetNo', 'containers']) { + if (Object.hasOwn(record || {}, field)) result.push(`forbidden:${field}`) + } + return result } export function customsCaseFieldGaps(record) { diff --git a/scripts/admin-test-env-modules/fulfillment-prototype-field-matrix-contract.test.mjs b/scripts/admin-test-env-modules/fulfillment-prototype-field-matrix-contract.test.mjs index 3f5eea1a1..d0e401f6e 100644 --- a/scripts/admin-test-env-modules/fulfillment-prototype-field-matrix-contract.test.mjs +++ b/scripts/admin-test-env-modules/fulfillment-prototype-field-matrix-contract.test.mjs @@ -54,7 +54,7 @@ test('Admin 当前列表逐字段列与验收矩阵一致', () => { 'loading-lists/components/LoadingListsView.vue': ['preloadNo', 'customers', 'skuCount', 'loadingListNo', 'totalPackageCount', 'totalGrossWeightKg', 'totalNetWeightKg', 'totalVolumeCbm', 'totalQuantity', 'createdByName'], 'ocean-bookings/pages/OceanBookingsView.vue': ['bookingNo', 'customerName', 'billOfLadingNo', 'carrierName', 'bookingAgent', 'vesselName', 'pol', 'pod', 'etd', 'eta', 'updatedAt'], 'port-schedules/pages/PortSchedulesView.vue': ['schedule.scheduleNo', 'schedule.sourceName', 'schedule.vesselName', 'schedule.departurePortName', 'schedule.destinationPortName', 'schedule.etdAt', 'schedule.etaAt', 'schedule.observedAt', 'expired'], - 'trucking/pages/TruckAppointmentsView.vue': ['appointmentNo', 'containerNo', 'company', 'driver', 'vehicleNo', 'store', 'payableAmount', 'currency', 'plannedLoadingAt', 'attachmentCount'], + 'trucking/pages/TruckAppointmentsView.vue': ['appointmentNo', 'oceanBookingNo', 'billOfLadingNo', 'warehouseName', 'company', 'driver', 'vehicleNo', 'store', 'payableAmount', 'currency', 'plannedLoadingAt', 'attachmentCount'], 'outbound-dispatch/pages/OutboundDispatchesView.vue': ['inboundCode', 'customerName', 'containerNo', 'oceanBookingStatus', 'truckAppointmentStatus', 'pickTaskStatus', 'dispatchStatus', 'gate'], } for (const [relative, fields] of Object.entries(expectations)) { @@ -87,7 +87,7 @@ test('字段校验器接受正式完整投影并精确暴露缺字段', () => { [loadingListFieldGaps, { planId: ids.plan, containerBookingId: ids.container, loadingListNo: 'LL-1', containerNo: 'MSCU1234567', cabinetNo: 'CAB-1', warehouseName: '宁波仓', status: 'COMPLETED', totalPackageCount: 1, totalGrossWeightKg: 10, totalNetWeightKg: 9, totalVolumeCbm: 1, totalQuantity: 2, createdAt: at }, undefined, 'containerNo'], [oceanBookingFieldGaps, { id: ids.id, bookingNo: 'BK-1', status: 'RELEASED', vesselName: 'VESSEL', voyageNo: 'V1', estimatedSailingAt: at, estimatedArrivalAt: at, customerId: ids.id, customerName: '测试客户', shippingCompanyId: null, shippingCompanyName: null, bookingAgentId: ids.id, bookingAgentName: '代理' }, undefined, 'voyageNo'], [portScheduleFieldGaps, { portSchedule: { schedule: { id: ids.id, scheduleNo: 'PS-1', sourceName: '船公司', vesselName: 'VESSEL', voyageNo: 'V1', departurePortCode: 'CNNGB', destinationPortCode: 'USLAX', etdAt: at, etaAt: at, observedAt: at, updatedAt: at, expiresAt: at }, expired: false } }, undefined, 'sourceName'], - [truckAppointmentFieldGaps, { id: ids.id, appointmentNo: 'TA-1', oceanBookingId: ids.id, containerBookingId: ids.container, loadingPlanId: ids.plan, warehouseId: ids.id, warehouseName: '宁波仓', carrierId: ids.id, carrierName: '拖车商', payableAmount: 0, currency: 'RMB', plannedLoadingAt: at, status: 'DRAFT', attachmentCount: 0 }, undefined, 'appointmentNo'], + [truckAppointmentFieldGaps, { id: ids.id, appointmentNo: 'TA-1', oceanBookingId: ids.id, warehouseId: ids.id, warehouseName: '宁波仓', carrierId: ids.id, carrierName: '拖车商', payableAmount: 0, currency: 'RMB', plannedLoadingAt: at, status: 'DRAFT', attachmentCount: 0 }, undefined, 'appointmentNo'], [outboundAggregateFieldGaps, { containerBookingId: ids.container, containerNo: 'MSCU1234567', loadingPlanId: ids.plan, loadingPlanStatus: 'COMPLETED', oceanBookingId: ids.id, oceanBookingStatus: 'RELEASED', dispatchStatus: 'READY_FOR_DISPATCH', version: 1, inbounds: [{ orderId: ids.order, inboundId: ids.inbound, inboundCode: 'IN-1', tenantId: ids.id, inboundStatus: 'LOADED', pickTaskId: ids.task, pickTaskStatus: 'COMPLETED' }], gate: { inbounds: [{ inboundId: ids.inbound, inboundCode: 'IN-1', gate: { items: [] } }] } }, undefined, 'containerNo'], ] for (const [validator, sample, options, missing] of samples) { @@ -97,6 +97,11 @@ test('字段校验器接受正式完整投影并精确暴露缺字段', () => { target[missing] = '' assert.ok(validator(invalid, options).includes(missing)) } + assert.ok(truckAppointmentFieldGaps({ + id: ids.id, appointmentNo: 'TA-1', oceanBookingId: ids.id, warehouseId: ids.id, + carrierId: ids.id, carrierName: '拖车商', payableAmount: 0, currency: 'RMB', + plannedLoadingAt: at, status: 'DRAFT', attachmentCount: 0, containerNo: 'MSCU1234567', + }).includes('forbidden:containerNo')) }) test('字段断言接入正式生产模块和默认全量入口', () => { diff --git a/scripts/admin-test-env-modules/loading-history-noop-contract.test.mjs b/scripts/admin-test-env-modules/loading-history-noop-contract.test.mjs index d66c0e20f..4e7e4fbeb 100644 --- a/scripts/admin-test-env-modules/loading-history-noop-contract.test.mjs +++ b/scripts/admin-test-env-modules/loading-history-noop-contract.test.mjs @@ -38,16 +38,13 @@ test('deduplication uses stable event identity and never identical display text' assert.deepEqual(rows.map((row) => row.historyId), ['1', '3', '4', '5']) }) -test('backend never appends a generic STATUS_UPDATE and push replay is read-only', () => { +test('backend never appends a generic STATUS_UPDATE and current operation replay is read-only', () => { assert.doesNotMatch(serviceSources, /(?:history|appendHistory)\([^;]{0,500}"STATUS_UPDATE"/s) - assert.match(smartLoading, /if \(key\.equals\(plan\.getPushIdempotencyKey\(\)\)\)[\s\S]{0,300}return view\(plan, booking\)/) - assert.match(smartLoading, /history\(planId, null, "PUSH_PRELOAD"/) - const replayStart = smartLoadingTest.indexOf('void exactPushReplayIsReadOnly()') - const replayEnd = smartLoadingTest.indexOf('@Test', replayStart + 10) - const replayBlock = smartLoadingTest.slice(replayStart, replayEnd) - assert.notEqual(replayStart, -1) - assert.match(replayBlock, /never\(\)\)\.insert/) - assert.match(smartLoadingTest, /getActionType\(\)\)\.isEqualTo\("PUSH_PRELOAD"\)/) + assert.match(smartLoading, /private Plan replay\([\s\S]{0,900}sameRequestHash/) + assert.match(smartLoadingTest, /void replaysBeforeVersionCheck\(\)/) + assert.match(smartLoadingTest, /void exactWarehouseRemarkReplayIsReadOnly\(\)/) + assert.match(smartLoadingTest, /replaysBeforeVersionCheck[\s\S]{0,1200}verify\(repository, never\(\)\)\.candidate/) + assert.match(smartLoadingTest, /exactWarehouseRemarkReplayIsReadOnly[\s\S]{0,1200}verify\(repository, never\(\)\)\.updateWarehouseRemark/) }) test('operationId is projected end-to-end for identity-based frontend deduplication', () => { diff --git a/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs b/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs index 2427b20b7..1fa71c449 100644 --- a/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs @@ -37,7 +37,8 @@ test('批量待分柜参数固定同一客户并限制为订单测量链', () => assert.match(acceptance, /merchant-username和merchant-password必须同时提供/) assert.match(acceptance, /modules\.splice\(fixtureIndex, 1\)/) assert.match(acceptance, /wait-split-count必须是1-100的整数/) - assert.match(acceptance, /批量WAIT_SPLIT只允许执行merchant-qualification-fixture、orders、inbound-measurement/) + assert.match(acceptance, /fixtureModules = new Set\(\['merchant-qualification-fixture', 'orders', 'inbound-measurement',[\s\S]{0,180}'coload-groups'[\s\S]{0,180}'firstleg-container-fee-allocation'/) + assert.match(acceptance, /批量WAIT_SPLIT只允许执行WAIT_SPLIT造数、同柜组与柜次跨客户分摊模块/) assert.match(acceptance, /createAdditionalWaitSplitFixtures/) assert.match(acceptance, /orderBlockingFailure[\s\S]*step\.deploymentBlocker/) assert.match(acceptance, /measurementBlockingFailure[\s\S]*step\.deploymentBlocker/) diff --git a/scripts/admin-test-env-modules/pda-complete-cross-service-recovery-contract.test.mjs b/scripts/admin-test-env-modules/pda-complete-cross-service-recovery-contract.test.mjs index da2d5aa12..f4201f234 100644 --- a/scripts/admin-test-env-modules/pda-complete-cross-service-recovery-contract.test.mjs +++ b/scripts/admin-test-env-modules/pda-complete-cross-service-recovery-contract.test.mjs @@ -91,9 +91,10 @@ test('a different operationId is rejected while the completion command is proces test('fifteen completed Warehouse settlements can be reconciled after Firstleg rollback', () => { assert.match(loadingPickTests, /workerProcessesFifteenInboundOwnersInStableOrderAndFinalizesOnce/) assert.match(loadingPickTests, /IntStream\.range\(0, 15\)/) - assert.match(settlementTests, /reusesWarehouseSettlementTargetWhenRetryingAfterLocalRollback/) - assert.match(settlementTests, /warehouse\.settlementTarget\("internal-token", 31L, "PDA-SETTLE-1-31"\)/) - assert.match(settlementTests, /targetBusinessId\(\) == 999L/) + assert.match(settlementTests, /repeatedSettleWithExistingChildDoesNotConsumeOrTransferAgain/) + assert.match(settlementTests, /splits\.appliedBatch\(10001L, "PDA-SPLIT-1-31"\)/) + assert.match(settlementTests, /verify\(warehouse, never\(\)\)\.transferBlock/) + assert.match(settlementTests, /verify\(warehouse, never\(\)\)\.releaseBlock/) assert.match(loadingPickTests, /workerRestartResumesFromOwnerCheckpointsWithoutRepeatingSettlements/) }) diff --git a/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs b/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs index 4445e2f2b..507161c2a 100644 --- a/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs @@ -64,7 +64,7 @@ test('active overview photos download through controlled paths while void histor ]) assert.equal(controlledPhotoDownload(path, response), false) }) -test('truck attachment API and Admin upload-reference-delete flow are frozen', () => { +test('truck attachment API reference flow and Admin upload-delete flow are frozen', () => { assert.match(truckApi, /referenceTruckAppointmentAttachment/) assert.match(truckApi, /uploadTruckAppointmentAttachment/) assert.match(truckApi, /deleteTruckAppointmentAttachment/) @@ -91,7 +91,8 @@ test('existing container number replacement keeps container plan and PDA identit }) test('PDA task opens only after no-number push, release, four-field link and plan-level confirmation', () => { - assert.match(containerLoadingAcceptance, /无真实柜号推送预装计划成功/) + assert.match(containerLoadingAcceptance, /分配后自动建立预装关联且计划可读/) + assert.match(containerLoadingAcceptance, /无号 push 后预装清单可见/) assert.match(containerLoadingAcceptance, /无号 push 成功后 PDA 查询 A0404/) assert.match(containerLoadingAcceptance, /oceanBookingId: bookingId, requirementId: id\(requirement\.id\), containerNo: realContainerNo/) assert.match(containerLoadingAcceptance, /关联后列表仅保留模拟柜原地补全的一条 ACTIVE 操作身份/) diff --git a/scripts/admin-test-env-modules/phase3-http-fail-closed-contract.test.mjs b/scripts/admin-test-env-modules/phase3-http-fail-closed-contract.test.mjs index 1a4f1bfa4..829146440 100644 --- a/scripts/admin-test-env-modules/phase3-http-fail-closed-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-http-fail-closed-contract.test.mjs @@ -30,8 +30,9 @@ test('phase3 inventory mutation and downstream gates cannot pass as coverage gap const convergence = read('phase3-pda-convergence.mjs') const dispatch = read('phase3-outbound-dispatch.mjs') const aggregate = read('phase3-outbound-aggregate.mjs') - assert.match(loading, /自动制造库存变化并验证旧 snapshot token A0409[\s\S]{0,300}false/) - assert.match(convergence, /PDA 真实库存变化后旧 token A0409 与刷新重试[\s\S]{0,300}false/) + assert.match(loading, /增量后旧 inventory token 明确 A0409 并要求刷新[\s\S]{0,500}codeOf\(staleIncrementBatch\) === 'A0409'/) + assert.match(loading, /增量旧 token 冲突后 plan\/task\/items 零写入/) + assert.match(convergence, /fixture\?\.dynamicIncrementVerified !== true[\s\S]{0,500}不能跳过增量重开、旧 token A0409、刷新续拣和已拣项保护/) assert.match(dispatch, /国内出仓六项硬门禁必须在同轮全部通过[\s\S]{0,300}false/) assert.match(dispatch, /拖车与独立船期仅作聚合参考且不参与出仓门禁/) assert.match(dispatch, /'CUSTOMS_CLEARANCE'/) diff --git a/scripts/admin-test-env-modules/phase3-loading-marker-contract.test.mjs b/scripts/admin-test-env-modules/phase3-loading-marker-contract.test.mjs index 72a1f1658..ec85dd985 100644 --- a/scripts/admin-test-env-modules/phase3-loading-marker-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-marker-contract.test.mjs @@ -14,11 +14,13 @@ test('phase3 loading fixture identifiers are deterministic and distinct across m assert.ok(`P3B-${primary}`.length <= 64) }) -test('phase3 loading uses the collision-safe marker suffix for inventory mutation locations', async () => { +test('phase3 loading uses the collision-safe marker suffix for generated business identifiers', async () => { const source = await import('node:fs/promises').then((fs) => fs.readFile( new URL('./phase3-loading.mjs', import.meta.url), 'utf8')) - assert.match(source, /const mutationSuffix = markerSuffix\(context\.marker\)/) - assert.doesNotMatch(source, /mutationSuffix = context\.marker\.replace\(\/\\D\/g/) - assert.match(source, /if \(!targetLocationId\)[\s\S]+库存变化fixture创建失败后停止旧snapshot提交[\s\S]+return/) - assert.match(source, /if \(!reboundValid\)[\s\S]+库存换绑未成功后停止旧snapshot提交[\s\S]+return/) + assert.match(source, /const suffix = markerSuffix\(context\.marker\)/) + assert.match(source, /billOfLadingNo: `P3BL-\$\{suffix\}`/) + assert.match(source, /vesselName: `P3VESSEL-\$\{suffix\}`/) + assert.doesNotMatch(source, /suffix = context\.marker\.replace\(\/\\D\/g/) + assert.match(source, /增量后旧 inventory token 明确 A0409 并要求刷新/) + assert.match(source, /增量旧 token 冲突后 plan\/task\/items 零写入/) }) diff --git a/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs b/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs index 65e5c3560..96a8a44ec 100644 --- a/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs @@ -18,7 +18,8 @@ test('each remainder uses a simulation linked to a released booking requirement' assert.match(source, /ocean-bookings\/\$\{oceanBookingId\}\/release/) const releaseStart = source.indexOf('const releaseResponse') const releaseBlock = source.slice(releaseStart, source.indexOf('bookingDetailResponse = await', releaseStart)) - assert.match(releaseBlock, /expectedVersion: Number\(bookingCreated\?\.oceanBooking\?\.version\)/) + assert.match(source, /bookingDocumentForm\.append\('expectedVersion', String\(Number\(bookingCreated\?\.oceanBooking\?\.version\)\)\)/) + assert.match(releaseBlock, /expectedVersion: releaseVersion/) assert.match(releaseBlock, /'Idempotency-Key': `\$\{operationBase\}-booking-release`\.slice\(0, 128\)/) assert.match(source, /oceanBooking\?\.status === 'RELEASED' && releasedAllocationMatches/) assert.match(source, /id\(item\?\.containerBookingId\) === containerId/) diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index b5dbf0a4b..25f023468 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -88,7 +88,8 @@ test('container loading follows push, claim, real pick, completed sequence', () test('bridge loading confirms preload before creating and claiming the PDA task', () => { const value = source('./phase3-loading.mjs') assertOrder(value, [ - '无真实柜号推送预装成功并冻结柜次明细', + '分配后自动建立预装关联且柜次计划可读', + '无号推送后预装清单正式可见', '无号 push 成功后 PDA 查询明确 A0404', 'push exact replay 对计划版本历史关联和任务零漂移', '无号且未关联订舱仍无 PDA 任务', @@ -111,7 +112,7 @@ test('bridge loading confirms preload before creating and claiming the PDA task' assert.match(value, /计划级确认同 key 异参返回冲突/) assert.match(value, /已确认计划使用新 key 重复确认返回冲突/) assert.doesNotMatch(value, /\/preload\/confirming|\/preload\/confirm\?tenantId=/) - assert.doesNotMatch(value.slice(value.indexOf('无真实柜号推送预装成功并冻结柜次明细'), value.indexOf('const linkResponse')), + assert.doesNotMatch(value.slice(value.indexOf('分配后自动建立预装关联且柜次计划可读'), value.indexOf('const linkResponse')), /\/transport/) assert.match(value, /missingContainerTaskResponse\.http === 200 && codeOf\(missingContainerTaskResponse\) === 'A0404'/) assert.doesNotMatch(value, /无真实柜号拒绝推送柜次计划|PDA complete后补确认/) @@ -159,22 +160,22 @@ test('PDA completion production and HTTP chain never call or gate on truck appoi assert.doesNotMatch(loading, /\/truck-appointments|completeLoadingFromPda/) }) -test('push replay and both conflict modes preserve the complete pre-PDA snapshot', () => { +test('automatic preload bridge repeated reads preserve the complete pre-PDA snapshot', () => { for (const file of ['./phase3-loading.mjs', './phase3-container-loading.mjs']) { const value = source(file) - assert.match(value, /const pushRequest = Object\.freeze\([\s\S]*body: Object\.freeze\(\{ expectedVersion:/) + assert.match(value, /const pushRequest = Object\.freeze\(\{ path: `\$\{(?:smartLoadingBase|SMART)\}\/plans\/\$\{(?:loadingPlanId|planId)\}` \}\)/) + assert.equal((value.match(/request\(context, 'GET', pushRequest\.path\)/g) || []).length >= 4, true) assert.match(value, /push exact replay[\s\S]*零漂移/) - assert.match(value, /同 key 异参[\s\S]*新 key 重复 push/) - assert.match(value, /非法 push 后回查柜次计划[\s\S]*非法 push 后回查预装历史[\s\S]*非法 push 后回查 bridge/) - assert.match(value, /两类非法 push 后计划版本历史关联(?:和)?任务仍?零漂移/) + assert.match(value, /自动关联计划重复读取稳定[\s\S]*自动关联计划再次读取稳定/) + assert.match(value, /重放后回查柜次计划[\s\S]*重放后回查预装历史[\s\S]*重放后回查 bridge/) assert.match(value, /codeOf\(rejectedTaskResponse\) === 'A0404'/) assert.match(value, /projectPushPreloadStability/) assert.match(value, /diffPushPreloadStability/) assert.match(value, /首次 push 后读取事务完成态柜次计划/) assert.match(value, /事务完成态计划版本与 bridge 一致且保持 EDITING/) assert.match(value, /plan: postPushPlan, detail:/) - assert.match(value, /expectedVersion: Number\(postPushPlan\.version\)/) assert.match(value, /changed=\$\{(?:pushReplayDiff|replayDiff|rejectedDiff)\.join/) + assert.doesNotMatch(value, /\/plans\/[^'`]*\/push-preload/) assert.doesNotMatch(value, /baselineBytes=|replayBytes=|rejectedBytes=/) } }) @@ -215,15 +216,15 @@ test('push stability projection sorts unordered facts and reports persisted fiel ]) }) -test('push orchestration is transactional and a bridge failure cannot reach PDA creation', () => { +test('assignment transaction establishes preload and bridge before PDA synchronization', () => { const service = source('../../firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java') const serviceTest = source('../../firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java') - const push = service.slice(service.indexOf('public Plan pushPreload'), service.indexOf('private void requirePushReplay')) - assert.match(service, /@Transactional[\s\S]*public Plan pushPreload/) - assert.match(push, /repository\.push[\s\S]*history\([\s\S]*loadingModelBridge\.bindAllocatedBeforeLoading/) - assert.doesNotMatch(push, /loadingPickService\.beginIfReady/) - assert.match(serviceTest, /bindingFailureStopsBeforePdaAndPropagatesForTransactionRollback/) - assert.match(serviceTest, /verify\(loadingPickService, never\(\)\)\.beginIfReady/) + const assign = service.slice(service.indexOf('public Plan assign('), service.indexOf('public Plan assignInbound(')) + assert.match(service, /@Transactional[\s\S]{0,300}public Plan assign\(/) + assert.match(assign, /splitPreloadService\.ensure(?:Pending|ForRunning)[\s\S]*touch\(plan[\s\S]*history\([\s\S]*loadingModelBridge\.bindIncrementalAllocation[\s\S]*loadingPickService\.sync/) + assert.match(serviceTest, /addsItemToLoadingPlanWithoutChangingStableTaskOrPlanStatus/) + assert.match(serviceTest, /inOrder\(loadingPickService, splitPreloadService, repository, loadingModelBridge\)/) + assert.match(serviceTest, /order\.verify\(loadingModelBridge\)\.bindIncrementalAllocation/) }) test('finalize rechecks the same unified order detail and list as LOADED', () => { @@ -274,9 +275,9 @@ test('container loading arranges a simulation before linking a released booking assertOrder(value, [ "const simulationResponse = await request(context, 'POST', CONTAINERS", "const candidateResponse = await context.request('GET', `${SMART}/candidates?bookingId=", - "const planResponse = await request(context, 'POST', `${SMART}/plans`", + "const planResponse = await request(context, 'GET', `${SMART}/plans/by-booking/", "const assignResponse = await request", - '无真实柜号推送预装计划成功', + '分配后自动建立预装关联且计划可读', "const bookingCreate = await request", "const linkResponse = await request(context, 'POST', `${CONTAINERS}/${containerId}/ocean-booking-link`", ]) @@ -304,10 +305,10 @@ test('container loading arranges a simulation before linking a released booking assert.match(value, /bookingId=\$\{encodeURIComponent\(containerId\)\}&warehouseId=/) assert.match(value, /tasks\?loadingPlanId=\$\{encodeURIComponent\(planId\)\}&warehouseId=/) assert.match(value, /loadingPlanId: planId, containerBookingId: containerId/) - assert.doesNotMatch(value.slice(value.indexOf('无真实柜号推送预装计划成功'), value.indexOf('const linkResponse')), + assert.doesNotMatch(value.slice(value.indexOf('分配后自动建立预装关联且计划可读'), value.indexOf('const linkResponse')), /\/transport/) assert.match(value, /订舱放舱为 RELEASED 且不预生成真实货柜/) - assert.match(value, /无真实柜号推送预装计划成功/) + assert.match(value, /分配后自动建立预装关联且计划可读/) assert.match(value, /订舱 RELEASED 但预装未 CONFIRMED 不生成 PDA 任务/) assert.match(value, /计划级原子确认整份预装并触发唯一 PDA 任务/) assert.match(value, /confirmationStatus === 'CONFIRMED'/) @@ -398,7 +399,7 @@ test('container loading permits manual cross-channel allocation and keeps stable '手工装柜允许两个真实有效渠道之间跨渠道分配', '跨渠道分配验收后释放入仓码供主流程继续', '人工分配本轮候选货物', - '无真实柜号推送预装计划成功', + '分配后自动建立预装关联且计划可读', '无号 push 成功后 PDA 查询 A0404', '无号且未关联订舱不生成 PDA 任务', '订舱放舱为 RELEASED 且不预生成真实货柜', @@ -431,16 +432,18 @@ test('container loading permits manual cross-channel allocation and keeps stable assert.doesNotMatch(batch, /containerNo:/) }) -test('preload and loading versions use immediate operation baselines and exact push replay', () => { +test('preload and loading versions use immediate baselines and stable automatic bridge reads', () => { const container = source('./phase3-container-loading.mjs') - assert.match(container, /const pushRequest = Object\.freeze\(\{[\s\S]*path:[\s\S]*body: Object\.freeze[\s\S]*headers: Object\.freeze/) - assert.equal((container.match(/request\(context, 'POST', pushRequest\.path, pushRequest\.body, pushRequest\.headers\)/g) || []).length, 2) + assert.match(container, /const pushRequest = Object\.freeze\(\{ path: `\$\{SMART\}\/plans\/\$\{planId\}` \}\)/) + assert.equal((container.match(/request\(context, 'GET', pushRequest\.path\)/g) || []).length >= 4, true) + assert.match(container, /自动关联保持 EDITING 且无需额外推送版本/) assert.match(container, /const beforeClaimPlanVersion = Number\(beforeClaimPlan\?\.version\)/) assert.match(container, /Number\(claimedPlan\?\.version\) > beforeClaimPlanVersion/) const loading = source('./phase3-loading.mjs') - assert.match(loading, /const pushRequest = Object\.freeze\(\{[\s\S]*body: Object\.freeze[\s\S]*headers: Object\.freeze/) - assert.equal((loading.match(/request\(context, 'POST', pushRequest\.path, pushRequest\.body, pushRequest\.headers\)/g) || []).length, 2) + assert.match(loading, /const pushRequest = Object\.freeze\(\{ path: `\$\{smartLoadingBase\}\/plans\/\$\{loadingPlanId\}` \}\)/) + assert.equal((loading.match(/request\(context, 'GET', pushRequest\.path\)/g) || []).length >= 4, true) + assert.match(loading, /自动关联保持 EDITING、身份稳定且无需额外推送版本/) assert.match(loading, /push exact replay 对计划版本历史关联和任务零漂移/) assert.match(loading, /Number\(claimedPlan\?\.version\) > beforeClaimPlanVersion/) assert.match(loading, /MATSON_SUPPLIER_NAME = 'MATSON 开发联调船司'/) @@ -524,18 +527,19 @@ test('phase3 loading proves stale inventory zero-write and defers final completi assert.match(loading, /authoritativeRemaining === authoritativeTotal - assignedPackageCount/) assert.match(loading, /调整箱数超过服务端权威总箱数返回A0400/) assert.match(loading, /超权威总箱数失败后计划和明细零写入/) - assert.match(loading, /taskItemsSignature\(afterStaleTask\) === taskItemsSignature\(beforeMutationTask\)/) + assert.match(loading, /taskItemsSignature\(taskAfterStale\) === taskItemsSignature\(incrementedTask\)/) assert.match(loading, /动态新增计划明细按 inboundDetailId 唯一定位/) assert.match(loading, /重新加入动态明细仍按 inboundDetailId 唯一定位/) assert.equal((loading.match(/id\(item\?\.inboundDetailId\) === id\(dynamicCandidate\.inboundDetailId\)/g) || []).length, 2) assert.doesNotMatch(loading, /items\?\.find\?\.\(\(item\) => id\(item\?\.inboundId\) === incrementalInboundId\)/) assertOrder(loading, [ - '旧 token 冲突对任务、明细、已拣数量和操作历史零写入', - '库存变化后刷新 PDA 实时库存快照', - '原子提交柜次 PDA 整批拣货', - 'PDA 原子批次同参同键重放保持待人工确认', '回查下游门禁前原子 Batch 审计', '最终确认前仅保留 CLAIM/BATCH 且无 COMPLETE_LOADING', + 'LOADING 阶段向同一 PDA task 增加入库单', + '增量后旧 inventory token 明确 A0409 并要求刷新', + '增量旧 token 冲突后 plan/task/items 零写入', + '重新增量后刷新 inventory snapshot', + '刷新后完成动态新增项拣货', '报关与最终完成前保持 LOADING 且不提前写 COMPLETE_LOADING', ]) assert.doesNotMatch(loading, /complete-before-downstream/) @@ -629,10 +633,11 @@ test('downstream container consumers prefer the phase3Loading fixture', () => { assert.match(ocean, /完整 DRAFT 放舱为 RELEASED 且冻结五项运输事实/) assert.match(ocean, /finalStatus: 'RELEASED', containerBookingId: null, loadingPlanId: null/) assert.match(source('./phase3-loading.mjs'), /oceanBookingId: bookingId, loadingPlanId, containerBookingId/) - assert.match(serviceCenter, /if \(!allocationMatches\) return/) assert.match(serviceCenter, /category=TRUCKING&status=ACTIVE/) assert.match(serviceCenter, /oceanBookingId: ocean\.id/) - assert.match(serviceCenter, /containerBookingId: linkContainer \? id\(containerFlow\.containerBookingId\) : null/) + const servicePayload = serviceCenter.slice(serviceCenter.indexOf('const payload = {'), + serviceCenter.indexOf('const response =', serviceCenter.indexOf('const payload = {'))) + assert.doesNotMatch(servicePayload, /containerBookingId:|loadingPlanId:/) assert.match(serviceCenter, /carrierId: id\(carrier\.id\), carrierName: carrier\.name/) assert.doesNotMatch(serviceCenter, /customerId:|customerName:/) assert.doesNotMatch(serviceCenter, /链路承运商-/) @@ -682,7 +687,7 @@ test('phase3 loading provisions its own marker-isolated WAIT_SPLIT fixture', asy const run = new AcceptanceRun({ gateway: 'http://127.0.0.1', modules: [], username: '', password: '', output: '' }) const entry = source('../admin-test-env-acceptance.mjs') assert.match(entry, /module === 'phase3-loading'/) - assert.match(entry, /preparePhase3LoadingFixture\(\)/) + assert.match(entry, /preparePhase3LoadingFixture\('', requireSharedOverflow\)/) assert.match(entry, /orders-phase3-loading-fixture/) assert.match(entry, /inbound-measurement-phase3-loading-fixture/) assert.match(entry, /marker: originalMarker/) diff --git a/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs index 261b0e84b..b785266cd 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs @@ -12,15 +12,21 @@ const source = fs.readFileSync(path.join(here, 'phase4-overseas-warehouse.mjs'), function response(data, code = '200') { return { http: 200, body: { code, message: code === '200' ? 'success' : 'conflict', data }, requestId: 'request-test', traceId: 'trace-test' } } function voidResponse() { return { http: 200, body: { code: '200', message: 'success' }, requestId: 'request-test', traceId: 'trace-test' } } function page(records = []) { return { records, page: 1, size: 20, total: records.length, pages: records.length ? 1 : 0 } } -function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehouseRecords = [{ id: '20001', status: 'ACTIVE', warehouseCode: 'LAX', warehouseName: '洛杉矶仓' }] } = {}) { +function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehouseRecords = [{ id: '20001', status: 'ACTIVE', warehouseType: 'OVERSEAS', warehouseCode: 'LAX', warehouseName: '洛杉矶仓' }] } = {}) { const calls = []; const steps = []; const gaps = []; let createdPallet = null; let createdLocation = null; let createdRelabel = null const context = { context: {}, createdResources: {}, marker: 'ACC-PHASE4-TEST', calls, steps, gaps, - async request(method, requestPath) { calls.push({ channel: 'domestic', method, path: requestPath }); return response(page(tenants)) }, - async requestOverseas(method, requestPath, body) { - calls.push({ channel: 'overseas', method, path: requestPath, body }) + async request(method, requestPath, body) { + calls.push({ channel: 'domestic', method, path: requestPath, body }) + if (requestPath.startsWith('/api/platform/v1/iam/tenants?')) return response(page(tenants)) + if (method === 'GET' && requestPath.startsWith('/api/platform/v1/warehouse-admin?')) return response(page(warehouseRecords)) + if (method === 'GET' && requestPath.includes('/warehouse-admin/20001?')) return response({ warehouse: { id: '20001', customerId: '0', warehouseType: 'OVERSEAS', status: 'ACTIVE' } }) if (method === 'POST' && requestPath === '/api/platform/v1/warehouse-admin') return response('20001') if (method === 'PATCH' && requestPath.includes('/warehouse-admin/20001/status?')) return response(null) + throw new Error(`Unexpected domestic path: ${requestPath}`) + }, + async requestOverseas(method, requestPath, body) { + calls.push({ channel: 'overseas', method, path: requestPath, body }) if (method === 'POST' && requestPath.endsWith('/warehouse-admin/20001/locations')) { createdLocation = { id: '72002', customerId: '0', warehouseId: '20001', ...body }; return response('72002') } if (method === 'PUT' && requestPath.endsWith('/warehouse-admin/20001/locations/72002')) { createdLocation = { ...createdLocation, ...body }; return voidResponse() } if (method === 'DELETE' && requestPath.endsWith('/warehouse-admin/20001/locations/72002')) { createdLocation = null; return voidResponse() } @@ -48,8 +54,6 @@ function contextFixture({ tenants = [{ id: '10001', status: 'ACTIVE' }], warehou if (requestPath.includes('/inventory/balances?')) return response(page([{ id: '60001', customerId: '10001', skuCode: 'SKU-1', totalPackages: 10, availablePackages: 10, updatedAt: '2026-08-21T10:00:00' }])) if (requestPath.includes('/inventory/balances/60001/positions?')) return response([{ id: '61001', balanceId: '60001', totalPackages: 10 }]) if (requestPath.includes('/inventory/ledger?')) return response(page([{ id: '62001', customerId: '10001', businessType: 'STOCK_IN', beforePackages: 0, deltaPackages: 10, afterPackages: 10, occurredAt: '2026-08-21T10:00:00' }])) - if (requestPath.startsWith('/api/platform/v1/warehouse-admin?')) return response(page(warehouseRecords)) - if (requestPath.includes('/warehouse-admin/20001?')) return response({ warehouse: { id: '20001', customerId: '10001', status: 'ACTIVE' } }) if (requestPath.endsWith('/warehouse-admin/20001/locations?page=1&size=20')) return response(page([{ id: '72001', customerId: '0', warehouseId: '20001', zoneCode: 'A', locationCode: 'A-01', locationType: 'STORAGE', maxLoadKg: 1000, status: 'ACTIVE' }])) if (requestPath.endsWith('/warehouse-admin/20001/locations/72001')) return response({ id: '72001', customerId: '0', warehouseId: '20001', locationCode: 'A-01', status: 'ACTIVE' }) if (requestPath.endsWith('/warehouse-admin/20001/locations/72002')) return createdLocation == null @@ -81,7 +85,10 @@ test('既有仓库场景只写本轮库位托盘与换标生命周期', () => { return run(context).then(() => { assert.ok(context.calls.length >= 12) assert.ok(context.calls.every((call) => ['GET', 'POST', 'PUT', 'DELETE'].includes(call.method))) - assert.equal(context.calls.filter((call) => call.channel === 'domestic').length, 1) + assert.equal(context.calls.filter((call) => call.channel === 'domestic').length, 3) + assert.ok(context.calls.some((call) => call.channel === 'domestic' && call.path.startsWith('/api/platform/v1/warehouse-admin?'))) + assert.ok(context.calls.some((call) => call.channel === 'domestic' && call.path.includes('/warehouse-admin/20001?'))) + assert.equal(context.calls.some((call) => call.channel === 'overseas' && call.path === '/api/platform/v1/warehouse-admin'), false) assert.ok(context.calls.filter((call) => call.channel === 'overseas').every((call) => call.path.startsWith('/api/platform/v1/'))) const writes = context.calls.filter((call) => call.method !== 'GET') assert.ok(writes.some((call) => call.method === 'DELETE' && call.path.includes('/locations/72002'))) @@ -103,10 +110,12 @@ test('既有仓库场景只写本轮库位托盘与换标生命周期', () => { test('覆盖冻结的海外只读列表与有记录才查详情', async () => { const context = contextFixture() await run(context) - for (const pathToken of ['/last-leg/orders?', '/relabel-applications?', '/inbound-plans?', '/inventory/summary?', '/inventory/balances?', '/inventory/ledger?', '/warehouse-admin?', '/locations?', '/pallets?']) { + for (const pathToken of ['/last-leg/orders?', '/relabel-applications?', '/inbound-plans?', '/inventory/summary?', '/inventory/balances?', '/inventory/ledger?', '/locations?', '/pallets?']) { assert.ok(context.calls.some((call) => call.channel === 'overseas' && call.path.includes(pathToken)), pathToken) } - for (const detailToken of ['/orders/30001?', '/relabel-applications/RL-1?', '/inbound-plans/IP-1?', '/balances/60001/positions?', '/warehouse-admin/20001?', '/pallets/70001']) { + assert.ok(context.calls.some((call) => call.channel === 'domestic' && call.path.includes('/warehouse-admin?'))) + assert.ok(context.calls.some((call) => call.channel === 'domestic' && call.path.includes('/warehouse-admin/20001?'))) + for (const detailToken of ['/orders/30001?', '/relabel-applications/RL-1?', '/inbound-plans/IP-1?', '/balances/60001/positions?', '/pallets/70001']) { assert.ok(context.calls.some((call) => call.path.includes(detailToken)), detailToken) } for (const call of context.calls.filter((item) => item.path.includes('/inventory/summary?') || item.path.includes('/inventory/balances/export?') || item.path.includes('/inventory/ledger/export?'))) { diff --git a/scripts/admin-test-env-modules/service-center-flow.mjs b/scripts/admin-test-env-modules/service-center-flow.mjs index eba1206e9..e5ecff44d 100644 --- a/scripts/admin-test-env-modules/service-center-flow.mjs +++ b/scripts/admin-test-env-modules/service-center-flow.mjs @@ -84,8 +84,6 @@ export async function run(context) { const payload = { outboundOrderId: null, oceanBookingId: ocean.id, - containerBookingId: null, - loadingPlanId: null, warehouseId: id(containerFlow?.warehouseId || context.createdResources?.warehouse?.id || context.context?.warehouseId || '193000000000000001'), carrierId: id(carrier.id), carrierName: carrier.name, @@ -101,7 +99,8 @@ export async function run(context) { const appointmentId = id(appointment?.id) context.assert(MODULE, '拖车预约仅关联订舱且版本和审计字段完整', response, Boolean(appointmentId) && id(appointment?.oceanBookingId) === id(ocean.id) && Number.isFinite(Number(appointment?.version)) - && !id(appointment?.containerBookingId) && !id(appointment?.loadingPlanId) + && !Object.hasOwn(appointment || {}, 'containerBookingId') + && !Object.hasOwn(appointment || {}, 'loadingPlanId') && Boolean(appointment?.createdAt || appointment?.createdBy || appointment?.operatorName), `appointmentId=${appointmentId || 'missing'}, oceanBookingId=${appointment?.oceanBookingId || 'missing'}, containerBookingId=${appointment?.containerBookingId || 'n/a'}, loadingPlanId=${appointment?.loadingPlanId || 'n/a'}, version=${appointment?.version ?? 'missing'}`, OPTIONS) if (!appointmentId) return @@ -130,7 +129,8 @@ export async function run(context) { const final = context.apiSuccess(MODULE, '回查跨服务链路安全终态', detail, '', OPTIONS) context.assert(MODULE, '跨服务链路终态持久化且关联订舱未漂移', detail, final?.status === 'COMPLETED' && id(final?.oceanBookingId) === id(ocean.id) - && !id(final?.containerBookingId) && !id(final?.loadingPlanId), + && !Object.hasOwn(final || {}, 'containerBookingId') + && !Object.hasOwn(final || {}, 'loadingPlanId'), `status=${final?.status || 'missing'}, oceanBookingId=${final?.oceanBookingId || 'missing'}, containerBookingId=${final?.containerBookingId || 'n/a'}, loadingPlanId=${final?.loadingPlanId || 'n/a'}`, OPTIONS) const historyResponse = await context.request('GET', `${TRUCK}/${appointmentId}/history`) diff --git a/scripts/admin-test-env-modules/suppliers-contract.test.mjs b/scripts/admin-test-env-modules/suppliers-contract.test.mjs index 075a79a26..e07505397 100644 --- a/scripts/admin-test-env-modules/suppliers-contract.test.mjs +++ b/scripts/admin-test-env-modules/suppliers-contract.test.mjs @@ -8,7 +8,7 @@ const openapi=readFileSync(new URL('../../warehouse/src/main/resources/openapi/s test('supplier acceptance covers full CRUD-like lifecycle, carrier matrix and boundary failures',()=>{ for(const marker of ['列表分页与订舱筛选','查询四类供应商统计','新增供应商','查询供应商详情','编辑供应商','停用供应商','重复名称返回冲突','tenantId非0被拒绝','非BOOKING携带carrierCodes返回A0400并保留链路ID','客户账号调用平台供应商接口被拒绝','requestId','traceId']) assert.match(source,new RegExp(marker)) assert.match(source,/carrierCodes:\['MATSON','COSCO'\]/) - assert.match(source,/JSON\.stringify\(detail\?\.carrierCodes\)===JSON\.stringify\(\['MATSON','COSCO'\]\)/) + assert.match(source,/sameCarrierCodes\(detail\?\.carrierCodes,\['MATSON','COSCO'\]\)/) assert.doesNotMatch(source,/scope\s*:/) }) test('supplier module is registered in the default full acceptance orchestration',()=>assert.match(orchestrator,/CONFIGURED_MODULES = \[[^\]]*'suppliers'/s)) diff --git a/scripts/admin-test-env-modules/test-container-inbound-plan-batch-contract.test.mjs b/scripts/admin-test-env-modules/test-container-inbound-plan-batch-contract.test.mjs index 61d75e712..472e02f9b 100644 --- a/scripts/admin-test-env-modules/test-container-inbound-plan-batch-contract.test.mjs +++ b/scripts/admin-test-env-modules/test-container-inbound-plan-batch-contract.test.mjs @@ -96,6 +96,9 @@ test('Admin consumes a server-paged container summary and never derives 15 from assert.match(adminApi, /listContainerPlanProgress/) assert.match(adminApi, /container-summaries/) assert.match(adminView, /listContainerPlanProgress\(/) - assert.match(adminView, /containerRows\.value\s*=\s*result\.records/) + assert.match(adminView, /const pendingRecords\s*=\s*result\.records\.filter/) + assert.match(adminView, /containerRows\.value\s*=\s*pendingRecords/) + assert.match(adminView, /containerOptions\.value\s*=\s*result\.records/) + assert.match(adminView, /if \(request !== containerOptionRequest\) return/) assert.doesNotMatch(adminView, /(?:rows|result\.records)\.value?\.(?:reduce|filter|forEach)[\s\S]{0,300}containerNo/) }) diff --git a/scripts/admin-test-env-modules/truck-appointments.mjs b/scripts/admin-test-env-modules/truck-appointments.mjs index d87df2a34..0a0c9f485 100644 --- a/scripts/admin-test-env-modules/truck-appointments.mjs +++ b/scripts/admin-test-env-modules/truck-appointments.mjs @@ -1,5 +1,6 @@ import { ensureMatsonCarrierPartner } from './carrier-partner-fixture.mjs' import { oceanBookingDraft } from './ocean-booking-fixture.mjs' +import { truckAppointmentFieldGaps } from './fulfillment-list-field-contract.mjs' const MODULE = 'truck-appointments' const OPTIONS = { scenario: 'booking-level-truck-appointment-lifecycle', deploymentBlocker: true } @@ -9,6 +10,9 @@ const FORBIDDEN_TRUCK_FIELDS = ['containerBookingId', 'loadingPlanId', 'containe function id(value) { return value == null ? '' : String(value) } function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } +function responseEvidence(response) { + return `HTTP=${response?.http ?? 'missing'}, code=${codeOf(response)}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` +} function data(response) { return codeOf(response) === '200' ? response.body.data : null } function failure(response) { return response?.http === 0 || response?.http >= 400 || (response?.http === 200 && codeOf(response) !== '200') } function records(value) { return Array.isArray(value) ? value : value?.records || [] } @@ -201,8 +205,10 @@ export async function run(context) { const appointmentId = id(appointment?.id); if (!appointmentId) return const path = `${TRUCKS}/${appointmentId}` context.assert(MODULE, '创建响应完全无柜次协议且费用正确', createdResponse, - !hasForbiddenTruckFields(appointment) && appointment?.currency === 'RMB' && Number(appointment?.payableAmount) === 0, - `appointmentNo=${appointment?.appointmentNo || 'missing'}`, OPTIONS) + truckAppointmentFieldGaps(appointment).length === 0 + && !hasForbiddenTruckFields(appointment) && appointment?.currency === 'RMB' + && Number(appointment?.payableAmount) === 0, + `appointmentNo=${appointment?.appointmentNo || 'missing'}, fieldGaps=${truckAppointmentFieldGaps(appointment).join('|') || 'none'}`, OPTIONS) const afterCreateCandidate = await candidate(context, identity) context.assert(MODULE, '创建后订舱立即退出候选', afterCreateCandidate.response, !afterCreateCandidate.item, `appointmentNo=${appointment.appointmentNo}`, OPTIONS) @@ -219,12 +225,45 @@ export async function run(context) { `dispatch-order-${identity.suffix}.pdf`) const uploadAttachmentResponse = await context.requestMultipart('POST', `${path}/attachments/upload`, attachmentForm, context.token) - const attachment = context.apiSuccess(MODULE, '上传DISPATCH_ORDER拖车附件', uploadAttachmentResponse, '', OPTIONS) + const attachment = context.apiSuccess(MODULE, '上传并关联DISPATCH_ORDER拖车附件', uploadAttachmentResponse, '', OPTIONS) const attachmentListResponse = await request(context, 'GET', `${path}/attachments`) const attachmentList = context.apiSuccess(MODULE, '查询拖车附件列表', attachmentListResponse, '', OPTIONS) context.assert(MODULE, '附件列表包含本轮运营附件', attachmentListResponse, records(attachmentList).some((item) => id(item?.id) === id(attachment?.id) && item?.attachmentType === 'DISPATCH_ORDER'), `attachmentId=${id(attachment?.id) || 'missing'}`, OPTIONS) + if (!attachment?.id || !attachment?.fileId) return + const deleteUploadedResponse = await request(context, 'DELETE', + `${path}/attachments/${encodeURIComponent(id(attachment?.id))}`) + context.addStep(MODULE, '删除首次上传的拖车附件引用', deleteUploadedResponse, + deleteUploadedResponse.http === 200 && codeOf(deleteUploadedResponse) === '200', + responseEvidence(deleteUploadedResponse), OPTIONS) + const referencedResponse = await request(context, 'POST', `${path}/attachments`, { + fileId: id(attachment?.fileId), attachmentType: 'DISPATCH_ORDER', + fileName: attachment?.fileName || `dispatch-order-${identity.suffix}.pdf`, + fileHash: attachment?.fileHash || null, + }) + const referenced = context.apiSuccess(MODULE, '重新引用平台归属拖车附件', referencedResponse, '', OPTIONS) + const referencedListResponse = await request(context, 'GET', `${path}/attachments`) + const referencedList = context.apiSuccess(MODULE, '回查重新引用的拖车附件', referencedListResponse, '', OPTIONS) + context.assert(MODULE, '附件列表包含重新引用的DISPATCH_ORDER', referencedListResponse, + records(referencedList).some((item) => id(item?.id) === id(referenced?.id) + && id(item?.fileId) === id(attachment?.fileId) && item?.attachmentType === 'DISPATCH_ORDER'), + `attachmentId=${id(referenced?.id) || 'missing'}, fileId=${id(attachment?.fileId) || 'missing'}`, OPTIONS) + const deleteReferencedResponse = await request(context, 'DELETE', + `${path}/attachments/${encodeURIComponent(id(referenced?.id))}`) + context.addStep(MODULE, '删除重新引用的拖车附件', deleteReferencedResponse, + deleteReferencedResponse.http === 200 && codeOf(deleteReferencedResponse) === '200', + responseEvidence(deleteReferencedResponse), OPTIONS) + const replacementForm = new FormData(); replacementForm.append('attachmentType', 'DISPATCH_ORDER') + replacementForm.append('file', new Blob([minimalPdfBytes(`${context.marker}-DISPATCH_ORDER-REPLACEMENT`)], + { type: 'application/pdf' }), `dispatch-order-replacement-${identity.suffix}.pdf`) + const replacementResponse = await context.requestMultipart('POST', `${path}/attachments/upload`, + replacementForm, context.token) + const replacement = context.apiSuccess(MODULE, '恢复一份DISPATCH_ORDER拖车附件供后续状态流转', + replacementResponse, '', OPTIONS) + context.assert(MODULE, '恢复附件使用新引用且仍指向平台文件', replacementResponse, + Boolean(id(replacement?.id) && id(replacement?.fileId)) && id(replacement?.id) !== id(attachment?.id), + `attachmentId=${id(replacement?.id) || 'missing'}, fileId=${id(replacement?.fileId) || 'missing'}`, OPTIONS) const editedResponse = await request(context, 'PUT', path, truckAppointmentPayload(context, identity.suffix, identity, Number(appointment.version), { currency: 'USD', payableAmount: 12.34 })) @@ -254,9 +293,10 @@ export async function run(context) { const finalDetailResponse = await request(context, 'GET', path) const finalDetail = context.apiSuccess(MODULE, '回查COMPLETED订舱级拖车详情', finalDetailResponse, '', OPTIONS) context.assert(MODULE, '完整生命周期完成且详情无柜次协议', finalDetailResponse, - finalDetail?.status === 'COMPLETED' && !hasForbiddenTruckFields(finalDetail) + finalDetail?.status === 'COMPLETED' && truckAppointmentFieldGaps(finalDetail).length === 0 + && !hasForbiddenTruckFields(finalDetail) && id(finalDetail?.oceanBookingId) === identity.oceanBookingId, - `appointmentNo=${finalDetail?.appointmentNo || 'missing'}, status=${finalDetail?.status || 'missing'}`, OPTIONS) + `appointmentNo=${finalDetail?.appointmentNo || 'missing'}, status=${finalDetail?.status || 'missing'}, fieldGaps=${truckAppointmentFieldGaps(finalDetail).join('|') || 'none'}`, OPTIONS) const afterCompleteCandidate = await candidate(context, identity) context.assert(MODULE, 'COMPLETED后订舱恢复候选', afterCompleteCandidate.response, Boolean(afterCompleteCandidate.item), `oceanBookingNo=${identity.oceanBookingNo}`, OPTIONS) diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index d57e476ad..0e09e69e1 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -221,6 +221,8 @@ Assert-Path 'apps/client-web/src/modules/first-leg/components/InboundBillingPane # 称重确认后的整票库位块和智能装柜暂估数量已成为跨端权威事实,索引必须直达并锁住回退顺序。 $measurementLocationOccupationService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementLocationOccupationService.java') +$inboundMeasurementService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java') +$measurementSubmissionService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/MeasurementSubmissionService.java') $smartLoadingService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java') $inboundSchema = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/db/phase2-inbound-schema.sql') $adminWeightPanel = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/inbounds/weight/components/WeightOcrPanel.vue') @@ -228,10 +230,17 @@ $clientOrdersView = Read-RequiredText (Join-Path $workspaceRoot 'apps/client-web Assert-Contains $rootAgents '称重确认、整票库位块与部分拣货资源结算' '根称重库位块路由' Assert-Contains $backendAgents '称重确认、整票库位块与 PDA 资源结算' '后端称重库位块路由' Assert-Contains $firstlegAgents 'MeasurementLocationOccupationService.java' 'Firstleg 称重库位块入口' -Assert-Contains $firstlegAgents '实测体积、测量批次冻结申报体积、Order 最新客户申报体积' 'Firstleg 库位块体积回退顺序' +Assert-Contains $firstlegAgents '最新测量批次的正数 `totalVolumeCbm/originalVolumeCbm` 取较大值' 'Firstleg 库位块测量批次体积选择' +Assert-Contains $firstlegAgents '再无有效值才查询 Order 最新客户申报体积' 'Firstleg 库位块体积回退顺序' +Assert-Contains $firstlegAgents '库位指引失败只记录警告,不阻断称重确认' 'Firstleg 称重库位非阻断语义' +Assert-Contains $firstlegAgents '`ensureForSubmission` 再次补齐和校验' 'Firstleg 提交库位补偿入口' Assert-Contains $firstlegAgents '槽位与称重唯一键必须包含 `inbound_id`' 'Firstleg 部分拣货快照身份' Assert-Contains $adminAgents '`locationCode/slotStatus/sharedOverflow`' 'Admin 权威称重库位状态' Assert-Contains $measurementLocationOccupationService 'orderCargo.measurementDeclaration' 'Firstleg Order 申报体积回退' +Assert-Contains $measurementLocationOccupationService 'measuredVolume.max(declaredVolume)' 'Firstleg 测量批次体积取较大值' +Assert-Contains $measurementLocationOccupationService 'ensureForSubmission' 'Firstleg 提交库位补齐实现' +Assert-Contains $inboundMeasurementService 'ensureLocationWithoutBlocking' 'Firstleg 称重库位非阻断实现' +Assert-Contains $measurementSubmissionService 'locations.ensureForSubmission' 'Firstleg 提交库位补齐调用' Assert-Contains $firstlegOpenApi '测量批次冻结的申报体积、Order 最新客户申报体积' '称重确认体积回退 OpenAPI' Assert-Contains $inboundSchema 'UNIQUE KEY uk_pallet_allocation_slot (tenant_id, inbound_id, block_id, slot_no)' '部分拣货块槽快照唯一键' Assert-Contains $inboundSchema 'UNIQUE KEY uk_pallet_allocation_weight (tenant_id, inbound_id, weight_result_id)' '部分拣货称重快照唯一键'