diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 033c79c9e..2e012ad41 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -24,7 +24,7 @@ | 入仓计划分页、转换与详情 | `inboundplan/controller/InboundPlanController.java`、`inboundplan/dto/InboundPlanRequests.java`、`InboundPlanListResponse.java`、`inboundplan/mapper/InboundPlanQueryMapper.java`、`inboundplan/service/InboundPlanService.java` | | 托盘分配算法 | `inboundplan/service/PalletAllocator.java` | | FBA 预约创建、详情与编辑 | `InboundPlanController.java` 的 `/fba-appointments/**`、`inboundplan/service/InboundFbaAppointmentService.java`、`BatchFbaAppointmentRequest.java`、`UpdateFbaAppointmentRequest.java` | -| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,旧 planNo/allocationId Controller 已移除 | +| PDA 入仓、实收、上架与完成 | `inboundplan/pda/controller/ReceivingContainerController.java`、`inboundplan/pda/service/ReceivingContainerService.java`、内部编排 `InboundPdaWorkflowService.java`;公网入口固定为 `/api/pda/v1/last-leg/receiving-containers/**`,`/warehouse-profile` 只按 Token 绑定仓解析共享仓库中文名称与编码,旧 planNo/allocationId Controller 已移除 | | PDA 卸柜照片上传、归属校验与预览 | `ReceivingContainerController.java` 的 `/current/unload-photos/files`、`/current/unload-photos/files/{fileId}/download`,网关 `inboundplan/pda/gateway/UnloadPhotoFileGateway.java`、`HttpUnloadPhotoFileGateway.java`;File 类型固定 `OVERSEAS_UNLOAD_PHOTO`,下载只允许当前会话已签收的 fileId 并返回 `no-store` | | 卸柜照片与海外卸柜完成 Outbox | `ReceivingContainerController.java`、`ReceivingContainerService.java`、`inboundplan/pda/service/InboundUnloadCompletedOutboxService.java`、`entity/LastlegEventOutbox.java`、`mapper/LastlegEventOutboxMapper.java`;多计划同柜只能在全柜完成后调用 `publishContainerCompleted`,按来源柜预订 ID 幂等发布一次,完成时刻与拍照时刻分离;事件模型见 `../common-event-contract/AGENTS.md` | | 入仓后 SKU 库存余额、库位和流水 | `inventory/`;平台只读查询与非出库库存命令分别从 `inventory/query/controller/InventoryQueryController.java`、`inventory/command/controller/InventoryCommandController.java` 进入 | diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java index 83f389c44..d614409c4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java @@ -47,6 +47,14 @@ public class ReceivingContainerController { return ApiResponse.success(service.candidates(identity.operatorKey(), identity.overseasWarehouseId())); } + @GetMapping("/warehouse-profile") + @Operation(summary = "Get the authenticated warehouse picker's current Chinese warehouse master name") + public ApiResponse warehouseProfile( + @AuthenticationPrincipal AuthenticatedUser user) { + PdaWarehouseIdentity identity = identity(user); + return ApiResponse.success(service.warehouseProfile(identity.overseasWarehouseId())); + } + @GetMapping("/{containerNo}") @Operation(summary = "Get one receiving container without exposing plan or allocation IDs") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java index f94f3f1ed..23a3be0bd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java @@ -10,6 +10,8 @@ public final class ReceivingContainerResponses { List destinationCodes, int plannedPalletCount, int totalPackages, int receivedPackages, boolean currentAccount) { } + public record WarehouseProfile(String warehouseId, String warehouseCode, String warehouseName) { } + public record Session(String containerNo, Long overseasWarehouseId, String overseasWarehouseName, String status, String operatorName, int version, List photos, LocalDateTime preparedAt, LocalDateTime signedAt, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java index 74c938574..5aa3b65e0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -87,6 +87,12 @@ public class ReceivingContainerService { .configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true); } + public ReceivingContainerResponses.WarehouseProfile warehouseProfile(long overseasWarehouseId) { + WarehouseResourceGateway.WarehouseReference value = warehouse.requireActiveSharedWarehouse(overseasWarehouseId); + return new ReceivingContainerResponses.WarehouseProfile( + Long.toString(value.warehouseId()), value.warehouseCode(), value.warehouseName()); + } + public List candidates(String operatorKey, long overseasWarehouseId) { ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey)); requireSessionWarehouse(current, overseasWarehouseId); diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index f329e3eec..c059d4b88 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -9,6 +9,12 @@ paths: description: 权限 lastleg:pda:operate;仅 PLATFORM WAREHOUSE_PICKER 可用;仅返回已单调消费 PICKUP_READY(80) 或更高实际船节点、状态为 RECEIVING_AVAILABLE 的柜,不接收 customerId。 security: [{ bearerAuth: [] }] responses: { '200': { description: 柜号候选及箱数进度, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingCandidateListApiResponse' } } } }, '403': { description: 缺少 PDA 操作权限 } } + /api/pda/v1/last-leg/receiving-containers/warehouse-profile: + get: + summary: 查询当前PDA账号绑定的海外仓主档 + description: 权限 lastleg:pda:operate;仓库ID只取Token,名称与编码由共享仓库主档实时解析,不接收warehouseId参数。 + security: [{ bearerAuth: [] }] + responses: { '200': { description: 当前绑定仓库中文名称与编码, content: { application/json: { schema: { $ref: '#/components/schemas/ReceivingWarehouseProfileApiResponse' } } } }, '403': { description: 账号未绑定有效海外仓 } } /api/pda/v1/last-leg/receiving-containers/{containerNo}: get: summary: 查询待签收柜号详情 @@ -691,6 +697,13 @@ components: totalPackages: { type: integer } receivedPackages: { type: integer } pendingPackages: { type: integer } + ReceivingWarehouseProfile: + type: object + required: [warehouseId, warehouseCode, warehouseName] + properties: + warehouseId: { type: string, pattern: '^\d+$' } + warehouseCode: { type: string } + warehouseName: { type: string, description: 国内Warehouse Service主档维护的当前中文名称 } ReceivingCandidate: type: object required: [containerNo, status, planCount, inboundCodeCount, destinationCodes, plannedPalletCount, totalPackages, receivedPackages, currentAccount] @@ -777,6 +790,12 @@ components: - type: object required: [data] properties: { data: { type: array, items: { $ref: '#/components/schemas/ReceivingCandidate' } } } + ReceivingWarehouseProfileApiResponse: + allOf: + - { $ref: '#/components/schemas/ReceivingApiResponseBase' } + - type: object + required: [data] + properties: { data: { $ref: '#/components/schemas/ReceivingWarehouseProfile' } } ReceivingSessionApiResponse: allOf: - { $ref: '#/components/schemas/ReceivingApiResponseBase' } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java index 5cb813bdd..2ac0fd471 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java @@ -124,6 +124,7 @@ class ReceivingContainerContractTest { String yaml = resource("openapi/overseas-business-v1.yaml"); String prefix = "/api/pda/v1/last-leg/receiving-containers"; assertTrue(yaml.contains(prefix + "/candidates:")); + assertTrue(yaml.contains(prefix + "/warehouse-profile:")); assertTrue(yaml.contains(prefix + "/current/receipts/bulk:")); assertTrue(yaml.contains(prefix + "/current/skus/resolve:")); assertTrue(yaml.contains(prefix + "/current/receipts/carton:")); @@ -133,8 +134,8 @@ class ReceivingContainerContractTest { String receivingPaths = yaml.substring(yaml.indexOf(prefix), yaml.indexOf("/api/platform/v1/last-leg/orders:")); List successResponses = receivingPaths.lines().map(String::trim) .filter(line -> line.startsWith("responses: { '200':")).toList(); - assertEquals(16, successResponses.size()); - assertEquals(12, successResponses.stream().filter(line -> line.contains("/schemas/Receiving")).count()); + assertEquals(17, successResponses.size()); + assertEquals(13, successResponses.stream().filter(line -> line.contains("/schemas/Receiving")).count()); assertEquals(4, successResponses.stream().filter(line -> line.contains("/schemas/Pda")).count()); assertTrue(receivingPaths.contains("返回 WAIT_SIGN、SIGNED 或 RECEIVING 当前柜")); assertTrue(receivingPaths.contains("仅 PLATFORM WAREHOUSE_PICKER 可用")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java index 5936cc5b2..c87523822 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java @@ -45,6 +45,17 @@ class ReceivingContainerControllerTest { verify(service).candidates("42", 900L); } + @Test + void warehouseProfileUsesOnlyTheTokenBoundWarehouse() { + when(service.warehouseProfile(900L)).thenReturn( + new ReceivingContainerResponses.WarehouseProfile("900", "US-LAX-01", "洛杉矶一号仓")); + + var response = controller.warehouseProfile(picker()); + + assertThat(response.data().warehouseName()).isEqualTo("洛杉矶一号仓"); + verify(service).warehouseProfile(900L); + } + @Test void merchantIdentityCannotEnterPdaReceivingApiEvenWithForgedAuthority() { AuthenticatedUser merchant = new AuthenticatedUser(7L, 100L, 100L, 8L, "merchant", "MERCHANT", "WEB", diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 7e214efc1..abb0069d8 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -6,12 +6,19 @@ import { pathToFileURL } from 'node:url' import process from 'node:process' const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-container-documents', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-carrier-pickup-ready', 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-downloads', 'customer-operating-statistics'] +const OPTIONAL_MODULES = ['manual-order-splitting-fixture', 'manual-order-splitting-verify', + 'manual-preload-push-fixture', 'manual-preload-push-verify', + 'manual-pda-ready-fixture', 'manual-pda-ready-verify', + 'manual-pda-batch-fixture', 'manual-pda-batch-verify', + 'manual-loading-complete-fixture', 'manual-loading-complete-verify'] +const AVAILABLE_MODULES = new Set([...CONFIGURED_MODULES, ...OPTIONAL_MODULES]) const MODULE_DEPENDENCIES = new Map([ ['suppliers', ['merchant-qualification-fixture']], ['supplemental', ['merchant-qualification-fixture']], ['orders', ['merchant-qualification-fixture']], ['inbound-measurement', ['orders']], + ['manual-order-splitting-fixture', ['inbound-measurement']], ['phase3-container-loading', ['inbound-measurement']], ['phase3-loading', ['phase3-container-loading']], ['phase3-container-overview', ['phase3-loading']], @@ -243,6 +250,12 @@ function usage() { --channel-code 固定物流渠道编码 --order-profile-mode 订单档案模式:fixed 或 randomized,默认fixed --wait-split-count 同一客户创建的 WAIT_SPLIT 订单数,1-100,默认1 + --manual-split-fixture-report 人工分柜准备报告路径,仅只读核验模块使用 + --manual-split-plan-id 人工操作产生的智能装柜计划 ID,仅只读核验模块使用 + --manual-preload-source-report 人工推送预装前置或核验来源报告路径 + --manual-pda-source-report 人工订舱关联、预装确认前置或核验来源报告路径 + --manual-pda-batch-source-report PDA 人工领取、扫码批量提交前置或核验来源报告路径 + --manual-loading-complete-source-report 五类证据与 PDA 最终完成前置或核验来源报告路径 --output JSON 报告路径,默认 reports/ --help 显示帮助 @@ -267,9 +280,9 @@ export function parseArguments(argv) { values.set(name, next); index += 1 } } + const modules = normalizeModuleOrder((values.get('modules') || DEFAULT_MODULES.join(',')).split(',').map((v) => v.trim()).filter(Boolean)) const merchantUsername = values.get('merchant-username') || process.env.MERCHANT_TEST_USERNAME || '' const merchantPassword = values.get('merchant-password') || process.env.MERCHANT_TEST_PASSWORD || '' - const modules = normalizeModuleOrder((values.get('modules') || DEFAULT_MODULES.join(',')).split(',').map((v) => v.trim()).filter(Boolean)) if (merchantUsername && merchantPassword) { const fixtureIndex = modules.indexOf('merchant-qualification-fixture') if (fixtureIndex >= 0) modules.splice(fixtureIndex, 1) @@ -287,12 +300,21 @@ export function parseArguments(argv) { channelName: values.get('channel-name') || '', channelCode: values.get('channel-code') || '', orderProfileMode: values.get('order-profile-mode') || 'fixed', - waitSplitCount: Number(values.get('wait-split-count') || process.env.ADMIN_WAIT_SPLIT_COUNT || 1), + waitSplitCount: Number(values.get('wait-split-count') || process.env.ADMIN_WAIT_SPLIT_COUNT + || (modules.includes('manual-order-splitting-fixture') ? 6 : 1)), + manualSplitFixtureReport: values.get('manual-split-fixture-report') || '', + manualSplitPlanId: values.get('manual-split-plan-id') || '', + manualPreloadSourceReport: values.get('manual-preload-source-report') || '', + manualPdaSourceReport: values.get('manual-pda-source-report') || '', + manualPdaBatchSourceReport: values.get('manual-pda-batch-source-report') || '', + manualLoadingCompleteSourceReport: values.get('manual-loading-complete-source-report') || '', output: values.get('output') || '', } } export function validateExecutionOptions(options) { + const unavailable = options.modules.filter((module) => !AVAILABLE_MODULES.has(module)) + if (unavailable.length > 0) throw new Error(`未注册的 HTTP 验收模块: ${unavailable.join(',')}`) for (const [name, value] of [['gateway', options.gateway], ['username', options.username], ['password', options.password]]) { if (typeof value !== 'string' || value.trim() === '') throw new Error(`缺少必填执行参数: ${name}`) } @@ -321,9 +343,39 @@ export function validateExecutionOptions(options) { throw new Error('randomized订单档案必须同时固定起运仓、渠道名称和渠道编码') } if (options.waitSplitCount > 1) { - const fixtureModules = new Set(['merchant-qualification-fixture', 'orders', 'inbound-measurement']) + const fixtureModules = new Set(['merchant-qualification-fixture', 'orders', 'inbound-measurement', + 'manual-order-splitting-fixture']) if (options.modules.some((module) => !fixtureModules.has(module))) { - throw new Error('批量WAIT_SPLIT只允许执行merchant-qualification-fixture、orders、inbound-measurement') + throw new Error('批量WAIT_SPLIT只允许执行merchant-qualification-fixture、orders、inbound-measurement、manual-order-splitting-fixture') + } + } + if (options.modules.includes('manual-order-splitting-fixture') && options.waitSplitCount < 4) { + throw new Error('manual-order-splitting-fixture要求wait-split-count至少为4') + } + if (options.modules.includes('manual-order-splitting-verify')) { + if (!options.manualSplitFixtureReport) throw new Error('manual-order-splitting-verify缺少manual-split-fixture-report') + if (!/^[1-9][0-9]*$/.test(String(options.manualSplitPlanId))) { + throw new Error('manual-order-splitting-verify要求manual-split-plan-id为正整数') + } + } + for (const module of ['manual-preload-push-fixture', 'manual-preload-push-verify']) { + if (options.modules.includes(module) && !options.manualPreloadSourceReport) { + throw new Error(`${module}缺少manual-preload-source-report`) + } + } + for (const module of ['manual-pda-ready-fixture', 'manual-pda-ready-verify']) { + if (options.modules.includes(module) && !options.manualPdaSourceReport) { + throw new Error(`${module}缺少manual-pda-source-report`) + } + } + for (const module of ['manual-pda-batch-fixture', 'manual-pda-batch-verify']) { + if (options.modules.includes(module) && !options.manualPdaBatchSourceReport) { + throw new Error(`${module}缺少manual-pda-batch-source-report`) + } + } + for (const module of ['manual-loading-complete-fixture', 'manual-loading-complete-verify']) { + if (options.modules.includes(module) && !options.manualLoadingCompleteSourceReport) { + throw new Error(`${module}缺少manual-loading-complete-source-report`) } } return options diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 38908e6e9..4a6955f45 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -4,6 +4,16 @@ 需要为人工联调批量准备同一客户、同一国内仓和同一渠道的 `WAIT_SPLIT` 数据时,使用[《批量待分柜数据生成指南》](批量待分柜数据生成指南.md)。该入口通过正式下单、入库、称重和测量接口生成数据,支持 FBA、海外仓、私人地址及一件代发等稳定随机组合;不得直接写库或用于生产环境。 +人工分柜采用两个可选模块,不进入默认全量入口:`manual-order-splitting-fixture.mjs` 基于本轮至少四票 `WAIT_SPLIT` 数据生成部分分配、整票拼柜和未分配对照清单;人工在 Admin 完成计划分配后,`manual-order-splitting-verify.mjs` 读取准备报告和 `loadingPlanId`,只读核对柜次范围、计划箱数及候选余量。完整命令见同一指南的“人工分柜验收”章节。 + +人工分柜通过后可继续执行 `manual-preload-push-fixture.mjs`:它读取分柜核验报告并冻结推送前计划版本、明细及无号模拟柜状态;人工点击“推送预装”后,再由 `manual-preload-push-verify.mjs` 只读核对 `EDITING` 预装清单、PENDING bridge、版本递增、明细零漂移和 PDA `A0404`。两个模块同样不进入默认全量入口。 + +预装推送通过后,`manual-pda-ready-fixture.mjs` 冻结无号、未关联订舱、不可确认且无 PDA 的基线;人工依次完成订舱 DRAFT、RELEASED 放舱、原模拟柜关联真实柜号和整份计划确认,再由 `manual-pda-ready-verify.mjs` 只读核对 RELEASED、allocation、稳定柜次/计划身份、CONFIRMED readiness 和唯一 `UNASSIGNED` PDA task。两个模块不领取任务,也不进入默认全量入口。 + +PDA 待领取通过后,`manual-pda-batch-fixture.mjs` 冻结任务明细、稳定箱码和库存快照基线;目标 PDA 作业员人工领取、逐箱扫码并一次提交原子 batch 后,`manual-pda-batch-verify.mjs` 只读核对作业员绑定、全量 PICKED、`AWAITING_REVIEW`、一次 CLAIM、一次 BATCH、pending/detail 一致和入库级数量收敛。HTTP 不记录逐次扫码轨迹,真机扫码仍需人工证据。 + +PDA 待复核通过后,`manual-loading-complete-fixture.mjs` 冻结无证据、未完成、RELEASED 订舱和 LOADING 计划基线;人工现场上传五类证据并执行一次最终完成后,`manual-loading-complete-verify.mjs` 只读核对五类真实图片、唯一 COMPLETE_LOADING、任务/计划 COMPLETED、来源入库单和统一订单 LOADED、实装分片数量守恒及货柜总览终态。 + 例如 `--modules system,inbound-weight-ocr` 会依次加载: - `system.mjs` @@ -51,6 +61,11 @@ export async function run(context) { | 入仓自动头程费用、派生详情、人工发布与 MANUAL 其他费用版本 | `inbound-fees.mjs`(前置:`merchant-qualification-fixture,orders,inbound-measurement`) | `inbound-fees-contract.test.mjs` | | 三期模拟柜关联、柜次概览、渠道隔离、装柜模型桥接、PDA、动态增量、四类货柜资料、报关与证据/出仓门禁 | `phase3-container-loading.mjs`、`ocean-booking-fixture.mjs`、`phase3-container-overview.mjs`、`phase3-loading.mjs`、`phase3-loading-model-bridge.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-loading-finalize.mjs`、`phase3-container-documents.mjs`、`phase3-customs.mjs`、`phase3-outbound-dispatch.mjs` | `container-simulation-association-contract.test.mjs`、`container-specification-fixture-contract.test.mjs`、`ocean-booking-channel-contract.test.mjs`、`phase3-container-regression-contract.test.mjs`、`phase3-loading-model-bridge-contract.test.mjs`、`phase3-loading-dynamic-final-contract.test.mjs`、`phase3-container-documents-contract.test.mjs`、`phase3-phase4-departure-pickup-chain-contract.test.mjs`、`phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` | | 分柜与父子入库单数量守恒 | `phase3-order-splitting.mjs` | `phase3-order-splitting-contract.test.mjs` | +| 人工计划分柜准备与只读核验 | `manual-order-splitting-fixture.mjs`、`manual-order-splitting-verify.mjs`(可选,不进入默认全量) | `manual-order-splitting-contract.test.mjs` | +| 人工推送预装前置与推送后只读核验 | `manual-preload-push-fixture.mjs`、`manual-preload-push-verify.mjs`(可选,不进入默认全量) | `manual-preload-push-contract.test.mjs` | +| 人工订舱关联、预装确认与 PDA 待领取核验 | `manual-pda-ready-fixture.mjs`、`manual-pda-ready-verify.mjs`(可选,不进入默认全量) | `manual-pda-ready-contract.test.mjs` | +| PDA 人工领取、逐箱扫码、原子批量提交与待复核核验 | `manual-pda-batch-fixture.mjs`、`manual-pda-batch-verify.mjs`(可选,不进入默认全量) | `manual-pda-batch-contract.test.mjs` | +| 五类装柜证据、PDA 最终完成与 LOADED 终态核验 | `manual-loading-complete-fixture.mjs`、`manual-loading-complete-verify.mjs`(可选,不进入默认全量) | `manual-loading-complete-contract.test.mjs` | | 订舱共享主数据前置与订舱验收 | `ocean-booking-fixture.mjs`、`ocean-bookings.mjs`、`phase3-port-schedules.mjs`、`phase3-carrier-schedule-queries.mjs`(DRAFT 可编辑;RELEASED 冻结;船司查询只创建并写当前 marker 登记任务) | `ocean-booking-prototype-contract.test.mjs`、`ocean-booking-fixture-contract.test.mjs`、`phase3-carrier-schedule-queries.test.mjs` | | 独立拖车预约与可选履约关联 | `truck-appointments.mjs`(同 marker 客户 + ACTIVE TRUCKING 承运商;订舱/柜次/计划可空;DISPATCH 前需提单号和有效 `BILL_OF_LADING` 文件) | `truck-appointments-contract.test.mjs` | | 装柜清单三备注 | `orders.mjs`、`phase3-loading.mjs`(客户备注、最新内部备注、仓库备注;仓库备注 PATCH/清空/精确重放/旧版本零写) | `loading-list-remarks-contract.test.mjs` | diff --git a/scripts/admin-test-env-modules/manual-loading-complete-contract.test.mjs b/scripts/admin-test-env-modules/manual-loading-complete-contract.test.mjs new file mode 100644 index 000000000..5bc28b9d8 --- /dev/null +++ b/scripts/admin-test-env-modules/manual-loading-complete-contract.test.mjs @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import { parseArguments, validateExecutionOptions } from '../admin-test-env-acceptance.mjs' + +const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8') +const fixture = readFileSync(new URL('./manual-loading-complete-fixture.mjs', import.meta.url), 'utf8') +const verify = readFileSync(new URL('./manual-loading-complete-verify.mjs', import.meta.url), 'utf8') + +test('manual loading-complete modules are optional and require a source report', () => { + assert.match(entry, /OPTIONAL_MODULES\s*=\s*\[[^\]]*'manual-loading-complete-fixture'[^\]]*'manual-loading-complete-verify'/s) + const defaults = parseArguments([]).modules + assert.equal(defaults.includes('manual-loading-complete-fixture'), false) + assert.equal(defaults.includes('manual-loading-complete-verify'), false) + for (const module of ['manual-loading-complete-fixture', 'manual-loading-complete-verify']) { + const options = parseArguments(['--gateway', 'http://127.0.0.1:9000', '--username', 'admin', + '--password', 'password-value', '--modules', module, + '--manual-loading-complete-source-report', 'source.json']) + assert.deepEqual(options.modules, [module]) + assert.equal(validateExecutionOptions(options), options) + assert.throws(() => validateExecutionOptions({ ...options, manualLoadingCompleteSourceReport: '' }), + /manual-loading-complete-source-report/) + } +}) + +test('completion preparation is read only and freezes evidence, task, plan and booking gates', () => { + assert.match(fixture, /manualPdaBatchVerification/) + assert.match(fixture, /PHOTO_STEP_1.*PHOTO_STEP_2.*PHOTO_STEP_3.*PHOTO_STEP_4.*SEAL/) + assert.match(fixture, /activeEvidence\.length === 0/) + assert.match(fixture, /finalCompletionStatus === 'IN_PROGRESS'/) + assert.match(fixture, /oceanBooking\?\.status === 'RELEASED'/) + assert.doesNotMatch(fixture, /context\.request\(['"](?:POST|PATCH|PUT|DELETE)/) +}) + +test('completion verifier proves five images, one completion audit and LOADED projections without writes', () => { + assert.match(verify, /task\?\.taskStatus === 'COMPLETED'/) + assert.match(verify, /activeEvidence\.length === fixture\.requiredEvidenceTypes\.length/) + assert.match(verify, /controlledImage\(download\)/) + assert.match(verify, /completeOperations\.length === 1/) + assert.match(verify, /plan\?\.status === 'COMPLETED'/) + assert.match(verify, /processStatus === 'LOADED'/) + assert.match(verify, /mainStatus === 'LOADED'/) + assert.match(verify, /splitRole === 'LOADED'/) + assert.doesNotMatch(verify, /context\.request\(['"](?:POST|PATCH|PUT|DELETE)/) +}) diff --git a/scripts/admin-test-env-modules/manual-loading-complete-fixture.mjs b/scripts/admin-test-env-modules/manual-loading-complete-fixture.mjs new file mode 100644 index 000000000..7d79a07ca --- /dev/null +++ b/scripts/admin-test-env-modules/manual-loading-complete-fixture.mjs @@ -0,0 +1,117 @@ +import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' + +const MODULE = 'manual-loading-complete-fixture' +const OPTIONS = { scenario: 'manual-loading-complete-fixture', deploymentBlocker: true } +const PDA = '/api/platform/v1/first-leg/pda-loading' +const SMART = '/api/platform/v1/first-leg/smart-loading' +const CONTAINERS = '/api/platform/v1/containers' +const REQUIRED_EVIDENCE_TYPES = ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL'] + +function id(value) { return value == null ? '' : String(value) } +function rows(value) { return Array.isArray(value) ? value : [] } + +async function loadSource(context) { + const report = JSON.parse(await readFile(resolve(context.options.manualLoadingCompleteSourceReport), 'utf8')) + const source = report?.createdResources?.manualPdaBatchVerification + const sourceGateway = String(report?.gateway || '').replace(/\/$/, '') + const valid = sourceGateway === context.options.gateway && source?.stage === 'PDA_AWAITING_REVIEW' + && source?.pdaTaskId && source?.loadingPlanId && source?.containerBookingId + context.assert(MODULE, '读取同环境 PDA 待复核核验报告', null, valid, + `sourceMarker=${report?.marker || 'missing'}, sameGateway=${sourceGateway === context.options.gateway}, pdaTaskId=${id(source?.pdaTaskId) || 'missing'}`, + OPTIONS) + return valid ? source : null +} + +function taskItems(value) { + return rows(value).map((item) => ({ id: id(item.id), tenantId: id(item.tenantId), + inboundId: id(item.inboundId), inboundDetailId: id(item.inboundDetailId), + plannedPackageCount: Number(item.plannedPackageCount), pickedPackageCount: Number(item.pickedPackageCount), + itemStatus: item.itemStatus, version: Number(item.version) })) + .sort((left, right) => left.inboundId.localeCompare(right.inboundId) || left.id.localeCompare(right.id)) +} + +function sourceInboundSnapshots(items) { + const values = new Map() + for (const item of rows(items)) { + const inboundId = id(item.inboundId) + const current = values.get(inboundId) || { tenantId: id(item.tenantId), inboundId, pickedPackageCount: 0 } + current.pickedPackageCount += Number(item.pickedPackageCount || 0) + values.set(inboundId, current) + } + return [...values.values()] +} + +export async function run(context) { + const source = await loadSource(context) + if (!source) return + const taskId = id(source.pdaTaskId) + const taskResponse = await context.request('GET', + `${PDA}/tasks/${encodeURIComponent(taskId)}?warehouseId=${encodeURIComponent(source.warehouseId)}`) + const task = context.apiSuccess(MODULE, '最终完成前读取 PDA 待复核任务', taskResponse, '', OPTIONS) + const beforeItems = taskItems(task?.items) + const evidenceOwner = rows(task?.items).find((item) => id(item.inboundId) === id(task?.inboundId)) + || rows(task?.items)[0] + context.assert(MODULE, '任务全部拣齐、待复核且证据归属入库单明确', taskResponse, + id(task?.id) === taskId && task?.taskStatus === 'LOADING' + && task?.workflowStatus === 'AWAITING_REVIEW' && task?.assignment?.assigneeId + && beforeItems.length > 0 && beforeItems.every((item) => item.itemStatus === 'PICKED' + && item.pickedPackageCount === item.plannedPackageCount) + && evidenceOwner?.tenantId && evidenceOwner?.inboundId, + `workflowStatus=${task?.workflowStatus || 'missing'}, items=${beforeItems.length}, evidenceTenantId=${id(evidenceOwner?.tenantId) || 'missing'}, evidenceInboundId=${id(evidenceOwner?.inboundId) || 'missing'}`, + OPTIONS) + if (!evidenceOwner?.tenantId || !evidenceOwner?.inboundId) return + + const evidencePath = `/api/platform/v1/inbounds/${encodeURIComponent(evidenceOwner.inboundId)}/loading/evidence?tenantId=${encodeURIComponent(evidenceOwner.tenantId)}` + const evidenceResponse = await context.request('GET', evidencePath) + const evidence = context.apiSuccess(MODULE, '上传五类证据前读取空基线', evidenceResponse, '', OPTIONS) + const activeEvidence = rows(evidence?.evidences).filter((item) => item.status !== 'VOID') + context.assert(MODULE, '本轮最终完成前尚未上传 ACTIVE 装柜证据', evidenceResponse, + activeEvidence.length === 0, + `activeEvidence=${activeEvidence.map((item) => item.evidenceType).join(',') || 'none'}`, OPTIONS) + + const planResponse = await context.request('GET', `${SMART}/plans/${encodeURIComponent(source.loadingPlanId)}`) + const plan = context.apiSuccess(MODULE, '最终完成前读取运行中装柜计划', planResponse, '', OPTIONS) + context.assert(MODULE, '计划保持 LOADING 且尚未最终完成', planResponse, + id(plan?.id) === id(source.loadingPlanId) && plan?.status === 'LOADING' + && plan?.finalCompletionStatus === 'IN_PROGRESS', + `status=${plan?.status || 'missing'}, finalCompletionStatus=${plan?.finalCompletionStatus || 'missing'}, version=${plan?.version ?? 'missing'}`, + OPTIONS) + + const operationsResponse = await context.request('GET', `${PDA}/tasks/${encodeURIComponent(taskId)}/operations`) + const operations = context.apiSuccess(MODULE, '最终完成前读取任务审计', operationsResponse, '', OPTIONS) + context.assert(MODULE, '完成前只有一次 CLAIM/BATCH 且无 COMPLETE_LOADING', operationsResponse, + rows(operations).filter((item) => item.actionType === 'CLAIM').length === 1 + && rows(operations).filter((item) => item.actionType === 'BATCH').length === 1 + && !rows(operations).some((item) => ['REVIEW', 'COMPLETE_LOADING'].includes(item.actionType)), + `actions=${rows(operations).map((item) => item.actionType).join(',') || 'none'}`, OPTIONS) + + const containerResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(source.containerBookingId)}`) + const container = context.apiSuccess(MODULE, '最终完成前回查 RELEASED 订舱柜次', containerResponse, '', OPTIONS) + const bookingResponse = container?.oceanBookingId + ? await context.request('GET', `/api/platform/v1/ocean-bookings/${encodeURIComponent(container.oceanBookingId)}`) + : containerResponse + const booking = context.apiSuccess(MODULE, '最终完成前回查订舱门禁', bookingResponse, '', OPTIONS) + context.assert(MODULE, '完成前真实柜号与 RELEASED 订舱仍有效', bookingResponse, + id(container?.id) === id(source.containerBookingId) && container?.containerNo === source.containerNo + && booking?.oceanBooking?.status === 'RELEASED', + `containerNo=${container?.containerNo || 'missing'}, oceanBookingId=${id(container?.oceanBookingId) || 'missing'}, bookingStatus=${booking?.oceanBooking?.status || 'missing'}`, + OPTIONS) + + context.createdResources.manualLoadingCompleteFixture = { + schemaVersion: 1, sourceMarker: source.sourceMarker, preparedAt: new Date().toISOString(), + stage: 'MANUAL_EVIDENCE_AND_COMPLETE_READY', pdaTaskId: taskId, + loadingPlanId: id(source.loadingPlanId), containerBookingId: id(source.containerBookingId), + cabinetNo: source.cabinetNo, containerNo: source.containerNo, warehouseId: id(source.warehouseId), + evidenceTenantId: id(evidenceOwner.tenantId), evidenceInboundId: id(evidenceOwner.inboundId), + requiredEvidenceTypes: REQUIRED_EVIDENCE_TYPES, beforeTaskVersion: Number(task.version), + beforePlanVersion: Number(plan?.version), beforeItems, + sourceInbounds: sourceInboundSnapshots(task.items), + instructions: [ + '在 PDA 当前任务中依次现场拍摄空仓、半仓、满仓、锁仓和带锁码照五类证据。', + '五类分别对应 PHOTO_STEP_1、PHOTO_STEP_2、PHOTO_STEP_3、PHOTO_STEP_4、SEAL;SEAL 必须填写非空锁码。', + '确认五类证据均为 ACTIVE 后,只点击一次最终完成;不要调用旧入库单兼容完成入口。', + '完成成功后任务、计划和所有来源入库单应进入唯一终态,立即运行 manual-loading-complete-verify。', + ], + } +} diff --git a/scripts/admin-test-env-modules/manual-loading-complete-verify.mjs b/scripts/admin-test-env-modules/manual-loading-complete-verify.mjs new file mode 100644 index 000000000..be7667639 --- /dev/null +++ b/scripts/admin-test-env-modules/manual-loading-complete-verify.mjs @@ -0,0 +1,163 @@ +import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' + +const MODULE = 'manual-loading-complete-verify' +const OPTIONS = { scenario: 'manual-loading-complete-readonly-verification', deploymentBlocker: true } +const PDA = '/api/platform/v1/first-leg/pda-loading' +const SMART = '/api/platform/v1/first-leg/smart-loading' +const CONTAINERS = '/api/platform/v1/containers' + +function id(value) { return value == null ? '' : String(value) } +function rows(value) { return Array.isArray(value) ? value : [] } + +async function loadFixture(context) { + const report = JSON.parse(await readFile(resolve(context.options.manualLoadingCompleteSourceReport), 'utf8')) + const fixture = report?.createdResources?.manualLoadingCompleteFixture + const sourceGateway = String(report?.gateway || '').replace(/\/$/, '') + const valid = sourceGateway === context.options.gateway && fixture?.schemaVersion === 1 + && fixture?.stage === 'MANUAL_EVIDENCE_AND_COMPLETE_READY' && fixture?.pdaTaskId + && Array.isArray(fixture?.requiredEvidenceTypes) && fixture.requiredEvidenceTypes.length === 5 + && Array.isArray(fixture?.sourceInbounds) && fixture.sourceInbounds.length > 0 + && fixture?.evidenceTenantId && fixture?.evidenceInboundId + context.assert(MODULE, '读取同环境装柜最终完成准备报告', null, valid, + `sourceMarker=${report?.marker || 'missing'}, sameGateway=${sourceGateway === context.options.gateway}, pdaTaskId=${id(fixture?.pdaTaskId) || 'missing'}`, + OPTIONS) + return valid ? fixture : null +} + +function itemIdentity(value) { + return { id: id(value.id), tenantId: id(value.tenantId), inboundId: id(value.inboundId), + inboundDetailId: id(value.inboundDetailId), plannedPackageCount: Number(value.plannedPackageCount), + pickedPackageCount: Number(value.pickedPackageCount), itemStatus: value.itemStatus } +} + +function controlledImage(download) { + const type = String(download?.contentType || '').toLowerCase() + return download?.http === 200 && download?.bytes?.length > 0 + && type.startsWith('image/') && !type.includes('json') && !type.includes('html') +} + +export async function run(context) { + const fixture = await loadFixture(context) + if (!fixture) return + const taskId = id(fixture.pdaTaskId) + + const taskResponse = await context.request('GET', + `${PDA}/tasks/${encodeURIComponent(taskId)}?warehouseId=${encodeURIComponent(fixture.warehouseId)}`) + const task = context.apiSuccess(MODULE, '读取 PDA 最终完成任务', taskResponse, '', OPTIONS) + const afterItems = rows(task?.items).map(itemIdentity) + .sort((left, right) => left.inboundId.localeCompare(right.inboundId) || left.id.localeCompare(right.id)) + const beforeItems = fixture.beforeItems.map(itemIdentity) + context.assert(MODULE, '最终完成后任务与 workflow 同为 COMPLETED 且明细身份不漂移', taskResponse, + id(task?.id) === taskId && id(task?.loadingPlanId) === id(fixture.loadingPlanId) + && id(task?.containerBookingId) === id(fixture.containerBookingId) + && task?.taskStatus === 'COMPLETED' && task?.workflowStatus === 'COMPLETED' + && Number(task?.version) > Number(fixture.beforeTaskVersion) + && JSON.stringify(afterItems) === JSON.stringify(beforeItems), + `taskStatus=${task?.taskStatus || 'missing'}, workflowStatus=${task?.workflowStatus || 'missing'}, beforeVersion=${fixture.beforeTaskVersion}, afterVersion=${task?.version ?? 'missing'}`, + OPTIONS) + + const evidencePath = `/api/platform/v1/inbounds/${encodeURIComponent(fixture.evidenceInboundId)}/loading/evidence?tenantId=${encodeURIComponent(fixture.evidenceTenantId)}` + const evidenceResponse = await context.request('GET', evidencePath) + const evidence = context.apiSuccess(MODULE, '读取 LOADED 后五类不可变证据', evidenceResponse, '', OPTIONS) + const activeEvidence = rows(evidence?.evidences).filter((item) => item.status !== 'VOID') + const evidenceComplete = fixture.requiredEvidenceTypes.every((type) => + activeEvidence.filter((item) => item.evidenceType === type).length === 1) + && activeEvidence.length === fixture.requiredEvidenceTypes.length + && activeEvidence.some((item) => item.evidenceType === 'SEAL' && String(item.sealNo || '').trim()) + context.assert(MODULE, '五类 ACTIVE 证据各一份且 SEAL 锁码有效', evidenceResponse, + evidenceComplete, + `types=${activeEvidence.map((item) => item.evidenceType).join(',') || 'none'}, sealNo=${activeEvidence.find((item) => item.evidenceType === 'SEAL')?.sealNo || 'missing'}`, + OPTIONS) + for (const item of activeEvidence) { + const path = String(item.previewUrl || '') + const controlled = path.startsWith('/api/') && !path.startsWith('//') && !path.includes('://') + const download = controlled ? await context.requestBinary('GET', path) : null + context.assert(MODULE, `${item.evidenceType} 证据可鉴权下载真实图片`, download, + controlled && controlledImage(download), + `evidenceId=${id(item.id)}, path=${path || 'missing'}, bytes=${download?.bytes?.length ?? 0}, contentType=${download?.contentType || 'missing'}`, + OPTIONS) + } + + const operationsResponse = await context.request('GET', `${PDA}/tasks/${encodeURIComponent(taskId)}/operations`) + const operations = context.apiSuccess(MODULE, '读取最终完成不可变操作审计', operationsResponse, '', OPTIONS) + const completeOperations = rows(operations).filter((item) => item.actionType === 'COMPLETE_LOADING') + context.assert(MODULE, '最终主链恰有一次 COMPLETE_LOADING 且保留原 CLAIM/BATCH', operationsResponse, + rows(operations).filter((item) => item.actionType === 'CLAIM').length === 1 + && rows(operations).filter((item) => item.actionType === 'BATCH').length === 1 + && completeOperations.length === 1 && completeOperations[0]?.operationId + && completeOperations[0]?.operatorId && completeOperations[0]?.operatorName + && completeOperations[0]?.createdAt, + `actions=${rows(operations).map((item) => item.actionType).join(',') || 'none'}, completeCount=${completeOperations.length}`, + OPTIONS) + + const planResponse = await context.request('GET', `${SMART}/plans/${encodeURIComponent(fixture.loadingPlanId)}`) + const plan = context.apiSuccess(MODULE, '读取最终完成装柜计划', planResponse, '', OPTIONS) + context.assert(MODULE, '原装柜计划进入 COMPLETED 且版本递增', planResponse, + id(plan?.id) === id(fixture.loadingPlanId) && plan?.status === 'COMPLETED' + && Number(plan?.version) > Number(fixture.beforePlanVersion), + `status=${plan?.status || 'missing'}, beforeVersion=${fixture.beforePlanVersion}, afterVersion=${plan?.version ?? 'missing'}`, + OPTIONS) + + const orderFacts = [] + for (const source of fixture.sourceInbounds || []) { + const inboundResponse = await context.request('GET', + `/api/platform/inbounds/${encodeURIComponent(source.inboundId)}?tenantId=${encodeURIComponent(source.tenantId)}`) + const inbound = context.apiSuccess(MODULE, `${source.inboundId} 回查 LOADED 入库终态`, inboundResponse, '', OPTIONS) + const orderId = id(inbound?.summary?.orderId) + context.assert(MODULE, `${source.inboundId} 入库状态和完成历史正确`, inboundResponse, + inbound?.summary?.processStatus === 'LOADED' && orderId + && rows(inbound?.statusHistory).some((item) => item.action === 'COMPLETE_LOADING' + && item.fromStatus === 'LOADING' && item.toStatus === 'LOADED' + && item.operatorName), + `status=${inbound?.summary?.processStatus || 'missing'}, orderId=${orderId || 'missing'}, histories=${rows(inbound?.statusHistory).length}`, + OPTIONS) + if (!orderId) continue + const orderResponse = await context.request('GET', + `/api/platform/v1/orders/${encodeURIComponent(orderId)}?tenantId=${encodeURIComponent(source.tenantId)}`) + const order = context.apiSuccess(MODULE, `${orderId} 回查统一订单终态`, orderResponse, '', OPTIONS) + context.assert(MODULE, `${orderId} 统一订单详情投影为 LOADED`, orderResponse, + id(order?.summary?.orderId) === orderId && order?.summary?.mainStatus === 'LOADED', + `mainStatus=${order?.summary?.mainStatus || 'missing'}, orderNo=${order?.summary?.orderNo || 'missing'}`, + OPTIONS) + orderFacts.push({ tenantId: id(source.tenantId), inboundId: id(source.inboundId), orderId, + orderNo: order?.summary?.orderNo || null }) + } + + const tenantId = id(fixture.sourceInbounds?.[0]?.tenantId) + const slicesResponse = await context.request('GET', + `/api/platform/v1/orders/container-bookings/${encodeURIComponent(fixture.containerBookingId)}/fulfillment-slices?tenantId=${encodeURIComponent(tenantId)}`) + const slices = context.apiSuccess(MODULE, '回查柜次最终实装分片', slicesResponse, '', OPTIONS) + const loadedSlices = rows(slices).filter((item) => item.splitRole === 'LOADED' + && id(item.containerBookingId) === id(fixture.containerBookingId) + && id(item.loadingPlanId) === id(fixture.loadingPlanId)) + const pickedPackages = fixture.sourceInbounds.reduce((sum, item) => sum + Number(item.pickedPackageCount || 0), 0) + context.assert(MODULE, '最终实装分片唯一且箱数等于 PDA 已拣总数', slicesResponse, + loadedSlices.length > 0 + && new Set(loadedSlices.map((item) => id(item.sourceOrderId))).size === loadedSlices.length + && loadedSlices.reduce((sum, item) => sum + Number(item.packageCount || 0), 0) === pickedPackages, + `loadedSlices=${loadedSlices.length}, loadedPackages=${loadedSlices.reduce((sum, item) => sum + Number(item.packageCount || 0), 0)}, pickedPackages=${pickedPackages}`, + OPTIONS) + + const overviewResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(fixture.containerBookingId)}/overview`) + const overview = context.apiSuccess(MODULE, '回查货柜最终总览', overviewResponse, '', OPTIONS) + const photoCounts = new Map(rows(overview?.pda?.photoCounts).map((item) => [item.evidenceType, Number(item.count)])) + context.assert(MODULE, '货柜总览汇总 COMPLETED PDA 和五类 ACTIVE 证据', overviewResponse, + id(overview?.loadingPlanId) === id(fixture.loadingPlanId) + && id(overview?.pda?.taskId) === taskId + && overview?.pda?.taskStatus === 'COMPLETED' && overview?.pda?.workflowStatus === 'COMPLETED' + && fixture.requiredEvidenceTypes.every((type) => Number(photoCounts.get(type)) >= 1) + && rows(overview?.pda?.photos).filter((item) => item.status === 'ACTIVE').length === 5, + `taskStatus=${overview?.pda?.taskStatus || 'missing'}, workflowStatus=${overview?.pda?.workflowStatus || 'missing'}, photoCounts=${JSON.stringify(Object.fromEntries(photoCounts))}`, + OPTIONS) + + context.createdResources.manualLoadingCompleteVerification = { + sourceMarker: fixture.sourceMarker, verifiedAt: new Date().toISOString(), stage: 'LOADED', + pdaTaskId: taskId, loadingPlanId: id(fixture.loadingPlanId), + containerBookingId: id(fixture.containerBookingId), cabinetNo: fixture.cabinetNo, + containerNo: fixture.containerNo, completeOperationId: completeOperations[0]?.operationId || null, + evidenceInboundId: id(fixture.evidenceInboundId), evidenceIds: activeEvidence.map((item) => id(item.id)), + loadedSliceCount: loadedSlices.length, loadedPackageCount: pickedPackages, orders: orderFacts, + terminalNote: '国内装柜人工验收链已到 LOADED;后续报关、国内出仓和海外接柜属于后续节点。', + } +} diff --git a/scripts/admin-test-env-modules/manual-order-splitting-contract.test.mjs b/scripts/admin-test-env-modules/manual-order-splitting-contract.test.mjs new file mode 100644 index 000000000..c81b839ce --- /dev/null +++ b/scripts/admin-test-env-modules/manual-order-splitting-contract.test.mjs @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import { parseArguments, validateExecutionOptions } from '../admin-test-env-acceptance.mjs' + +const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8') +const fixture = readFileSync(new URL('./manual-order-splitting-fixture.mjs', import.meta.url), 'utf8') +const verify = readFileSync(new URL('./manual-order-splitting-verify.mjs', import.meta.url), 'utf8') + +test('manual splitting modules are optional registered entries with a WAIT_SPLIT producer dependency', () => { + assert.match(entry, /OPTIONAL_MODULES\s*=\s*\[[^\]]*'manual-order-splitting-fixture'[^\]]*'manual-order-splitting-verify'/s) + assert.match(entry, /\['manual-order-splitting-fixture', \['inbound-measurement'\]\]/) + assert.match(entry, /--manual-split-fixture-report/) + assert.match(entry, /--manual-split-plan-id/) + assert.match(entry, /manual-order-splitting-fixture[^\n]+waitSplitCount < 4/) +}) + +test('fixture defaults to six orders while verifier requires an explicit report and plan id', () => { + const defaultModules = parseArguments([]).modules + assert.equal(defaultModules.includes('manual-order-splitting-fixture'), false) + assert.equal(defaultModules.includes('manual-order-splitting-verify'), false) + const fixtureOptions = parseArguments([ + '--gateway', 'http://127.0.0.1:9000', '--username', 'admin', '--password', 'password-value', + '--modules', 'manual-order-splitting-fixture', + ]) + assert.deepEqual(fixtureOptions.modules, [ + 'merchant-qualification-fixture', 'orders', 'inbound-measurement', 'manual-order-splitting-fixture', + ]) + assert.equal(fixtureOptions.waitSplitCount, 6) + assert.equal(validateExecutionOptions(fixtureOptions), fixtureOptions) + + const verifyOptions = parseArguments([ + '--gateway', 'http://127.0.0.1:9000', '--username', 'admin', '--password', 'password-value', + '--modules', 'manual-order-splitting-verify', '--manual-split-fixture-report', 'fixture.json', + '--manual-split-plan-id', '123', + ]) + assert.deepEqual(verifyOptions.modules, ['manual-order-splitting-verify']) + assert.equal(validateExecutionOptions(verifyOptions), verifyOptions) + assert.throws(() => validateExecutionOptions({ ...verifyOptions, manualSplitPlanId: '' }), + /manual-split-plan-id/) + assert.throws(() => validateExecutionOptions({ ...fixtureOptions, waitSplitCount: 3 }), + /wait-split-count至少为4/) +}) + +test('fixture prepares a partial, two full and one untouched scenario without performing split writes', () => { + assert.match(fixture, /role: 'PARTIAL'/) + assert.match(fixture, /role: `FULL_\$\{index \+ 1\}`/) + assert.match(fixture, /role: 'UNASSIGNED_CONTROL'/) + assert.match(fixture, /WAIT_SPLIT_READY_FOR_MANUAL_ASSIGNMENT/) + assert.doesNotMatch(fixture, /context\.request\(['"]POST/) +}) + +test('verifier is read only and checks plan, container, allocations and remaining candidates', () => { + assert.match(verify, /context\.request\('GET', `\$\{SMART\}\/plans/) + assert.match(verify, /context\.request\('GET', `\$\{CONTAINERS\}/) + assert.match(verify, /inbound-candidates\?bookingId=/) + assert.match(verify, /sourceGateway[\s\S]*sameGateway/) + assert.match(verify, /候选列表未出现异渠道或异仓入库单/) + assert.match(verify, /expectedAllocatedPackageCount/) + assert.match(verify, /expectedRemainingPackageCount/) + assert.doesNotMatch(verify, /context\.request\(['"](?:POST|PATCH|PUT|DELETE)/) +}) diff --git a/scripts/admin-test-env-modules/manual-order-splitting-fixture.mjs b/scripts/admin-test-env-modules/manual-order-splitting-fixture.mjs new file mode 100644 index 000000000..f8325f3fe --- /dev/null +++ b/scripts/admin-test-env-modules/manual-order-splitting-fixture.mjs @@ -0,0 +1,78 @@ +const MODULE = 'manual-order-splitting-fixture' +const OPTIONS = { scenario: 'manual-order-splitting-fixture', deploymentBlocker: true } + +function id(value) { return value == null ? '' : String(value) } +function positiveInteger(value) { return Number.isInteger(Number(value)) && Number(value) > 0 } + +function normalizedFixture(value) { + return { + sequence: Number(value.sequence), tenantId: id(value.tenantId), orderId: id(value.orderId), + orderNo: value.orderNo || null, inboundId: id(value.inboundId), inboundCode: value.inboundCode || null, + warehouseId: id(value.receivingWarehouseId), warehouseName: value.receivingWarehouseName || null, + channelCode: value.channelCode || null, channelName: value.channelName || null, + totalPackageCount: Number(value.totalPackageCount), destinationType: value.destinationType || null, + fulfillmentType: value.fulfillmentType || null, containerMode: value.containerMode || null, + } +} + +export async function run(context) { + const fixtures = (context.createdResources?.waitSplitFixtures || []).map(normalizedFixture) + const valid = fixtures.length >= 4 && fixtures.every((item) => item.tenantId && item.orderId + && item.inboundId && item.warehouseId && item.channelCode && positiveInteger(item.totalPackageCount)) + context.assert(MODULE, '至少取得四票本轮 WAIT_SPLIT 人工分柜数据', null, valid, + `count=${fixtures.length}, invalid=${fixtures.filter((item) => !item.tenantId || !item.orderId + || !item.inboundId || !item.warehouseId || !item.channelCode || !positiveInteger(item.totalPackageCount)).length}`, + OPTIONS) + if (!valid) return + + const tenantIds = new Set(fixtures.map((item) => item.tenantId)) + const warehouseIds = new Set(fixtures.map((item) => item.warehouseId)) + const channelCodes = new Set(fixtures.map((item) => item.channelCode)) + const sameScope = tenantIds.size === 1 && warehouseIds.size === 1 && channelCodes.size === 1 + context.assert(MODULE, '人工分柜正向样本固定同一客户、起运仓和渠道', null, sameScope, + `tenantIds=${[...tenantIds].join(',')}, warehouseIds=${[...warehouseIds].join(',')}, channelCodes=${[...channelCodes].join(',')}`, + OPTIONS) + if (!sameScope) return + + const byPackages = [...fixtures].sort((left, right) => right.totalPackageCount - left.totalPackageCount + || left.sequence - right.sequence) + const partial = byPackages.find((item) => item.totalPackageCount >= 2) + const remaining = fixtures.filter((item) => item.inboundId !== partial?.inboundId) + const full = remaining.slice(0, 2) + const control = remaining[2] + const scenarioReady = Boolean(partial && full.length === 2 && control) + context.assert(MODULE, '人工分柜场景包含部分分配、两票整票拼柜和未分配对照', null, scenarioReady, + `partial=${partial?.inboundCode || 'missing'}, full=${full.map((item) => item.inboundCode).join(',') || 'missing'}, control=${control?.inboundCode || 'missing'}`, + OPTIONS) + if (!scenarioReady) return + + const assignments = [ + { role: 'PARTIAL', ...partial, expectedAllocatedPackageCount: partial.totalPackageCount - 1, + expectedRemainingPackageCount: 1 }, + ...full.map((item, index) => ({ role: `FULL_${index + 1}`, ...item, + expectedAllocatedPackageCount: item.totalPackageCount, expectedRemainingPackageCount: 0 })), + ] + const manifest = { + schemaVersion: 1, marker: context.marker, stage: 'WAIT_SPLIT_READY_FOR_MANUAL_ASSIGNMENT', + tenantId: fixtures[0].tenantId, warehouseId: fixtures[0].warehouseId, + warehouseName: fixtures[0].warehouseName, channelCode: fixtures[0].channelCode, + channelName: fixtures[0].channelName, + adminEntry: '/container-loading', + instructions: [ + '在智能装柜页面创建一个同仓同渠道的内部模拟柜。', + '创建智能装柜计划,并严格按 assignments 的 expectedAllocatedPackageCount 分配。', + '保留 unassignedControl 不加入计划,用于核对候选剩余与误选保护。', + '人工操作后记录 loadingPlanId,并运行 manual-order-splitting-verify 只读核验。', + ], + assignments, + unassignedControl: { role: 'UNASSIGNED_CONTROL', ...control, + expectedAllocatedPackageCount: 0, expectedRemainingPackageCount: control.totalPackageCount }, + allFixtures: fixtures, + } + context.createdResources.manualOrderSplittingFixture = manifest + context.assert(MODULE, '人工分柜清单数量守恒', null, + assignments.every((item) => item.totalPackageCount + === item.expectedAllocatedPackageCount + item.expectedRemainingPackageCount), + `assignments=${assignments.length}, plannedPackages=${assignments.reduce((sum, item) => sum + item.expectedAllocatedPackageCount, 0)}`, + OPTIONS) +} diff --git a/scripts/admin-test-env-modules/manual-order-splitting-verify.mjs b/scripts/admin-test-env-modules/manual-order-splitting-verify.mjs new file mode 100644 index 000000000..c41ba6fd1 --- /dev/null +++ b/scripts/admin-test-env-modules/manual-order-splitting-verify.mjs @@ -0,0 +1,97 @@ +import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' + +const MODULE = 'manual-order-splitting-verify' +const OPTIONS = { scenario: 'manual-order-splitting-readonly-verification', deploymentBlocker: true } +const SMART = '/api/platform/v1/first-leg/smart-loading' +const CONTAINERS = '/api/platform/v1/containers' + +function id(value) { return value == null ? '' : String(value) } +function rows(value) { return Array.isArray(value) ? value : [] } + +async function loadManifest(context) { + const path = resolve(context.options.manualSplitFixtureReport) + const report = JSON.parse(await readFile(path, 'utf8')) + const manifest = report?.createdResources?.manualOrderSplittingFixture + const sourceGateway = String(report?.gateway || '').replace(/\/$/, '') + const sameGateway = sourceGateway && sourceGateway === context.options.gateway + context.assert(MODULE, '读取人工分柜准备报告', null, + manifest?.schemaVersion === 1 && manifest?.stage === 'WAIT_SPLIT_READY_FOR_MANUAL_ASSIGNMENT' + && Array.isArray(manifest.assignments) && manifest.assignments.length === 3 && sameGateway, + `sourceMarker=${report?.marker || 'missing'}, schemaVersion=${manifest?.schemaVersion ?? 'missing'}, assignments=${manifest?.assignments?.length ?? 'missing'}, sameGateway=${Boolean(sameGateway)}`, + OPTIONS) + return sameGateway ? manifest : null +} + +export async function run(context) { + const manifest = await loadManifest(context) + if (!manifest?.assignments?.length) return + + const planId = id(context.options.manualSplitPlanId) + const planResponse = await context.request('GET', `${SMART}/plans/${encodeURIComponent(planId)}`) + const plan = context.apiSuccess(MODULE, '读取人工操作后的智能装柜计划', planResponse, + `loadingPlanId=${planId}`, OPTIONS) + if (!plan) return + + const bookingId = id(plan.bookingId || plan.containerBookingId) + const planItems = rows(plan.items) + context.assert(MODULE, '智能装柜计划身份与状态可继续人工验收', planResponse, + id(plan.id) === planId && bookingId && ['EDITING', 'LOADING'].includes(plan.status), + `planId=${id(plan.id) || 'missing'}, bookingId=${bookingId || 'missing'}, status=${plan.status || 'missing'}, version=${plan.version ?? 'missing'}`, + OPTIONS) + if (!bookingId) return + + const containerResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(bookingId)}`) + const container = context.apiSuccess(MODULE, '读取人工分柜关联柜次', containerResponse, '', OPTIONS) + context.assert(MODULE, '柜次保持准备清单的仓库和渠道范围', containerResponse, + id(container?.id) === bookingId && id(container?.warehouseId) === id(manifest.warehouseId) + && container?.channelCode === manifest.channelCode, + `containerId=${id(container?.id) || 'missing'}, warehouseId=${id(container?.warehouseId) || 'missing'}, channelCode=${container?.channelCode || 'missing'}`, + OPTIONS) + + const expectedByInbound = new Map(manifest.assignments.map((item) => [id(item.inboundId), item])) + for (const expected of manifest.assignments) { + const matches = planItems.filter((item) => id(item.inboundId) === id(expected.inboundId)) + const allocated = matches.reduce((sum, item) => sum + Number(item.packageCount || 0), 0) + context.assert(MODULE, `${expected.role} 按清单完成计划分配`, planResponse, + matches.length >= 1 && allocated === Number(expected.expectedAllocatedPackageCount), + `inboundCode=${expected.inboundCode || 'missing'}, expected=${expected.expectedAllocatedPackageCount}, actual=${allocated}, itemCount=${matches.length}`, + OPTIONS) + } + + const foreignItems = planItems.filter((item) => !expectedByInbound.has(id(item.inboundId))) + const controlItems = planItems.filter((item) => id(item.inboundId) === id(manifest.unassignedControl?.inboundId)) + context.assert(MODULE, '计划未混入清单外或未分配对照入库单', planResponse, + foreignItems.length === 0 && controlItems.length === 0, + `foreignInboundIds=${foreignItems.map((item) => id(item.inboundId)).join(',') || 'none'}, controlItemCount=${controlItems.length}`, + OPTIONS) + + const candidatesPath = `${SMART}/inbound-candidates?bookingId=${encodeURIComponent(bookingId)}&warehouseId=${encodeURIComponent(manifest.warehouseId)}` + const candidatesResponse = await context.request('GET', candidatesPath) + const candidates = context.apiSuccess(MODULE, '回查人工分柜后的入仓码候选余量', candidatesResponse, '', OPTIONS) + const candidateRows = rows(candidates) + const candidateScopeValid = candidateRows.every((item) => (!item.channelCode + || item.channelCode === manifest.channelCode) + && (!item.warehouseId || id(item.warehouseId) === id(manifest.warehouseId))) + context.assert(MODULE, '候选列表未出现异渠道或异仓入库单', candidatesResponse, + candidateScopeValid, + `candidateCount=${candidateRows.length}, channelCode=${manifest.channelCode}, warehouseId=${manifest.warehouseId}`, + OPTIONS) + for (const expected of [...manifest.assignments, manifest.unassignedControl]) { + const candidate = candidateRows.find((item) => id(item.inboundId) === id(expected.inboundId)) + const actualRemaining = candidate ? Number(candidate.availablePackageCount) : 0 + context.assert(MODULE, `${expected.role} 分配后剩余箱数正确`, candidatesResponse, + actualRemaining === Number(expected.expectedRemainingPackageCount), + `inboundCode=${expected.inboundCode || 'missing'}, expectedRemaining=${expected.expectedRemainingPackageCount}, actualRemaining=${actualRemaining}`, + OPTIONS) + } + + context.createdResources.manualOrderSplittingVerification = { + sourceMarker: manifest.marker, verifiedAt: new Date().toISOString(), loadingPlanId: planId, + containerBookingId: bookingId, tenantId: id(manifest.tenantId), warehouseId: id(manifest.warehouseId), + channelCode: manifest.channelCode, planStatus: plan.status, planVersion: Number(plan.version), + verifiedAssignments: manifest.assignments.map((item) => ({ role: item.role, orderId: id(item.orderId), + inboundId: id(item.inboundId), expectedAllocatedPackageCount: Number(item.expectedAllocatedPackageCount) })), + note: '本模块只读核验计划分柜;父子订单在 PDA 实装结算后另行核验。', + } +} diff --git a/scripts/admin-test-env-modules/manual-pda-batch-contract.test.mjs b/scripts/admin-test-env-modules/manual-pda-batch-contract.test.mjs new file mode 100644 index 000000000..fdb36948e --- /dev/null +++ b/scripts/admin-test-env-modules/manual-pda-batch-contract.test.mjs @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import { parseArguments, validateExecutionOptions } from '../admin-test-env-acceptance.mjs' + +const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8') +const fixture = readFileSync(new URL('./manual-pda-batch-fixture.mjs', import.meta.url), 'utf8') +const verify = readFileSync(new URL('./manual-pda-batch-verify.mjs', import.meta.url), 'utf8') + +test('manual PDA batch modules are optional and require a source report', () => { + assert.match(entry, /OPTIONAL_MODULES\s*=\s*\[[^\]]*'manual-pda-batch-fixture'[^\]]*'manual-pda-batch-verify'/s) + const defaults = parseArguments([]).modules + assert.equal(defaults.includes('manual-pda-batch-fixture'), false) + assert.equal(defaults.includes('manual-pda-batch-verify'), false) + for (const module of ['manual-pda-batch-fixture', 'manual-pda-batch-verify']) { + const options = parseArguments(['--gateway', 'http://127.0.0.1:9000', '--username', 'admin', + '--password', 'password-value', '--modules', module, '--manual-pda-batch-source-report', 'source.json']) + assert.deepEqual(options.modules, [module]) + assert.equal(validateExecutionOptions(options), options) + assert.throws(() => validateExecutionOptions({ ...options, manualPdaBatchSourceReport: '' }), + /manual-pda-batch-source-report/) + } +}) + +test('PDA batch preparation is read only and preserves stable carton scan instructions', () => { + assert.match(fixture, /manualPdaReadyVerification/) + assert.match(fixture, /boxCodes\.length >= item\.plannedPackageCount/) + assert.match(fixture, /scanBoxCodes: rows\(item\.boxCodes\)/) + assert.match(fixture, /inventory-snapshot/) + assert.match(fixture, /不存在 CLAIM、BATCH、REVIEW 或 COMPLETE_LOADING/) + assert.match(fixture, /逐次扫码动作需保留真机人工证据/) + assert.doesNotMatch(fixture, /context\.request\(['"](?:POST|PATCH|PUT|DELETE)/) +}) + +test('post-batch verifier proves one claim, one batch and AWAITING_REVIEW without writes', () => { + assert.match(verify, /workflowStatus === 'AWAITING_REVIEW'/) + assert.match(verify, /itemStatus === 'PICKED'/) + assert.match(verify, /claimOperations\.length === 1 && batchOperations\.length === 1/) + assert.match(verify, /pendingItemCount\) === 0/) + assert.match(verify, /expectedCount\) === Number\(projection\?\.pickedCount\)/) + assert.match(verify, /HTTP 不记录逐次扫码轨迹/) + assert.doesNotMatch(verify, /context\.request\(['"](?:POST|PATCH|PUT|DELETE)/) +}) diff --git a/scripts/admin-test-env-modules/manual-pda-batch-fixture.mjs b/scripts/admin-test-env-modules/manual-pda-batch-fixture.mjs new file mode 100644 index 000000000..d484ab2ac --- /dev/null +++ b/scripts/admin-test-env-modules/manual-pda-batch-fixture.mjs @@ -0,0 +1,105 @@ +import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' + +const MODULE = 'manual-pda-batch-fixture' +const OPTIONS = { scenario: 'manual-pda-batch-fixture', deploymentBlocker: true } +const PDA = '/api/platform/v1/first-leg/pda-loading' + +function id(value) { return value == null ? '' : String(value) } +function rows(value) { return Array.isArray(value) ? value : [] } + +async function loadSource(context) { + const report = JSON.parse(await readFile(resolve(context.options.manualPdaBatchSourceReport), 'utf8')) + const source = report?.createdResources?.manualPdaReadyVerification + const sourceGateway = String(report?.gateway || '').replace(/\/$/, '') + const valid = sourceGateway === context.options.gateway && source?.stage === 'PDA_UNASSIGNED' + && source?.pdaTaskId && source?.loadingPlanId && source?.containerBookingId + context.assert(MODULE, '读取同环境 PDA 待领取核验报告', null, valid, + `sourceMarker=${report?.marker || 'missing'}, sameGateway=${sourceGateway === context.options.gateway}, pdaTaskId=${id(source?.pdaTaskId) || 'missing'}`, + OPTIONS) + return valid ? source : null +} + +function taskItemSnapshot(items) { + return rows(items).map((item) => ({ + id: id(item.id), loadingPlanItemId: id(item.loadingPlanItemId), tenantId: id(item.tenantId), + inboundId: id(item.inboundId), inboundDetailId: id(item.inboundDetailId), + orderNo: item.orderNo || null, inboundCode: item.inboundCode || null, cargoName: item.cargoName || null, + boxCodes: rows(item.boxCodes).map(String), + scanBoxCodes: rows(item.boxCodes).map(String).slice(0, Number(item.plannedPackageCount)), + plannedPackageCount: Number(item.plannedPackageCount), + })).sort((left, right) => left.inboundId.localeCompare(right.inboundId) || left.id.localeCompare(right.id)) +} + +export async function run(context) { + const source = await loadSource(context) + if (!source) return + const taskId = id(source.pdaTaskId) + + const taskResponse = await context.request('GET', + `${PDA}/tasks/${encodeURIComponent(taskId)}?warehouseId=${encodeURIComponent(source.warehouseId)}`) + const task = context.apiSuccess(MODULE, 'PDA 人工作业前读取稳定任务详情', taskResponse, '', OPTIONS) + const taskItems = taskItemSnapshot(task?.items) + const scanBoxCodes = taskItems.flatMap((item) => item.scanBoxCodes) + const cartonCodesReady = taskItems.length > 0 && taskItems.every((item) => item.id && item.inboundId + && item.plannedPackageCount > 0 && item.boxCodes.length >= item.plannedPackageCount + && item.scanBoxCodes.length === item.plannedPackageCount + && new Set(item.boxCodes).size === item.boxCodes.length) + && new Set(scanBoxCodes).size === scanBoxCodes.length + context.assert(MODULE, '待领取任务下发稳定箱码且计划箱数可逐箱扫描', taskResponse, + id(task?.id) === taskId && id(task?.loadingPlanId) === id(source.loadingPlanId) + && id(task?.containerBookingId) === id(source.containerBookingId) + && task?.taskStatus === 'LOADING' && task?.workflowStatus === 'UNASSIGNED' + && task?.assignment == null && cartonCodesReady, + `workflowStatus=${task?.workflowStatus || 'missing'}, items=${taskItems.length}, plannedPackages=${taskItems.reduce((sum, item) => sum + item.plannedPackageCount, 0)}, boxCodes=${taskItems.reduce((sum, item) => sum + item.boxCodes.length, 0)}`, + OPTIONS) + if (!cartonCodesReady || task?.workflowStatus !== 'UNASSIGNED') return + + const pendingResponse = await context.request('GET', + `${PDA}/tasks/pending?keyword=${encodeURIComponent(source.containerNo)}`) + const pending = context.apiSuccess(MODULE, '作业前读取当前操作员 PDA 待办', pendingResponse, '', OPTIONS) + const pendingTask = rows(pending).find((item) => id(item.taskId) === taskId) + context.assert(MODULE, '未领取任务在 PDA 待办中唯一可见', pendingResponse, + Boolean(pendingTask) && pendingTask.workflowStatus === 'UNASSIGNED' + && Number(pendingTask.pendingItemCount) === taskItems.length, + `matches=${rows(pending).filter((item) => id(item.taskId) === taskId).length}, pendingItemCount=${pendingTask?.pendingItemCount ?? 'missing'}`, + OPTIONS) + + const inventoryResponse = await context.request('GET', + `${PDA}/tasks/${encodeURIComponent(taskId)}/inventory-snapshot?warehouseId=${encodeURIComponent(source.warehouseId)}`) + const inventory = context.apiSuccess(MODULE, '作业前读取实时库存快照基线', inventoryResponse, '', OPTIONS) + const inventoryReady = id(inventory?.taskId) === taskId + && id(inventory?.loadingPlanId) === id(source.loadingPlanId) + && id(inventory?.containerBookingId) === id(source.containerBookingId) + && Number(inventory?.taskVersion) === Number(task?.version) + && typeof inventory?.snapshotToken === 'string' && inventory.snapshotToken.length === 64 + && rows(inventory?.inbounds).length > 0 + context.assert(MODULE, '库存快照与待领取任务版本和四元身份一致', inventoryResponse, + inventoryReady, + `taskVersion=${task?.version ?? 'missing'}, snapshotTaskVersion=${inventory?.taskVersion ?? 'missing'}, inboundCount=${rows(inventory?.inbounds).length}`, + OPTIONS) + + const operationsResponse = await context.request('GET', `${PDA}/tasks/${encodeURIComponent(taskId)}/operations`) + const operations = context.apiSuccess(MODULE, '作业前读取 PDA 操作历史', operationsResponse, '', OPTIONS) + context.assert(MODULE, '作业前不存在 CLAIM、BATCH、REVIEW 或 COMPLETE_LOADING', operationsResponse, + rows(operations).every((item) => !['CLAIM', 'BATCH', 'REVIEW', 'COMPLETE_LOADING'].includes(item.actionType)), + `actions=${rows(operations).map((item) => item.actionType).join(',') || 'none'}`, + OPTIONS) + + context.createdResources.manualPdaBatchFixture = { + schemaVersion: 1, sourceMarker: source.sourceMarker, preparedAt: new Date().toISOString(), + stage: 'MANUAL_PDA_CLAIM_AND_BATCH_READY', pdaTaskId: taskId, + loadingPlanId: id(source.loadingPlanId), containerBookingId: id(source.containerBookingId), + cabinetNo: source.cabinetNo, containerNo: source.containerNo, tenantId: id(source.tenantId), + warehouseId: id(source.warehouseId), beforeTaskVersion: Number(task.version), taskItems, + inventoryBaseline: rows(inventory?.inbounds).map((item) => ({ inboundId: id(item.inboundId), + availablePackageCount: Number(item.availablePackageCount), resourceCount: rows(item.resources).length })), + instructions: [ + '使用目标 PDA 作业员进入待办并领取本任务,确认任务进入 IN_PROGRESS。', + '按 taskItems.scanBoxCodes 在真机逐箱扫描;每个箱码只扫描一次,处理完全部计划箱数。', + '点击提交时由 PDA 重新获取库存快照,并只发送一次原子 batch;不要使用逐明细兼容接口。', + '提交成功停在 AWAITING_REVIEW,不上传装柜证据、不复核、不执行最终完成。', + 'HTTP 只能核验箱码下发和服务端数量,逐次扫码动作需保留真机人工证据。', + ], + } +} diff --git a/scripts/admin-test-env-modules/manual-pda-batch-verify.mjs b/scripts/admin-test-env-modules/manual-pda-batch-verify.mjs new file mode 100644 index 000000000..dfa83523a --- /dev/null +++ b/scripts/admin-test-env-modules/manual-pda-batch-verify.mjs @@ -0,0 +1,111 @@ +import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' + +const MODULE = 'manual-pda-batch-verify' +const OPTIONS = { scenario: 'manual-pda-batch-readonly-verification', deploymentBlocker: true } +const PDA = '/api/platform/v1/first-leg/pda-loading' + +function id(value) { return value == null ? '' : String(value) } +function rows(value) { return Array.isArray(value) ? value : [] } + +async function loadFixture(context) { + const report = JSON.parse(await readFile(resolve(context.options.manualPdaBatchSourceReport), 'utf8')) + const fixture = report?.createdResources?.manualPdaBatchFixture + const sourceGateway = String(report?.gateway || '').replace(/\/$/, '') + const valid = sourceGateway === context.options.gateway && fixture?.schemaVersion === 1 + && fixture?.stage === 'MANUAL_PDA_CLAIM_AND_BATCH_READY' && fixture?.pdaTaskId + && Array.isArray(fixture?.taskItems) && fixture.taskItems.length > 0 + context.assert(MODULE, '读取同环境 PDA 人工作业准备报告', null, valid, + `sourceMarker=${report?.marker || 'missing'}, sameGateway=${sourceGateway === context.options.gateway}, pdaTaskId=${id(fixture?.pdaTaskId) || 'missing'}`, + OPTIONS) + return valid ? fixture : null +} + +function stableItem(value) { + return { id: id(value.id), loadingPlanItemId: id(value.loadingPlanItemId), tenantId: id(value.tenantId), + inboundId: id(value.inboundId), inboundDetailId: id(value.inboundDetailId), + boxCodes: rows(value.boxCodes).map(String), plannedPackageCount: Number(value.plannedPackageCount) } +} + +export async function run(context) { + const fixture = await loadFixture(context) + if (!fixture) return + const taskId = id(fixture.pdaTaskId) + + const taskResponse = await context.request('GET', + `${PDA}/tasks/${encodeURIComponent(taskId)}?warehouseId=${encodeURIComponent(fixture.warehouseId)}`) + const task = context.apiSuccess(MODULE, '读取人工领取和批量提交后的 PDA 任务', taskResponse, '', OPTIONS) + const afterItems = rows(task?.items).map(stableItem) + .sort((left, right) => left.inboundId.localeCompare(right.inboundId) || left.id.localeCompare(right.id)) + const beforeStable = fixture.taskItems.map(stableItem) + const allPicked = rows(task?.items).length > 0 && rows(task?.items).every((item) => + item.itemStatus === 'PICKED' + && Number(item.pickedPackageCount) === Number(item.plannedPackageCount)) + context.assert(MODULE, '领取后任务绑定作业员并以一次批量提交进入待复核', taskResponse, + id(task?.id) === taskId && id(task?.loadingPlanId) === id(fixture.loadingPlanId) + && id(task?.containerBookingId) === id(fixture.containerBookingId) + && task?.cabinetNo === fixture.cabinetNo && task?.currentContainerNo === fixture.containerNo + && task?.taskStatus === 'LOADING' && task?.workflowStatus === 'AWAITING_REVIEW' + && task?.assignment?.assigneeId && task?.assignment?.assigneeName + && Number(task?.version) > Number(fixture.beforeTaskVersion) + && JSON.stringify(afterItems) === JSON.stringify(beforeStable) && allPicked, + `taskStatus=${task?.taskStatus || 'missing'}, workflowStatus=${task?.workflowStatus || 'missing'}, assigneeId=${id(task?.assignment?.assigneeId) || 'missing'}, beforeVersion=${fixture.beforeTaskVersion}, afterVersion=${task?.version ?? 'missing'}`, + OPTIONS) + if (!task || task.workflowStatus !== 'AWAITING_REVIEW') return + + const pendingResponse = await context.request('GET', + `${PDA}/tasks/pending?keyword=${encodeURIComponent(fixture.containerNo)}`) + const pending = context.apiSuccess(MODULE, '读取待复核 PDA pending 投影', pendingResponse, '', OPTIONS) + const pendingTask = rows(pending).find((item) => id(item.taskId) === taskId) + context.assert(MODULE, 'pending 与详情一致显示 AWAITING_REVIEW 且无未处理明细', pendingResponse, + Boolean(pendingTask) && pendingTask.workflowStatus === 'AWAITING_REVIEW' + && Number(pendingTask.itemCount) === rows(task.items).length + && Number(pendingTask.pendingItemCount) === 0, + `workflowStatus=${pendingTask?.workflowStatus || 'missing'}, itemCount=${pendingTask?.itemCount ?? 'missing'}, pendingItemCount=${pendingTask?.pendingItemCount ?? 'missing'}`, + OPTIONS) + + const operationsResponse = await context.request('GET', `${PDA}/tasks/${encodeURIComponent(taskId)}/operations`) + const operations = context.apiSuccess(MODULE, '读取领取与原子批量提交审计', operationsResponse, '', OPTIONS) + const claimOperations = rows(operations).filter((item) => item.actionType === 'CLAIM') + const batchOperations = rows(operations).filter((item) => item.actionType === 'BATCH') + context.assert(MODULE, '人工主链只保留一次 CLAIM 和一次 BATCH 且尚无复核完成记录', operationsResponse, + claimOperations.length === 1 && batchOperations.length === 1 + && [...claimOperations, ...batchOperations].every((item) => item.operationId + && item.operatorId && item.operatorName && item.createdAt) + && !rows(operations).some((item) => ['REVIEW', 'COMPLETE_LOADING'].includes(item.actionType)), + `actions=${rows(operations).map((item) => item.actionType).join(',') || 'none'}, claimCount=${claimOperations.length}, batchCount=${batchOperations.length}`, + OPTIONS) + + const uniqueInbounds = [...new Map(rows(task.items).map((item) => [id(item.inboundId), item])).values()] + for (const item of uniqueInbounds) { + const projectionResponse = await context.request('GET', + `/api/platform/v1/inbounds/${encodeURIComponent(item.inboundId)}/loading/picking?tenantId=${encodeURIComponent(item.tenantId)}`) + const projection = context.apiSuccess(MODULE, `回查 ${item.inboundCode || item.inboundId} 入库级拣货投影`, projectionResponse, '', OPTIONS) + context.assert(MODULE, `${item.inboundCode || item.inboundId} 服务端汇总数量已拣齐`, projectionResponse, + Number(projection?.expectedCount) > 0 + && Number(projection?.expectedCount) === Number(projection?.pickedCount), + `status=${projection?.status || 'missing'}, expected=${projection?.expectedCount ?? 'missing'}, picked=${projection?.pickedCount ?? 'missing'}`, + OPTIONS) + const evidenceResponse = await context.request('GET', + `/api/platform/v1/inbounds/${encodeURIComponent(item.inboundId)}/loading/evidence?tenantId=${encodeURIComponent(item.tenantId)}`) + const evidence = context.apiSuccess(MODULE, `${item.inboundCode || item.inboundId} 待复核取证入口可读`, evidenceResponse, '', OPTIONS) + context.assert(MODULE, `${item.inboundCode || item.inboundId} AWAITING_REVIEW 可进入取证`, evidenceResponse, + Array.isArray(evidence?.evidences), + `evidenceCount=${evidence?.evidences?.length ?? 'missing'}`, OPTIONS) + } + + context.createdResources.manualPdaBatchVerification = { + sourceMarker: fixture.sourceMarker, verifiedAt: new Date().toISOString(), stage: 'PDA_AWAITING_REVIEW', + pdaTaskId: taskId, loadingPlanId: id(fixture.loadingPlanId), + containerBookingId: id(fixture.containerBookingId), cabinetNo: fixture.cabinetNo, + containerNo: fixture.containerNo, warehouseId: id(fixture.warehouseId), taskVersion: Number(task.version), + assigneeId: id(task.assignment?.assigneeId), assigneeName: task.assignment?.assigneeName || null, + itemCount: rows(task.items).length, + plannedPackageCount: rows(task.items).reduce((sum, item) => sum + Number(item.plannedPackageCount || 0), 0), + pickedPackageCount: rows(task.items).reduce((sum, item) => sum + Number(item.pickedPackageCount || 0), 0), + claimOperationId: claimOperations[0]?.operationId || null, + batchOperationId: batchOperations[0]?.operationId || null, + deviceEvidenceRequired: 'HTTP 不记录逐次扫码轨迹;箱码逐一扫描需保留真机人工验收证据。', + nextStage: '上传五类 ACTIVE 装柜证据后,再执行 PDA 最终完成与 LOADED 核验。', + } +} diff --git a/scripts/admin-test-env-modules/manual-pda-ready-contract.test.mjs b/scripts/admin-test-env-modules/manual-pda-ready-contract.test.mjs new file mode 100644 index 000000000..2cd64f7ca --- /dev/null +++ b/scripts/admin-test-env-modules/manual-pda-ready-contract.test.mjs @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import { parseArguments, validateExecutionOptions } from '../admin-test-env-acceptance.mjs' + +const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8') +const fixture = readFileSync(new URL('./manual-pda-ready-fixture.mjs', import.meta.url), 'utf8') +const verify = readFileSync(new URL('./manual-pda-ready-verify.mjs', import.meta.url), 'utf8') + +test('manual PDA-ready modules are optional and require a source report', () => { + assert.match(entry, /OPTIONAL_MODULES\s*=\s*\[[^\]]*'manual-pda-ready-fixture'[^\]]*'manual-pda-ready-verify'/s) + const defaults = parseArguments([]).modules + assert.equal(defaults.includes('manual-pda-ready-fixture'), false) + assert.equal(defaults.includes('manual-pda-ready-verify'), false) + for (const module of ['manual-pda-ready-fixture', 'manual-pda-ready-verify']) { + const options = parseArguments(['--gateway', 'http://127.0.0.1:9000', '--username', 'admin', + '--password', 'password-value', '--modules', module, '--manual-pda-source-report', 'source.json']) + assert.deepEqual(options.modules, [module]) + assert.equal(validateExecutionOptions(options), options) + assert.throws(() => validateExecutionOptions({ ...options, manualPdaSourceReport: '' }), + /manual-pda-source-report/) + } +}) + +test('PDA-ready preparation is read only and proves all three prerequisites are absent', () => { + assert.match(fixture, /manualPreloadPushVerification/) + assert.match(fixture, /container\?\.containerNo == null/) + assert.match(fixture, /container\?\.oceanBookingId == null/) + assert.match(fixture, /readiness\?\.canConfirm === false/) + assert.match(fixture, /codeOf\(taskResponse\) === 'A0404'/) + assert.doesNotMatch(fixture, /context\.request\(['"](?:POST|PATCH|PUT|DELETE)/) +}) + +test('PDA-ready verifier checks release, stable link, confirmation and one unassigned task without writes', () => { + assert.match(verify, /oceanBooking\?\.status === 'RELEASED'/) + assert.match(verify, /positiveId\(allocation\?\.requirementId\)/) + assert.match(verify, /confirmationStatus === 'CONFIRMED'/) + assert.match(verify, /readyForPda === true/) + assert.match(verify, /workflowStatus === 'UNASSIGNED'/) + assert.match(verify, /processStatus === 'WAIT_SPLIT'/) + assert.doesNotMatch(verify, /context\.request\(['"](?:POST|PATCH|PUT|DELETE)/) +}) diff --git a/scripts/admin-test-env-modules/manual-pda-ready-fixture.mjs b/scripts/admin-test-env-modules/manual-pda-ready-fixture.mjs new file mode 100644 index 000000000..5d483e5f6 --- /dev/null +++ b/scripts/admin-test-env-modules/manual-pda-ready-fixture.mjs @@ -0,0 +1,89 @@ +import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' + +const MODULE = 'manual-pda-ready-fixture' +const OPTIONS = { scenario: 'manual-pda-ready-fixture', deploymentBlocker: true } +const SMART = '/api/platform/v1/first-leg/smart-loading' +const FIRST_LEG = '/api/platform/v1/first-leg' +const CONTAINERS = '/api/platform/v1/containers' +const PDA = `${FIRST_LEG}/pda-loading` + +function id(value) { return value == null ? '' : String(value) } +function rows(value) { return Array.isArray(value) ? value : [] } +function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } +function items(value) { + return rows(value).map((item) => ({ id: id(item.id), inboundId: id(item.inboundId), + packageCount: Number(item.packageCount) })) + .sort((left, right) => left.inboundId.localeCompare(right.inboundId) || left.id.localeCompare(right.id)) +} + +async function loadSource(context) { + const report = JSON.parse(await readFile(resolve(context.options.manualPdaSourceReport), 'utf8')) + const source = report?.createdResources?.manualPreloadPushVerification + const sourceGateway = String(report?.gateway || '').replace(/\/$/, '') + const valid = sourceGateway === context.options.gateway && source?.stage === 'PRELOAD_EDITING' + && source?.loadingPlanId && source?.containerBookingId && source?.pdaTaskCreated === false + && Array.isArray(source?.expectedAssignments) && source.expectedAssignments.length > 0 + context.assert(MODULE, '读取同环境推送预装核验报告', null, valid, + `sourceMarker=${report?.marker || 'missing'}, sameGateway=${sourceGateway === context.options.gateway}, loadingPlanId=${id(source?.loadingPlanId) || 'missing'}`, + OPTIONS) + return valid ? source : null +} + +export async function run(context) { + const source = await loadSource(context) + if (!source) return + const planId = id(source.loadingPlanId) + const containerId = id(source.containerBookingId) + + const planResponse = await context.request('GET', `${SMART}/plans/${encodeURIComponent(planId)}`) + const plan = context.apiSuccess(MODULE, 'PDA 就绪操作前读取预装计划', planResponse, '', OPTIONS) + const beforeItems = items(plan?.items) + context.assert(MODULE, '操作前计划仍为无号已推送 EDITING', planResponse, + id(plan?.id) === planId && plan?.status === 'EDITING' && plan?.containerNo == null + && Number(plan?.version) === Number(source.planVersion) && beforeItems.length > 0, + `status=${plan?.status || 'missing'}, expectedVersion=${source.planVersion}, actualVersion=${plan?.version ?? 'missing'}, items=${beforeItems.length}`, + OPTIONS) + if (!plan || beforeItems.length === 0) return + + const containerResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(containerId)}`) + const container = context.apiSuccess(MODULE, 'PDA 就绪操作前读取内部模拟柜', containerResponse, '', OPTIONS) + context.assert(MODULE, '操作前柜次仍无真实柜号和订舱关联', containerResponse, + id(container?.id) === containerId && container?.cabinetNo === source.cabinetNo + && container?.containerNo == null && container?.oceanBookingId == null + && Number.isInteger(Number(container?.version)), + `cabinetNo=${container?.cabinetNo || 'missing'}, containerNo=${container?.containerNo || 'null'}, oceanBookingId=${id(container?.oceanBookingId) || 'null'}, version=${container?.version ?? 'missing'}`, + OPTIONS) + + const detailResponse = await context.request('GET', + `${FIRST_LEG}/loading-plans/${encodeURIComponent(planId)}?warehouseId=${encodeURIComponent(source.warehouseId)}`) + const detail = context.apiSuccess(MODULE, '操作前读取预装确认门禁', detailResponse, '', OPTIONS) + context.assert(MODULE, '缺少真实柜号与 RELEASED 订舱时不能确认预装', detailResponse, + detail?.summary?.status === 'EDITING' && detail?.summary?.readiness?.canConfirm === false + && detail?.summary?.readiness?.readyForPda === false + && detail?.summary?.readiness?.pdaTaskId == null, + `canConfirm=${detail?.summary?.readiness?.canConfirm}, readyForPda=${detail?.summary?.readiness?.readyForPda}, blockers=${rows(detail?.summary?.readiness?.blockers).join(',')}`, + OPTIONS) + + const taskResponse = await context.request('GET', + `${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(source.warehouseId)}`) + context.addStep(MODULE, '操作前 PDA 任务仍不存在', taskResponse, + taskResponse.http === 200 && codeOf(taskResponse) === 'A0404', + `HTTP=${taskResponse.http}, code=${codeOf(taskResponse)}`, OPTIONS) + + context.createdResources.manualPdaReadyFixture = { + schemaVersion: 1, sourceMarker: source.sourceMarker, preparedAt: new Date().toISOString(), + stage: 'MANUAL_BOOKING_LINK_CONFIRM_READY', loadingPlanId: planId, containerBookingId: containerId, + cabinetNo: source.cabinetNo, tenantId: id(source.tenantId), warehouseId: id(source.warehouseId), + channelCode: source.channelCode, beforePlanVersion: Number(plan.version), + beforeContainerVersion: Number(container?.version), beforeItems, + expectedAssignments: source.expectedAssignments || [], + instructions: [ + '在订舱页面创建与本清单客户、起运仓、渠道和柜型一致的 DRAFT 订舱,并填写完整运输资料。', + '确认放舱,使订舱进入 RELEASED;放舱本身不得创建货柜。', + '在原 containerBookingId 上选择该订舱未占用的柜型需求,录入唯一真实 containerNo 完成关联。', + '回到预装详情,确认 canConfirm=true 后执行整份计划确认;不要领取 PDA 任务。', + '记录 bookingNo、containerNo,并立即运行 manual-pda-ready-verify。', + ], + } +} diff --git a/scripts/admin-test-env-modules/manual-pda-ready-verify.mjs b/scripts/admin-test-env-modules/manual-pda-ready-verify.mjs new file mode 100644 index 000000000..d268ef348 --- /dev/null +++ b/scripts/admin-test-env-modules/manual-pda-ready-verify.mjs @@ -0,0 +1,122 @@ +import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' + +const MODULE = 'manual-pda-ready-verify' +const OPTIONS = { scenario: 'manual-pda-ready-readonly-verification', deploymentBlocker: true } +const SMART = '/api/platform/v1/first-leg/smart-loading' +const FIRST_LEG = '/api/platform/v1/first-leg' +const CONTAINERS = '/api/platform/v1/containers' +const PDA = `${FIRST_LEG}/pda-loading` + +function id(value) { return value == null ? '' : String(value) } +function rows(value) { return Array.isArray(value) ? value : [] } +function positiveId(value) { return /^[1-9][0-9]*$/.test(id(value)) } +function itemSnapshot(value) { + return rows(value).map((item) => ({ id: id(item.id), inboundId: id(item.inboundId), + packageCount: Number(item.packageCount) })) + .sort((left, right) => left.inboundId.localeCompare(right.inboundId) || left.id.localeCompare(right.id)) +} + +async function loadFixture(context) { + const report = JSON.parse(await readFile(resolve(context.options.manualPdaSourceReport), 'utf8')) + const fixture = report?.createdResources?.manualPdaReadyFixture + const sourceGateway = String(report?.gateway || '').replace(/\/$/, '') + const valid = sourceGateway === context.options.gateway && fixture?.schemaVersion === 1 + && fixture?.stage === 'MANUAL_BOOKING_LINK_CONFIRM_READY' && fixture?.loadingPlanId + && fixture?.containerBookingId && Array.isArray(fixture?.beforeItems) + && Array.isArray(fixture?.expectedAssignments) && fixture.expectedAssignments.length > 0 + context.assert(MODULE, '读取同环境 PDA 就绪准备报告', null, valid, + `sourceMarker=${report?.marker || 'missing'}, sameGateway=${sourceGateway === context.options.gateway}, loadingPlanId=${id(fixture?.loadingPlanId) || 'missing'}`, + OPTIONS) + return valid ? fixture : null +} + +export async function run(context) { + const fixture = await loadFixture(context) + if (!fixture) return + const planId = id(fixture.loadingPlanId) + const containerId = id(fixture.containerBookingId) + + const planResponse = await context.request('GET', `${SMART}/plans/${encodeURIComponent(planId)}`) + const plan = context.apiSuccess(MODULE, '读取预装确认后的原智能装柜计划', planResponse, '', OPTIONS) + const afterItems = itemSnapshot(plan?.items) + context.assert(MODULE, '订舱关联和预装确认后计划身份、状态及明细稳定', planResponse, + id(plan?.id) === planId && id(plan?.bookingId || plan?.containerBookingId) === containerId + && plan?.status === 'EDITING' && Number(plan?.version) > Number(fixture.beforePlanVersion) + && JSON.stringify(afterItems) === JSON.stringify(fixture.beforeItems), + `status=${plan?.status || 'missing'}, beforeVersion=${fixture.beforePlanVersion}, afterVersion=${plan?.version ?? 'missing'}, items=${afterItems.length}`, + OPTIONS) + + const containerResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(containerId)}`) + const container = context.apiSuccess(MODULE, '读取关联真实柜号后的稳定柜次', containerResponse, '', OPTIONS) + const oceanBookingId = id(container?.oceanBookingId) + const realContainerNo = String(container?.containerNo || '') + context.assert(MODULE, '真实柜号和订舱原地补全且内部柜次身份不变', containerResponse, + id(container?.id) === containerId && container?.cabinetNo === fixture.cabinetNo + && realContainerNo.length > 0 && positiveId(oceanBookingId) + && Number(container?.version) > Number(fixture.beforeContainerVersion) + && id(container?.warehouseId) === id(fixture.warehouseId) && container?.channelCode === fixture.channelCode, + `containerId=${id(container?.id) || 'missing'}, cabinetNo=${container?.cabinetNo || 'missing'}, containerNo=${realContainerNo || 'missing'}, oceanBookingId=${oceanBookingId || 'missing'}`, + OPTIONS) + if (!positiveId(oceanBookingId) || !realContainerNo) return + + const bookingResponse = await context.request('GET', `/api/platform/v1/ocean-bookings/${encodeURIComponent(oceanBookingId)}`) + const booking = context.apiSuccess(MODULE, '读取关联后的 RELEASED 订舱与 allocation', bookingResponse, '', OPTIONS) + const allocation = rows(booking?.containers).find((item) => id(item.containerBookingId) === containerId + && item.containerNo === realContainerNo) + context.assert(MODULE, '订舱已放舱且唯一 allocation 指向原柜次', bookingResponse, + booking?.oceanBooking?.status === 'RELEASED' && positiveId(allocation?.requirementId) + && positiveId(allocation?.allocationId) + && rows(booking?.containers).filter((item) => id(item.containerBookingId) === containerId).length === 1, + `status=${booking?.oceanBooking?.status || 'missing'}, bookingNo=${booking?.oceanBooking?.bookingNo || 'missing'}, requirementId=${id(allocation?.requirementId) || 'missing'}, allocationId=${id(allocation?.allocationId) || 'missing'}`, + OPTIONS) + + const detailResponse = await context.request('GET', + `${FIRST_LEG}/loading-plans/${encodeURIComponent(planId)}?warehouseId=${encodeURIComponent(fixture.warehouseId)}`) + const detail = context.apiSuccess(MODULE, '读取整份预装确认结果', detailResponse, '', OPTIONS) + const taskId = id(detail?.summary?.readiness?.pdaTaskId) + context.assert(MODULE, '预装确认状态为 CONFIRMED 并返回唯一 PDA taskId', detailResponse, + id(detail?.summary?.planId) === planId && detail?.summary?.status === 'EDITING' + && detail?.summary?.readiness?.confirmationStatus === 'CONFIRMED' + && detail?.summary?.readiness?.canConfirm === false + && detail?.summary?.readiness?.readyForPda === true && positiveId(taskId), + `confirmationStatus=${detail?.summary?.readiness?.confirmationStatus || 'missing'}, readyForPda=${detail?.summary?.readiness?.readyForPda}, taskId=${taskId || 'missing'}`, + OPTIONS) + if (!positiveId(taskId)) return + + const taskResponse = await context.request('GET', + `${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(fixture.warehouseId)}`) + const task = context.apiSuccess(MODULE, '读取唯一待领取 PDA 任务', taskResponse, '', OPTIONS) + const plannedPackages = rows(task?.items).reduce((sum, item) => sum + Number(item.plannedPackageCount || 0), 0) + const expectedPackages = rows(fixture.expectedAssignments) + .reduce((sum, item) => sum + Number(item.expectedAllocatedPackageCount || 0), 0) + context.assert(MODULE, 'PDA 任务四元身份、未领取状态和计划箱数正确', taskResponse, + id(task?.id) === taskId && id(task?.loadingPlanId) === planId + && id(task?.containerBookingId) === containerId && task?.cabinetNo === fixture.cabinetNo + && task?.currentContainerNo === realContainerNo && task?.taskStatus === 'LOADING' + && task?.workflowStatus === 'UNASSIGNED' && task?.assignment == null + && rows(task?.items).length > 0 && plannedPackages === expectedPackages, + `taskId=${id(task?.id) || 'missing'}, taskStatus=${task?.taskStatus || 'missing'}, workflowStatus=${task?.workflowStatus || 'missing'}, plannedPackages=${plannedPackages}, expectedPackages=${expectedPackages}`, + OPTIONS) + + for (const assignment of fixture.expectedAssignments || []) { + const inboundResponse = await context.request('GET', + `/api/platform/inbounds/${encodeURIComponent(assignment.inboundId)}?tenantId=${encodeURIComponent(fixture.tenantId)}`) + const inbound = context.apiSuccess(MODULE, `${assignment.role} claim 前回查入库状态`, inboundResponse, '', OPTIONS) + context.assert(MODULE, `${assignment.role} 在 PDA 领取前仍为 WAIT_SPLIT`, inboundResponse, + id(inbound?.summary?.id) === id(assignment.inboundId) + && inbound?.summary?.processStatus === 'WAIT_SPLIT', + `inboundId=${id(inbound?.summary?.id) || 'missing'}, status=${inbound?.summary?.processStatus || 'missing'}`, + OPTIONS) + } + + context.createdResources.manualPdaReadyVerification = { + sourceMarker: fixture.sourceMarker, verifiedAt: new Date().toISOString(), stage: 'PDA_UNASSIGNED', + oceanBookingId, bookingNo: booking?.oceanBooking?.bookingNo || null, + requirementId: id(allocation?.requirementId), allocationId: id(allocation?.allocationId), + loadingPlanId: planId, containerBookingId: containerId, cabinetNo: fixture.cabinetNo, + containerNo: realContainerNo, pdaTaskId: taskId, tenantId: id(fixture.tenantId), + warehouseId: id(fixture.warehouseId), plannedPackageCount: plannedPackages, + nextStage: 'PDA 人工领取任务后进入 LOADING,再执行扫码、批量提交和待复核验收。', + } +} diff --git a/scripts/admin-test-env-modules/manual-preload-push-contract.test.mjs b/scripts/admin-test-env-modules/manual-preload-push-contract.test.mjs new file mode 100644 index 000000000..0c4d507cc --- /dev/null +++ b/scripts/admin-test-env-modules/manual-preload-push-contract.test.mjs @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import { parseArguments, validateExecutionOptions } from '../admin-test-env-acceptance.mjs' + +const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8') +const fixture = readFileSync(new URL('./manual-preload-push-fixture.mjs', import.meta.url), 'utf8') +const verify = readFileSync(new URL('./manual-preload-push-verify.mjs', import.meta.url), 'utf8') + +test('manual preload modules are optional and require an explicit source report', () => { + assert.match(entry, /OPTIONAL_MODULES\s*=\s*\[[^\]]*'manual-preload-push-fixture'[^\]]*'manual-preload-push-verify'/s) + const defaults = parseArguments([]).modules + assert.equal(defaults.includes('manual-preload-push-fixture'), false) + assert.equal(defaults.includes('manual-preload-push-verify'), false) + for (const module of ['manual-preload-push-fixture', 'manual-preload-push-verify']) { + const options = parseArguments(['--gateway', 'http://127.0.0.1:9000', '--username', 'admin', + '--password', 'password-value', '--modules', module, '--manual-preload-source-report', 'source.json']) + assert.deepEqual(options.modules, [module]) + assert.equal(validateExecutionOptions(options), options) + assert.throws(() => validateExecutionOptions({ ...options, manualPreloadSourceReport: '' }), + /manual-preload-source-report/) + } +}) + +test('preload preparation is read only and freezes the pre-push plan snapshot', () => { + assert.match(fixture, /manualOrderSplittingVerification/) + assert.match(fixture, /beforePlanVersion/) + assert.match(fixture, /beforeItems/) + assert.match(fixture, /未点击推送前不存在预装 bridge/) + assert.doesNotMatch(fixture, /context\.request\(['"](?:POST|PATCH|PUT|DELETE)/) +}) + +test('post-push verifier proves EDITING bridge visibility and PDA A0404 without writes', () => { + assert.match(verify, /Number\(plan\?\.version\) > Number\(fixture\.beforePlanVersion\)/) + assert.match(verify, /allocatedPackageCount/) + assert.match(verify, /preloadStatus === 'PENDING'/) + assert.match(verify, /readiness\?\.canConfirm === false/) + assert.match(verify, /codeOf\(taskResponse\) === 'A0404'/) + assert.match(verify, /container\?\.containerNo == null/) + assert.doesNotMatch(verify, /context\.request\(['"](?:POST|PATCH|PUT|DELETE)/) +}) diff --git a/scripts/admin-test-env-modules/manual-preload-push-fixture.mjs b/scripts/admin-test-env-modules/manual-preload-push-fixture.mjs new file mode 100644 index 000000000..a702e30b9 --- /dev/null +++ b/scripts/admin-test-env-modules/manual-preload-push-fixture.mjs @@ -0,0 +1,70 @@ +import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' + +const MODULE = 'manual-preload-push-fixture' +const OPTIONS = { scenario: 'manual-preload-push-fixture', deploymentBlocker: true } +const SMART = '/api/platform/v1/first-leg/smart-loading' +const CONTAINERS = '/api/platform/v1/containers' + +function id(value) { return value == null ? '' : String(value) } +function rows(value) { return Array.isArray(value) ? value : [] } +function itemSnapshot(items) { + return rows(items).map((item) => ({ id: id(item.id), inboundId: id(item.inboundId), + packageCount: Number(item.packageCount) })) + .sort((left, right) => left.inboundId.localeCompare(right.inboundId) || left.id.localeCompare(right.id)) +} + +async function loadSource(context) { + const report = JSON.parse(await readFile(resolve(context.options.manualPreloadSourceReport), 'utf8')) + const verification = report?.createdResources?.manualOrderSplittingVerification + const sourceGateway = String(report?.gateway || '').replace(/\/$/, '') + const valid = sourceGateway === context.options.gateway && verification?.loadingPlanId + && verification?.containerBookingId && Array.isArray(verification?.verifiedAssignments) + context.assert(MODULE, '读取同环境人工分柜核验报告', null, valid, + `sourceMarker=${report?.marker || 'missing'}, sameGateway=${sourceGateway === context.options.gateway}, loadingPlanId=${id(verification?.loadingPlanId) || 'missing'}`, + OPTIONS) + return valid ? verification : null +} + +export async function run(context) { + const source = await loadSource(context) + if (!source) return + const planId = id(source.loadingPlanId) + const containerId = id(source.containerBookingId) + + const planResponse = await context.request('GET', `${SMART}/plans/${encodeURIComponent(planId)}`) + const plan = context.apiSuccess(MODULE, '推送前读取智能装柜计划', planResponse, '', OPTIONS) + const items = itemSnapshot(plan?.items) + context.assert(MODULE, '计划仍处于未推送 EDITING 且分柜明细完整', planResponse, + id(plan?.id) === planId && id(plan?.bookingId || plan?.containerBookingId) === containerId + && plan?.status === 'EDITING' && Number.isInteger(Number(plan?.version)) && items.length > 0, + `planId=${id(plan?.id) || 'missing'}, status=${plan?.status || 'missing'}, version=${plan?.version ?? 'missing'}, items=${items.length}`, + OPTIONS) + if (!plan || plan.status !== 'EDITING' || items.length === 0) return + + const linksResponse = await context.request('GET', `${SMART}/plans/${encodeURIComponent(planId)}/preloads`) + const links = context.apiSuccess(MODULE, '推送前回查尚未建立预装 bridge', linksResponse, '', OPTIONS) + context.assert(MODULE, '未点击推送前不存在预装 bridge', linksResponse, + rows(links).length === 0, `linkCount=${rows(links).length}`, OPTIONS) + + const containerResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(containerId)}`) + const container = context.apiSuccess(MODULE, '推送前读取内部模拟柜', containerResponse, '', OPTIONS) + context.assert(MODULE, '当前仍是无真实柜号且未关联订舱的内部模拟柜', containerResponse, + id(container?.id) === containerId && container?.containerNo == null && container?.oceanBookingId == null + && id(container?.warehouseId) === id(source.warehouseId) && container?.channelCode === source.channelCode, + `containerId=${id(container?.id) || 'missing'}, cabinetNo=${container?.cabinetNo || 'missing'}, containerNo=${container?.containerNo || 'null'}, oceanBookingId=${id(container?.oceanBookingId) || 'null'}`, + OPTIONS) + + context.createdResources.manualPreloadPushFixture = { + schemaVersion: 1, sourceMarker: source.sourceMarker, preparedAt: new Date().toISOString(), + stage: 'MANUAL_PUSH_PRELOAD_READY', loadingPlanId: planId, containerBookingId: containerId, + cabinetNo: container?.cabinetNo || null, tenantId: id(source.tenantId), warehouseId: id(source.warehouseId), + channelCode: source.channelCode, beforePlanVersion: Number(plan.version), beforeItems: items, + expectedAssignments: source.verifiedAssignments, + instructions: [ + '在智能装柜计划页面点击推送预装。', + '本节点保持无真实集装箱号、无订舱关联,不执行计划确认。', + '推送成功后不要继续录入柜号或创建 PDA 任务,立即运行 manual-preload-push-verify。', + ], + } +} diff --git a/scripts/admin-test-env-modules/manual-preload-push-verify.mjs b/scripts/admin-test-env-modules/manual-preload-push-verify.mjs new file mode 100644 index 000000000..e89d8834e --- /dev/null +++ b/scripts/admin-test-env-modules/manual-preload-push-verify.mjs @@ -0,0 +1,106 @@ +import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' + +const MODULE = 'manual-preload-push-verify' +const OPTIONS = { scenario: 'manual-preload-push-readonly-verification', deploymentBlocker: true } +const SMART = '/api/platform/v1/first-leg/smart-loading' +const FIRST_LEG = '/api/platform/v1/first-leg' +const CONTAINERS = '/api/platform/v1/containers' +const PDA = `${FIRST_LEG}/pda-loading` + +function id(value) { return value == null ? '' : String(value) } +function rows(value) { return Array.isArray(value) ? value : [] } +function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } +function itemSnapshot(items) { + return rows(items).map((item) => ({ id: id(item.id), inboundId: id(item.inboundId), + packageCount: Number(item.packageCount) })) + .sort((left, right) => left.inboundId.localeCompare(right.inboundId) || left.id.localeCompare(right.id)) +} + +async function loadFixture(context) { + const report = JSON.parse(await readFile(resolve(context.options.manualPreloadSourceReport), 'utf8')) + const fixture = report?.createdResources?.manualPreloadPushFixture + const sourceGateway = String(report?.gateway || '').replace(/\/$/, '') + const valid = sourceGateway === context.options.gateway && fixture?.schemaVersion === 1 + && fixture?.stage === 'MANUAL_PUSH_PRELOAD_READY' && fixture?.loadingPlanId + && fixture?.containerBookingId && Array.isArray(fixture?.beforeItems) + context.assert(MODULE, '读取同环境推送预装准备报告', null, valid, + `sourceMarker=${report?.marker || 'missing'}, sameGateway=${sourceGateway === context.options.gateway}, loadingPlanId=${id(fixture?.loadingPlanId) || 'missing'}`, + OPTIONS) + return valid ? fixture : null +} + +export async function run(context) { + const fixture = await loadFixture(context) + if (!fixture) return + const planId = id(fixture.loadingPlanId) + const containerId = id(fixture.containerBookingId) + + const planResponse = await context.request('GET', `${SMART}/plans/${encodeURIComponent(planId)}`) + const plan = context.apiSuccess(MODULE, '读取人工推送后的智能装柜计划', planResponse, '', OPTIONS) + const afterItems = itemSnapshot(plan?.items) + context.assert(MODULE, '无号推送后计划保持 EDITING、版本递增且明细零漂移', planResponse, + id(plan?.id) === planId && id(plan?.bookingId || plan?.containerBookingId) === containerId + && plan?.status === 'EDITING' && plan?.containerNo == null + && Number(plan?.version) > Number(fixture.beforePlanVersion) + && JSON.stringify(afterItems) === JSON.stringify(fixture.beforeItems), + `status=${plan?.status || 'missing'}, beforeVersion=${fixture.beforePlanVersion}, afterVersion=${plan?.version ?? 'missing'}, items=${afterItems.length}`, + OPTIONS) + if (!plan || plan.status !== 'EDITING') return + + const linksResponse = await context.request('GET', `${SMART}/plans/${encodeURIComponent(planId)}/preloads`) + const links = context.apiSuccess(MODULE, '回查推送原子建立的预装 bridge', linksResponse, '', OPTIONS) + const linkRows = rows(links) + for (const expected of fixture.expectedAssignments || []) { + const matched = linkRows.filter((link) => id(link.inboundId) === id(expected.inboundId)) + const allocated = matched.reduce((sum, link) => sum + Number(link.allocatedPackageCount || 0), 0) + context.assert(MODULE, `${expected.role} 预装 bridge 数量与人工分柜一致`, linksResponse, + matched.length >= 1 && allocated === Number(expected.expectedAllocatedPackageCount) + && matched.every((link) => id(link.loadingPlanId) === planId + && id(link.containerBookingId) === containerId && link.preloadStatus === 'PENDING' + && link.loadingPlanStatus === 'EDITING' && Number(link.loadingPlanVersion) === Number(plan.version)), + `inboundId=${id(expected.inboundId)}, expected=${expected.expectedAllocatedPackageCount}, actual=${allocated}, links=${matched.length}`, + OPTIONS) + } + const expectedInboundIds = new Set((fixture.expectedAssignments || []).map((item) => id(item.inboundId))) + const unexpectedLinks = linkRows.filter((link) => !expectedInboundIds.has(id(link.inboundId))) + context.assert(MODULE, '预装 bridge 未混入人工分柜清单外入库单', linksResponse, + unexpectedLinks.length === 0 && linkRows.length >= expectedInboundIds.size, + `linkCount=${linkRows.length}, unexpectedInboundIds=${unexpectedLinks.map((link) => id(link.inboundId)).join(',') || 'none'}`, + OPTIONS) + + const detailResponse = await context.request('GET', + `${FIRST_LEG}/loading-plans/${encodeURIComponent(planId)}?warehouseId=${encodeURIComponent(fixture.warehouseId)}`) + const detail = context.apiSuccess(MODULE, '读取推送后正式预装清单详情', detailResponse, '', OPTIONS) + context.assert(MODULE, '预装清单保持 EDITING 且无号阶段不能确认或创建 PDA', detailResponse, + id(detail?.summary?.planId) === planId && detail?.summary?.status === 'EDITING' + && detail?.summary?.readiness?.canConfirm === false + && detail?.summary?.readiness?.readyForPda === false + && detail?.summary?.readiness?.pdaTaskId == null, + `planId=${id(detail?.summary?.planId) || 'missing'}, status=${detail?.summary?.status || 'missing'}, canConfirm=${detail?.summary?.readiness?.canConfirm}, readyForPda=${detail?.summary?.readiness?.readyForPda}`, + OPTIONS) + + const taskResponse = await context.request('GET', + `${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(fixture.warehouseId)}`) + context.addStep(MODULE, '无真实柜号推送后 PDA 查询仍为 A0404', taskResponse, + taskResponse.http === 200 && codeOf(taskResponse) === 'A0404', + `HTTP=${taskResponse.http}, code=${codeOf(taskResponse)}, requestId=${taskResponse.requestId || 'missing'}, traceId=${taskResponse.traceId || 'missing'}`, + OPTIONS) + + const containerResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(containerId)}`) + const container = context.apiSuccess(MODULE, '回查推送后内部柜次身份', containerResponse, '', OPTIONS) + context.assert(MODULE, '推送预装不伪造真实柜号或订舱关联', containerResponse, + id(container?.id) === containerId && container?.cabinetNo === fixture.cabinetNo + && container?.containerNo == null && container?.oceanBookingId == null, + `containerId=${id(container?.id) || 'missing'}, cabinetNo=${container?.cabinetNo || 'missing'}, containerNo=${container?.containerNo || 'null'}, oceanBookingId=${id(container?.oceanBookingId) || 'null'}`, + OPTIONS) + + context.createdResources.manualPreloadPushVerification = { + sourceMarker: fixture.sourceMarker, verifiedAt: new Date().toISOString(), stage: 'PRELOAD_EDITING', + loadingPlanId: planId, containerBookingId: containerId, cabinetNo: fixture.cabinetNo, + tenantId: id(fixture.tenantId), warehouseId: id(fixture.warehouseId), channelCode: fixture.channelCode, + planVersion: Number(plan.version), preloadLinkCount: linkRows.length, pdaTaskCreated: false, + expectedAssignments: fixture.expectedAssignments, + nextStage: '关联 RELEASED 订舱与真实柜号后,人工执行整份预装计划确认。', + } +} diff --git a/scripts/admin-test-env-modules/批量待分柜数据生成指南.md b/scripts/admin-test-env-modules/批量待分柜数据生成指南.md index 2e52faa80..26f2c6e41 100644 --- a/scripts/admin-test-env-modules/批量待分柜数据生成指南.md +++ b/scripts/admin-test-env-modules/批量待分柜数据生成指南.md @@ -7,7 +7,7 @@ - 用于开发、联调或经明确授权的验收环境准备人工操作数据。 - 生成结果固定为入库单 `processStatus=WAIT_SPLIT`、测量审核 `APPROVED`。 - 单次支持 `1-100` 条;更大批量分多次执行并分别保存报告。 -- 不负责后续分柜、装柜、订舱、国内出仓、海外入仓或 PDA 收货。 +- 基础批量模式不自动执行后续分柜、装柜、订舱、国内出仓、海外入仓或 PDA 收货;需要人工分柜验收时使用本文后半部分的准备与只读核验入口。 - 禁止用于生产环境。对任何共享环境执行前,必须在当前对话明确获得该环境的写数据授权。 ## 固定项与随机项 @@ -121,6 +121,286 @@ node scripts/admin-test-env-acceptance.mjs ` 报告生成前会扫描密码、Token、文件二进制、base64 和 OCR 原文等敏感内容。报告仍属于本地验收证据,不应提交到源码仓库。 +## 人工分柜验收 + +### 1. 生成分柜准备清单 + +人工分柜模块默认生成 6 票同客户、同起运仓、同渠道的 `WAIT_SPLIT` 数据,并在报告的 `createdResources.manualOrderSplittingFixture` 中给出: + +- 一票 `PARTIAL`:只分配部分箱数,固定保留 1 箱; +- 两票 `FULL_1/FULL_2`:整票加入同一个柜,验证多订单拼柜; +- 一票 `UNASSIGNED_CONTROL`:不得加入计划,用于核对未分配对照和候选余量; +- 全部订单、入库单、入仓码、箱数、客户、仓库和渠道信息。 + +复用既有客户时执行: + +```powershell +node scripts/admin-test-env-acceptance.mjs ` + --modules manual-order-splitting-fixture ` + --merchant-username $env:MERCHANT_TEST_USERNAME ` + --merchant-password $env:MERCHANT_TEST_PASSWORD ` + --origin-warehouse-name '<固定国内仓名称>' ` + --channel-name '<固定渠道名称>' ` + --channel-code '<固定渠道编码>' ` + --order-profile-mode fixed ` + --wait-split-count 6 ` + --output '<人工分柜准备报告绝对路径>.json' +``` + +`manual-order-splitting-fixture` 会自动展开 `orders → inbound-measurement`。本入口通过正式 HTTP 接口写入测试数据,仍需当前对话明确授权目标环境。 + +### 2. Admin 人工操作 + +根据准备报告执行: + +1. 打开智能装柜页面,创建与清单一致的起运仓、渠道模拟柜。 +2. 创建智能装柜计划。 +3. 严格按 `assignments[].expectedAllocatedPackageCount` 分配三票入仓码。 +4. 不要选择 `unassignedControl`。 +5. 保存计划但暂不推送预装,记录页面或接口返回的 `loadingPlanId`。 + +此阶段只是“计划分柜”。父子订单和最终实装数量要到 PDA 实装结算后形成,不能在本节点提前宣称已经完成订单拆分。 + +### 3. 人工操作后只读核验 + +```powershell +node scripts/admin-test-env-acceptance.mjs ` + --modules manual-order-splitting-verify ` + --manual-split-fixture-report '<人工分柜准备报告绝对路径>.json' ` + --manual-split-plan-id '<页面记录的 loadingPlanId>' ` + --output '<人工分柜核验报告绝对路径>.json' +``` + +核验模块只发送 GET 请求,检查: + +- 计划与柜次 ID 有效,计划仍处于可继续验收的 `EDITING/LOADING`; +- 柜次仓库、渠道与准备清单一致; +- `PARTIAL/FULL_1/FULL_2` 的计划箱数精确匹配; +- 未混入清单外入库单,`UNASSIGNED_CONTROL` 未被误选; +- 候选回查不出现异渠道或异仓入库单; +- 每票分配后的候选剩余箱数满足数量守恒。 + +核验报告会写入 `createdResources.manualOrderSplittingVerification`,记录来源 marker、计划、柜次和已核验分配,不保存凭据。 + +## 人工推送预装验收 + +完成上一节人工分柜核验后,继续验证“点击推送预装,但尚无真实柜号、未关联订舱”的节点。 + +### 1. 生成推送前操作清单 + +使用上一节生成的“人工分柜核验报告”: + +```powershell +node scripts/admin-test-env-acceptance.mjs ` + --modules manual-preload-push-fixture ` + --manual-preload-source-report '<人工分柜核验报告绝对路径>.json' ` + --output '<推送预装前准备报告绝对路径>.json' +``` + +该模块只发送 GET 请求,并确认: + +- 智能装柜计划仍为 `EDITING`; +- 人工分柜明细存在且版本有效; +- 尚未建立预装 bridge; +- 柜次仍为无真实 `containerNo`、无 `oceanBookingId` 的内部模拟柜。 + +成功后,报告中的 `createdResources.manualPreloadPushFixture` 会保存推送前计划版本、明细快照和操作说明。 + +### 2. Admin 人工推送 + +在同一智能装柜计划上点击“推送预装”。本节点只做这一项操作: + +- 不录入真实集装箱号; +- 不创建或关联订舱; +- 不确认预装计划; +- 不领取或创建 PDA 任务。 + +推送成功后立即执行下一步只读核验。 + +### 3. 推送后只读核验 + +```powershell +node scripts/admin-test-env-acceptance.mjs ` + --modules manual-preload-push-verify ` + --manual-preload-source-report '<推送预装前准备报告绝对路径>.json' ` + --output '<推送预装核验报告绝对路径>.json' +``` + +核验内容包括: + +- 计划仍为 `EDITING`,版本相对推送前递增,分柜明细没有漂移; +- 每票入库单生成 `PENDING` 预装 bridge,箱数与人工分柜一致; +- 正式预装清单详情可见; +- 无真实柜号阶段 `canConfirm=false`、`readyForPda=false`; +- PDA 任务查询返回 `A0404`; +- 推送没有伪造真实柜号、订舱关联或改变稳定柜次身份。 + +通过后报告写入 `createdResources.manualPreloadPushVerification`,下一节点再处理订舱放舱、真实柜号关联和整份预装计划确认。 + +## 人工订舱关联、预装确认与 PDA 待领取验收 + +### 1. 冻结操作前基线 + +以上一节“推送预装核验报告”为来源: + +```powershell +node scripts/admin-test-env-acceptance.mjs ` + --modules manual-pda-ready-fixture ` + --manual-pda-source-report '<推送预装核验报告绝对路径>.json' ` + --output '.json' +``` + +准备模块只发送 GET 请求,确认: + +- 已推送计划仍为 `EDITING`,版本和分柜明细没有变化; +- 原柜次仍无真实柜号、无订舱关联; +- 当前 `canConfirm=false`、`readyForPda=false`; +- PDA 查询仍返回 `A0404`。 + +报告的 `createdResources.manualPdaReadyFixture` 保存柜次、计划、版本和明细基线。 + +### 2. Admin 人工操作 + +严格按以下顺序操作: + +1. 创建与准备清单客户、起运仓、渠道、柜型一致的海运订舱 DRAFT,并补齐提单号、船名、航次、ETD、ETA、参与方和柜型需求。 +2. 点击放舱,使订舱进入 `RELEASED`。此时不应自动生成真实柜次或 PDA 任务。 +3. 在原内部柜次上选择该订舱未占用的柜型需求,录入唯一真实 `containerNo` 完成关联;不得新建替代柜次。 +4. 回到预装详情,确认门禁变为 `canConfirm=true`,再执行整份预装计划确认。 +5. 确认成功后不要领取 PDA 任务,记录订舱号和真实柜号并立即执行核验。 + +### 3. 只读核验 PDA 待领取状态 + +```powershell +node scripts/admin-test-env-acceptance.mjs ` + --modules manual-pda-ready-verify ` + --manual-pda-source-report '.json' ` + --output '.json' +``` + +核验内容包括: + +- 订舱为 `RELEASED`,且 allocation 唯一指向原 `containerBookingId`; +- 真实柜号在原柜次上补全,`cabinetNo/loadingPlanId/items` 不漂移; +- 整份预装为 `CONFIRMED`、`readyForPda=true`,并返回唯一 `pdaTaskId`; +- PDA task 四元身份正确,`taskStatus=LOADING`、`workflowStatus=UNASSIGNED`; +- PDA 计划箱数与人工分柜总箱数一致; +- 尚未领取任务时各来源入库单仍保持 `WAIT_SPLIT`。 + +通过后报告写入 `createdResources.manualPdaReadyVerification`。下一节点进入 PDA 人工领取、扫码、批量提交和待复核验收。 + +## PDA 人工领取、扫码和批量提交验收 + +### 1. 冻结 PDA 作业前基线 + +以上一节“PDA 待领取核验报告”为来源: + +```powershell +node scripts/admin-test-env-acceptance.mjs ` + --modules manual-pda-batch-fixture ` + --manual-pda-batch-source-report '.json' ` + --output '.json' +``` + +准备模块只发送 GET 请求,确认并记录: + +- 任务为 `UNASSIGNED`,尚未绑定作业员; +- PDA 待办中只有同一稳定 `taskId`; +- 每条任务明细包含稳定 `boxCodes`,数量足以覆盖计划箱数; +- 库存快照的任务版本和四元身份一致; +- 操作历史尚无 `CLAIM/BATCH/REVIEW/COMPLETE_LOADING`。 + +报告的 `createdResources.manualPdaBatchFixture.taskItems[].scanBoxCodes` 提供本轮应扫描箱码,`boxCodes` 保留服务端完整下发集合;库存 `snapshotToken` 不写入报告,PDA 提交前必须实时重新获取。 + +### 2. PDA 真机人工操作 + +1. 使用目标仓库作业员进入 PDA 待办并领取任务,确认页面进入作业中。 +2. 按准备报告对应任务明细,在真机逐箱扫描稳定箱码;同一箱码不能重复扫描。 +3. 所有明细均按计划箱数完成,本节点不使用 `PARTIAL` 或 `SKIP`。 +4. 点击提交时由 PDA 重新获取库存快照,并只发送一次原子 batch。 +5. 成功后停在 `AWAITING_REVIEW`,不要上传五类证据,不要复核或执行最终完成。 + +服务端 batch 只接收明细与数量,不保存逐次扫码轨迹。因此自动报告只能证明稳定箱码已下发和服务端数量已收敛,真机逐箱扫码过程必须保留人工证据。 + +### 3. 批量提交后只读核验 + +```powershell +node scripts/admin-test-env-acceptance.mjs ` + --modules manual-pda-batch-verify ` + --manual-pda-batch-source-report '.json' ` + --output '.json' +``` + +核验内容包括: + +- 任务已绑定真实作业员,版本递增; +- 所有明细均为 `PICKED`,已拣箱数等于计划箱数; +- 任务保持 `taskStatus=LOADING`,工作流进入 `AWAITING_REVIEW`; +- pending 与 detail 均显示零条未处理明细; +- 操作历史恰有一次 `CLAIM`、一次 `BATCH`,没有 `REVIEW/COMPLETE_LOADING`; +- 各入库单服务端汇总 `expectedCount=pickedCount`; +- 待复核取证 GET 已开放。 + +通过后报告写入 `createdResources.manualPdaBatchVerification`,并明确标记逐次扫码仍需真机证据。下一节点处理五类装柜证据和 PDA 最终完成。 + +## 五类装柜证据、PDA 最终完成与 LOADED 验收 + +### 1. 冻结最终完成前基线 + +以上一节“PDA 待复核核验报告”为来源: + +```powershell +node scripts/admin-test-env-acceptance.mjs ` + --modules manual-loading-complete-fixture ` + --manual-loading-complete-source-report '.json' ` + --output '<装柜最终完成前准备报告绝对路径>.json' +``` + +准备模块只发送 GET 请求,确认: + +- PDA 任务为 `AWAITING_REVIEW`,所有明细已拣齐; +- 装柜计划仍为 `LOADING/IN_PROGRESS`; +- 任务审计只有一次 `CLAIM` 和一次 `BATCH`; +- 当前证据归属入库单没有 ACTIVE 装柜证据; +- 真实柜号和 `RELEASED` 订舱门禁仍有效。 + +报告的 `createdResources.manualLoadingCompleteFixture` 会固定证据归属、五类证据要求和最终完成前版本。 + +### 2. PDA 真机人工操作 + +在同一 PDA 任务内现场完成: + +1. 拍摄空仓照 `PHOTO_STEP_1`。 +2. 拍摄半仓照 `PHOTO_STEP_2`。 +3. 拍摄满仓照 `PHOTO_STEP_3`。 +4. 拍摄锁仓照 `PHOTO_STEP_4`。 +5. 拍摄带锁码照 `SEAL`,并填写非空锁码。 +6. 确认五类均为 ACTIVE 后,只点击一次最终完成。 + +不要调用旧入库单级兼容完成入口;当前唯一正式动作是 PDA task 的最终完成。成功后不要继续修改柜号、计划或证据。 + +### 3. LOADED 终态只读核验 + +```powershell +node scripts/admin-test-env-acceptance.mjs ` + --modules manual-loading-complete-verify ` + --manual-loading-complete-source-report '<装柜最终完成前准备报告绝对路径>.json' ` + --output '.json' +``` + +核验内容包括: + +- 五类 ACTIVE 证据各一份,SEAL 锁码非空且每张图片可鉴权下载; +- 任务 `taskStatus/workflowStatus` 均为 `COMPLETED`; +- 操作历史恰有一次 `COMPLETE_LOADING`,并保留原 CLAIM/BATCH; +- 原装柜计划进入 `COMPLETED`; +- 所有来源入库单进入 `LOADED`,状态历史包含 `LOADING → LOADED`; +- 对应统一订单详情投影为 `LOADED`; +- 柜次实装分片唯一,实装箱数等于 PDA 已拣总箱数; +- 货柜总览聚合 COMPLETED PDA 和五类 ACTIVE 证据。 + +通过后报告写入 `createdResources.manualLoadingCompleteVerification`。至此,本指南覆盖的国内人工验收链已经从订单造数连续到装柜 `LOADED`;后续报关、国内出仓和海外接柜另按对应节点扩展。 + ## 失败、重跑与批量拆分 - 批次遇到第一条失败即停止,已经成功创建的订单不会自动删除或回滚。 @@ -148,7 +428,17 @@ node scripts/admin-test-env-acceptance.mjs ` ```powershell node --check scripts/admin-test-env-acceptance.mjs node --check scripts/admin-test-env-modules/orders.mjs -node --test scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs +node --check scripts/admin-test-env-modules/manual-order-splitting-fixture.mjs +node --check scripts/admin-test-env-modules/manual-order-splitting-verify.mjs +node --check scripts/admin-test-env-modules/manual-preload-push-fixture.mjs +node --check scripts/admin-test-env-modules/manual-preload-push-verify.mjs +node --check scripts/admin-test-env-modules/manual-pda-ready-fixture.mjs +node --check scripts/admin-test-env-modules/manual-pda-ready-verify.mjs +node --check scripts/admin-test-env-modules/manual-pda-batch-fixture.mjs +node --check scripts/admin-test-env-modules/manual-pda-batch-verify.mjs +node --check scripts/admin-test-env-modules/manual-loading-complete-fixture.mjs +node --check scripts/admin-test-env-modules/manual-loading-complete-verify.mjs +node --test scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs scripts/admin-test-env-modules/inbound-measurement-box-code-contract.test.mjs scripts/admin-test-env-modules/manual-order-splitting-contract.test.mjs scripts/admin-test-env-modules/manual-preload-push-contract.test.mjs scripts/admin-test-env-modules/manual-pda-ready-contract.test.mjs scripts/admin-test-env-modules/manual-pda-batch-contract.test.mjs scripts/admin-test-env-modules/manual-loading-complete-contract.test.mjs git diff --check ```